mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 09:02:02 +03:00
feat: Add --localTime option to logs command for local timezone display (#13818)
* feat: add --localTime options to make logs to show time with local time zone fix #12447 * fix: prep logs local-time option and docs (#13818) (thanks @xialonglee) --------- Co-authored-by: xialonglee <li.xialong@xydigit.com> Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
- Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut.
|
- Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut.
|
||||||
|
- CLI: add `openclaw logs --local-time` (plus `--localTime` compatibility alias) to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
|
||||||
|
|
||||||
## 2026.2.9
|
## 2026.2.9
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,8 @@ openclaw logs
|
|||||||
openclaw logs --follow
|
openclaw logs --follow
|
||||||
openclaw logs --json
|
openclaw logs --json
|
||||||
openclaw logs --limit 500
|
openclaw logs --limit 500
|
||||||
|
openclaw logs --local-time
|
||||||
|
openclaw logs --follow --local-time
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `--local-time` to render timestamps in your local timezone. `--localTime` is supported as a compatibility alias.
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ describe("extractMessagingToolSend", () => {
|
|||||||
|
|
||||||
expect(result?.tool).toBe("message");
|
expect(result?.tool).toBe("message");
|
||||||
expect(result?.provider).toBe("slack");
|
expect(result?.provider).toBe("slack");
|
||||||
expect(result?.to).toBe("channel:c1");
|
expect(result?.to).toBe("channel:C1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { formatLogTimestamp } from "./logs-cli.js";
|
||||||
|
|
||||||
const callGatewayFromCli = vi.fn();
|
const callGatewayFromCli = vi.fn();
|
||||||
|
|
||||||
@@ -53,6 +54,40 @@ describe("logs cli", () => {
|
|||||||
expect(stderrWrites.join("")).toContain("Log cursor reset");
|
expect(stderrWrites.join("")).toContain("Log cursor reset");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wires --local-time through CLI parsing and emits local timestamps", async () => {
|
||||||
|
callGatewayFromCli.mockResolvedValueOnce({
|
||||||
|
file: "/tmp/openclaw.log",
|
||||||
|
lines: [
|
||||||
|
JSON.stringify({
|
||||||
|
time: "2025-01-01T12:00:00.000Z",
|
||||||
|
_meta: { logLevelName: "INFO", name: JSON.stringify({ subsystem: "gateway" }) },
|
||||||
|
0: "line one",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdoutWrites: string[] = [];
|
||||||
|
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
||||||
|
stdoutWrites.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registerLogsCli } = await import("./logs-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerLogsCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["logs", "--local-time", "--plain"], { from: "user" });
|
||||||
|
|
||||||
|
stdoutSpy.mockRestore();
|
||||||
|
|
||||||
|
const output = stdoutWrites.join("");
|
||||||
|
expect(output).toContain("line one");
|
||||||
|
const timestamp = output.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?/u)?.[0];
|
||||||
|
expect(timestamp).toBeTruthy();
|
||||||
|
expect(timestamp?.endsWith("Z")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("warns when the output pipe closes", async () => {
|
it("warns when the output pipe closes", async () => {
|
||||||
callGatewayFromCli.mockResolvedValueOnce({
|
callGatewayFromCli.mockResolvedValueOnce({
|
||||||
file: "/tmp/openclaw.log",
|
file: "/tmp/openclaw.log",
|
||||||
@@ -82,4 +117,49 @@ describe("logs cli", () => {
|
|||||||
|
|
||||||
expect(stderrWrites.join("")).toContain("output stdout closed");
|
expect(stderrWrites.join("")).toContain("output stdout closed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("formatLogTimestamp", () => {
|
||||||
|
it("formats UTC timestamp in plain mode by default", () => {
|
||||||
|
const result = formatLogTimestamp("2025-01-01T12:00:00.000Z");
|
||||||
|
expect(result).toBe("2025-01-01T12:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats UTC timestamp in pretty mode", () => {
|
||||||
|
const result = formatLogTimestamp("2025-01-01T12:00:00.000Z", "pretty");
|
||||||
|
expect(result).toBe("12:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats local time in plain mode when localTime is true", () => {
|
||||||
|
const utcTime = "2025-01-01T12:00:00.000Z";
|
||||||
|
const result = formatLogTimestamp(utcTime, "plain", true);
|
||||||
|
// Should be local time without 'Z' suffix
|
||||||
|
expect(result).not.toContain("Z");
|
||||||
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||||
|
// The exact time depends on timezone, but should be different from UTC
|
||||||
|
expect(result).not.toBe(utcTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats local time in pretty mode when localTime is true", () => {
|
||||||
|
const utcTime = "2025-01-01T12:00:00.000Z";
|
||||||
|
const result = formatLogTimestamp(utcTime, "pretty", true);
|
||||||
|
// Should be HH:MM:SS format
|
||||||
|
expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
// Should be different from UTC time (12:00:00) if not in UTC timezone
|
||||||
|
const tzOffset = new Date(utcTime).getTimezoneOffset();
|
||||||
|
if (tzOffset !== 0) {
|
||||||
|
expect(result).not.toBe("12:00:00");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty or invalid timestamps", () => {
|
||||||
|
expect(formatLogTimestamp(undefined)).toBe("");
|
||||||
|
expect(formatLogTimestamp("")).toBe("");
|
||||||
|
expect(formatLogTimestamp("invalid-date")).toBe("invalid-date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves original value for invalid dates", () => {
|
||||||
|
const result = formatLogTimestamp("not-a-date");
|
||||||
|
expect(result).toBe("not-a-date");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+23
-5
@@ -26,6 +26,7 @@ type LogsCliOptions = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
plain?: boolean;
|
plain?: boolean;
|
||||||
color?: boolean;
|
color?: boolean;
|
||||||
|
localTime?: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
@@ -59,7 +60,11 @@ async function fetchLogs(
|
|||||||
return payload as LogsTailPayload;
|
return payload as LogsTailPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") {
|
export function formatLogTimestamp(
|
||||||
|
value?: string,
|
||||||
|
mode: "pretty" | "plain" = "plain",
|
||||||
|
localTime = false,
|
||||||
|
) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -67,10 +72,18 @@ function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain")
|
|||||||
if (Number.isNaN(parsed.getTime())) {
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (mode === "pretty") {
|
let timeString: string;
|
||||||
return parsed.toISOString().slice(11, 19);
|
if (localTime) {
|
||||||
|
const tzoffset = parsed.getTimezoneOffset() * 60000; // offset in milliseconds
|
||||||
|
const localISOTime = new Date(parsed.getTime() - tzoffset).toISOString().slice(0, -1);
|
||||||
|
timeString = localISOTime;
|
||||||
|
} else {
|
||||||
|
timeString = parsed.toISOString();
|
||||||
}
|
}
|
||||||
return parsed.toISOString();
|
if (mode === "pretty") {
|
||||||
|
return timeString.slice(11, 19);
|
||||||
|
}
|
||||||
|
return timeString;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogLine(
|
function formatLogLine(
|
||||||
@@ -78,6 +91,7 @@ function formatLogLine(
|
|||||||
opts: {
|
opts: {
|
||||||
pretty: boolean;
|
pretty: boolean;
|
||||||
rich: boolean;
|
rich: boolean;
|
||||||
|
localTime: boolean;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const parsed = parseLogLine(raw);
|
const parsed = parseLogLine(raw);
|
||||||
@@ -85,7 +99,7 @@ function formatLogLine(
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
const label = parsed.subsystem ?? parsed.module ?? "";
|
const label = parsed.subsystem ?? parsed.module ?? "";
|
||||||
const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain");
|
const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain", opts.localTime);
|
||||||
const level = parsed.level ?? "";
|
const level = parsed.level ?? "";
|
||||||
const levelLabel = level.padEnd(5).trim();
|
const levelLabel = level.padEnd(5).trim();
|
||||||
const message = parsed.message || parsed.raw;
|
const message = parsed.message || parsed.raw;
|
||||||
@@ -192,6 +206,8 @@ export function registerLogsCli(program: Command) {
|
|||||||
.option("--json", "Emit JSON log lines", false)
|
.option("--json", "Emit JSON log lines", false)
|
||||||
.option("--plain", "Plain text output (no ANSI styling)", false)
|
.option("--plain", "Plain text output (no ANSI styling)", false)
|
||||||
.option("--no-color", "Disable ANSI colors")
|
.option("--no-color", "Disable ANSI colors")
|
||||||
|
.option("--local-time", "Display timestamps in local timezone", false)
|
||||||
|
.option("--localTime", "Alias for --local-time", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
() =>
|
() =>
|
||||||
@@ -208,6 +224,7 @@ export function registerLogsCli(program: Command) {
|
|||||||
const jsonMode = Boolean(opts.json);
|
const jsonMode = Boolean(opts.json);
|
||||||
const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain;
|
const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain;
|
||||||
const rich = isRich() && opts.color !== false;
|
const rich = isRich() && opts.color !== false;
|
||||||
|
const localTime = Boolean(opts.localTime);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let payload: LogsTailPayload;
|
let payload: LogsTailPayload;
|
||||||
@@ -279,6 +296,7 @@ export function registerLogsCli(program: Command) {
|
|||||||
formatLogLine(line, {
|
formatLogLine(line, {
|
||||||
pretty,
|
pretty,
|
||||||
rich,
|
rich,
|
||||||
|
localTime,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user