feat(cli): R3 — tunnel status/logs/info/rotate (completa spec 8.7 tunnels)

- tunnel.mjs: adiciona runTunnelStatusCommand, runTunnelLogsCommand,
  runTunnelInfoCommand, runTunnelRotateCommand
- 4 novos subcomandos: status (uptime/requests/latency), logs (--tail),
  info (config completo JSON/table), rotate (novo URL com confirmação)
- locales: tunnel.statusDescription/logsDescription/infoDescription/
  rotateDescription/tailOpt/typeRequired/noLogs/infoTitle/rotated/confirmRotate
- testes: valida exports e registro dos 7 subcomandos (list/create/stop+novos)
This commit is contained in:
diegosouzapw
2026-05-15 08:32:41 -03:00
parent d577759002
commit d8445caf90
4 changed files with 244 additions and 4 deletions

View File

@@ -31,13 +31,53 @@ export function registerTunnel(program) {
tunnel
.command("stop <type>")
.description("Stop a tunnel")
.option("--yes", "Skip confirmation")
.description(t("tunnel.stopDescription"))
.option("--yes", t("common.yesOpt"))
.action(async (type, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runTunnelStopCommand(type, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
tunnel
.command("status <type>")
.description(t("tunnel.statusDescription"))
.option("--json", t("common.jsonOpt"))
.action(async (type, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runTunnelStatusCommand(type, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
tunnel
.command("logs <type>")
.description(t("tunnel.logsDescription"))
.option("--tail <n>", t("tunnel.tailOpt"), "50")
.action(async (type, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runTunnelLogsCommand(type, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
tunnel
.command("info <type>")
.description(t("tunnel.infoDescription"))
.option("--json", t("common.jsonOpt"))
.action(async (type, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runTunnelInfoCommand(type, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
tunnel
.command("rotate <type>")
.description(t("tunnel.rotateDescription"))
.option("--yes", t("common.yesOpt"))
.action(async (type, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runTunnelRotateCommand(type, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
}
export async function runTunnelListCommand(opts = {}) {
@@ -149,3 +189,159 @@ export async function runTunnelStopCommand(type, opts = {}) {
return 1;
}
}
export async function runTunnelStatusCommand(type, opts = {}) {
if (!type) {
console.error(t("tunnel.typeRequired"));
return 1;
}
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
try {
const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}/status`, {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
const data = await res.json();
if (opts.json || opts.output === "json") {
console.log(JSON.stringify(data, null, 2));
return 0;
}
const uptime = data.uptime ? `${Math.floor(data.uptime / 60)}m` : "N/A";
const statusLabel = data.active ? "\x1b[32m● active\x1b[0m" : "\x1b[31m○ inactive\x1b[0m";
console.log(`\n\x1b[1m${type}\x1b[0m ${statusLabel}`);
console.log(` URL: ${data.url || "N/A"}`);
console.log(` Uptime: ${uptime}`);
console.log(` Requests: ${data.requests ?? data.totalRequests ?? "N/A"}`);
console.log(` Latency: ${data.avgLatencyMs != null ? `${data.avgLatencyMs}ms` : "N/A"}`);
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runTunnelLogsCommand(type, opts = {}) {
if (!type) {
console.error(t("tunnel.typeRequired"));
return 1;
}
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
const tail = Number(opts.tail || 50);
try {
const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}/logs?tail=${tail}`, {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
const data = await res.json();
const lines = data.logs || data.lines || data;
if (!Array.isArray(lines) || lines.length === 0) {
console.log(t("tunnel.noLogs"));
return 0;
}
for (const line of lines) {
const ts = line.timestamp || line.ts || "";
const msg = line.message || line.msg || String(line);
console.log(`\x1b[2m${ts}\x1b[0m ${msg}`);
}
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runTunnelInfoCommand(type, opts = {}) {
if (!type) {
console.error(t("tunnel.typeRequired"));
return 1;
}
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
try {
const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}`, {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
const data = await res.json();
if (opts.json || opts.output === "json") {
console.log(JSON.stringify(data, null, 2));
return 0;
}
console.log(`\n\x1b[1m\x1b[36m${t("tunnel.infoTitle", { type })}\x1b[0m\n`);
for (const [k, v] of Object.entries(data)) {
console.log(` ${String(k).padEnd(20)} ${JSON.stringify(v)}`);
}
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runTunnelRotateCommand(type, opts = {}) {
if (!type) {
console.error(t("tunnel.typeRequired"));
return 1;
}
if (!opts.yes) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question(t("tunnel.confirmRotate", { type }) + " [y/N] ", resolve)
);
rl.close();
if (!/^y(es)?$/i.test(answer)) {
console.log(t("common.cancelled"));
return 0;
}
}
const serverUp = await isServerUp();
if (!serverUp) {
console.error(t("common.serverOffline"));
return 1;
}
try {
const res = await apiFetch(`/api/tunnels/${encodeURIComponent(type)}/rotate`, {
method: "POST",
retry: false,
timeout: 15000,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}
const data = await res.json();
console.log(t("tunnel.rotated", { url: data.url || "(see dashboard)" }));
return 0;
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}

View File

@@ -405,7 +405,18 @@
"title": "Tunnels",
"created": "Tunnel created: {url}",
"stopped": "Tunnel stopped.",
"confirmStop": "Stop tunnel {id}?"
"confirmStop": "Stop tunnel {id}?",
"stopDescription": "Stop a tunnel",
"statusDescription": "Show detailed status of a tunnel",
"logsDescription": "Show logs for a tunnel",
"infoDescription": "Show configuration details of a tunnel",
"rotateDescription": "Generate a new tunnel URL",
"tailOpt": "Number of log lines to show",
"typeRequired": "Tunnel type is required.",
"noLogs": "No logs available.",
"infoTitle": "Tunnel info: {type}",
"rotated": "Tunnel URL rotated: {url}",
"confirmRotate": "Rotate tunnel {type}? (a new URL will be generated)"
},
"stop": {
"description": "Stop the OmniRoute server",

View File

@@ -405,7 +405,18 @@
"title": "Túneis",
"created": "Túnel criado: {url}",
"stopped": "Túnel parado.",
"confirmStop": "Parar túnel {id}?"
"confirmStop": "Parar túnel {id}?",
"stopDescription": "Parar um túnel",
"statusDescription": "Exibir status detalhado de um túnel",
"logsDescription": "Exibir logs de um túnel",
"infoDescription": "Exibir configuração detalhada de um túnel",
"rotateDescription": "Gerar nova URL para o túnel",
"tailOpt": "Número de linhas de log a exibir",
"typeRequired": "Tipo de túnel é obrigatório.",
"noLogs": "Nenhum log disponível.",
"infoTitle": "Info do túnel: {type}",
"rotated": "URL do túnel rotacionada: {url}",
"confirmRotate": "Rotacionar túnel {type}? (uma nova URL será gerada)"
},
"stop": {
"description": "Parar o servidor OmniRoute",

View File

@@ -51,6 +51,28 @@ test("provider-store.mjs exporta removeProviderConnectionByProvider", async () =
assert.equal(typeof mod.removeProviderConnectionByProvider, "function");
});
test("tunnel.mjs exporta subcomandos status/logs/info/rotate", async () => {
const mod = await import("../../bin/cli/commands/tunnel.mjs");
assert.equal(typeof mod.registerTunnel, "function");
assert.equal(typeof mod.runTunnelStatusCommand, "function");
assert.equal(typeof mod.runTunnelLogsCommand, "function");
assert.equal(typeof mod.runTunnelInfoCommand, "function");
assert.equal(typeof mod.runTunnelRotateCommand, "function");
});
test("tunnel — registerTunnel registra list/create/stop/status/logs/info/rotate", async () => {
const { registerTunnel } = await import("../../bin/cli/commands/tunnel.mjs");
const { Command } = await import("commander");
const prog = new Command().exitOverride();
registerTunnel(prog);
const tunnelCmd = prog.commands.find((c) => c.name() === "tunnel");
assert.ok(tunnelCmd, "tunnel command deve existir");
const names = tunnelCmd.commands.map((c) => c.name());
for (const sub of ["list", "create", "stop", "status", "logs", "info", "rotate"]) {
assert.ok(names.includes(sub), `tunnel ${sub} deve existir`);
}
});
test("health components com alertsOnly=true não lança", async () => {
// Server não está rodando — função deve retornar 1 sem throw.
const { runHealthComponentsCommand } = await import("../../bin/cli/commands/health.mjs");