mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user