Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion src/lib/nodes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import set from "./set.ts";
import extend from "./extend.ts";
import get from "./get.tsx";
import { addRedeploy } from "./redeploy.ts";
import ssh from "./ssh.ts";
import logs from "./logs.ts";
import { isFeatureEnabled } from "../posthog.ts";

export async function registerNodes(program: Command) {
Expand All @@ -25,7 +27,9 @@ export async function registerNodes(program: Command) {
.addCommand(extend)
.addCommand(release)
.addCommand(deleteCommand)
.addCommand(set);
.addCommand(set)
.addCommand(ssh)
.addCommand(logs);

const baseHelpText = nodes.helpInformation();

Expand Down Expand Up @@ -64,6 +68,18 @@ $ sf nodes set my-node-name --max-price 12.50

\x1b[2m# Extend a reserved node\x1b[0m
$ sf nodes extend my-node-name --duration 3600 --max-price 12.50

\x1b[2m# SSH into a node's current VM\x1b[0m
$ sf nodes ssh my-node-name

\x1b[2m# View logs from a node's current VM\x1b[0m
$ sf nodes logs my-node-name

\x1b[2m# SSH into a specific VM\x1b[0m
$ sf nodes ssh user@vm_xxxxxxxxxxxxxxxxxxxxx

\x1b[2m# View logs from a specific VM\x1b[0m
$ sf nodes logs -i vm_xxxxxxxxxxxxxxxxxxxxx
`,
)
// Add action to display help if no subcommand is provided
Expand Down
5 changes: 2 additions & 3 deletions src/lib/nodes/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { handleNodesError, nodesClient } from "../../nodesClient.ts";
import { Row } from "../Row.tsx";
import {
createNodesTable,
getLastVM,
getStatusColor,
getVMStatusColor,
jsonOption,
Expand Down Expand Up @@ -102,9 +103,7 @@ function getActionsForNode(node: SFCNodes.Node) {
const nodeActions: { label: string; command: string }[] = [];

// Get the last VM for logs/ssh commands
const lastVm = node.vms?.data?.sort((a, b) =>
(b.start_at ?? b.updated_at) - (a.start_at ?? a.updated_at)
).at(0);
const lastVm = getLastVM(node);

if (lastVm?.image_id) {
nodeActions.push({
Expand Down
262 changes: 262 additions & 0 deletions src/lib/nodes/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { Command, CommanderError } from "@commander-js/extra-typings";
import console from "node:console";
import { setTimeout } from "node:timers";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import ora from "ora";
import { cyan } from "jsr:@std/fmt/colors";
import process from "node:process";

import { getLastVM } from "./utils.ts";
import { apiClient } from "../../apiClient.ts";
import { getAuthToken } from "../../helpers/config.ts";
import {
logAndQuit,
logSessionTokenExpiredAndQuit,
} from "../../helpers/errors.ts";
import { paths } from "../../schema.ts";
import { handleNodesError, nodesClient } from "../../nodesClient.ts";

dayjs.extend(utc);

type VMLogsParams = paths["/v0/vms/logs2"]["get"]["parameters"]["query"];
type VMLogsResponse =
paths["/v0/vms/logs2"]["get"]["responses"]["200"]["content"][
"application/json"
]["data"];

function formatTimestampToISO(timestamp: string): string {
const date = dayjs(timestamp);
if (!date.isValid()) {
throw new CommanderError(
1,
"INVALID_TIMESTAMP_FORMAT",
`Invalid timestamp format: ${timestamp}. Please use RFC3339 format (e.g., 2023-01-01T00:00:00Z)`,
);
}
return date.toISOString();
}

const logs = new Command("logs")
.description("View or tail VM logs from a node")
.showHelpAfterError()
.argument("[node]", "Node name or ID to get logs from")
.option(
"-i, --instance <id>",
"VM instance ID (conflicts with node argument)",
)
.option(
"-l, --limit <number>",
"Number of log lines to fetch",
(val) => {
const parsedValue = Number(val);
if (
Number.isNaN(parsedValue) || !Number.isInteger(parsedValue) ||
parsedValue <= 0
) {
throw new CommanderError(
1,
"LIMIT_MUST_BE_A_POSITIVE_INTEGER",
"Limit must be a positive integer",
);
}
return parsedValue;
},
100,
)
.option(
"--before <timestamp>",
"Get logs older than this timestamp (descending)",
formatTimestampToISO,
)
.option(
"--since <timestamp>",
"Get logs newer than this timestamp (ascending)",
formatTimestampToISO,
)
.option("-f, --follow", "Continue polling newer logs (like tail -f)")
.addHelpText(
"after",
`
Examples:

\x1b[2m# Get logs for a node's current VM\x1b[0m
$ sf nodes logs my-node

\x1b[2m# Get logs for a specific VM ID\x1b[0m
$ sf nodes logs -i vm_xxxxxxxxxxxxxxxxxxxxx

\x1b[2m# Get last 200 log lines for a node\x1b[0m
$ sf nodes logs my-node --limit 200

\x1b[2m# Get logs before a given timestamp\x1b[0m
$ sf nodes logs my-node --before "2025-01-01"

\x1b[2m# Tail logs in real-time\x1b[0m
$ sf nodes logs my-node --follow

\x1b[2m# Get up to 300 logs between a 3 hour duration\x1b[0m
$ sf nodes logs my-node --since "2025-01-01T17:30:00" --before "2025-01-01T20:30:00" -l 300
`,
)
.action(async (node, options, cmd) => {
try {
// Validate that either node or instance is provided, but not both
if (!node && !options.instance) {
logAndQuit(
"Either a node name/ID or --instance flag must be provided",
);
}

if (node && options.instance) {
logAndQuit(
"Cannot specify both a node name/ID and --instance flag. Use one or the other.",
);
}

let vmId: string;

// If node is provided, fetch the node and get its current_vm
if (node) {
const nodesClientInstance = await nodesClient();
const spinner = ora("Fetching node information...").start();

try {
const nodeData = await nodesClientInstance.nodes.get(node);
spinner.succeed(`Node found for name ${cyan(node)}.`);

const lastVm = getLastVM(nodeData);

if (!lastVm) {
spinner.fail(
`Node ${
cyan(node)
} does not have a VM. VMs can take up to 5-10 minutes to spin up.`,
);
process.exit(1);
}

vmId = lastVm.id;
} catch {
spinner.info(
`No node found for name ${cyan(node)}. Interpreting as VM ID...`,
);
vmId = node;
}
} else {
vmId = options.instance!;
}

const client = await apiClient(await getAuthToken());

const fetchLogs = async (query: VMLogsParams) => {
const { response, data } = await client.GET("/v0/vms/logs2", {
params: { query },
});

if (response.status === 401) {
await logSessionTokenExpiredAndQuit();
}

if (!response.ok) {
logAndQuit(
`Failed to fetch logs: ${response.status} ${response.statusText}`,
);
}
return data;
};

const params: VMLogsParams = {
instance_id: vmId,
limit: options.limit,
since_realtime_timestamp: options.since,
before_realtime_timestamp: options.before,
order_by: "seqnum_asc",
};

let incompleteLine = "";
let lastTimestamp = "";

const processLogs = (logs: VMLogsResponse) => {
for (const log of logs) {
const timestamp = dayjs(log.realtime_timestamp).format(
"YYYY-MM-DD HH:mm:ss",
);
lastTimestamp = timestamp;

const chunkData = new TextDecoder("utf-8", { fatal: false }).decode(
new Uint8Array(log.data),
);

const fullData = incompleteLine + chunkData;
const lines = fullData.split("\n");

incompleteLine = fullData.endsWith("\n") ? "" : lines.pop() || "";

const prefix = `(instance ${log.instance_id}) [${timestamp}]`;
for (const line of lines) {
if (line.length > 0) {
console.log(`${prefix} ${line}`);
}
}
}
};

const flushIncompleteLine = () => {
if (incompleteLine.length > 0) {
console.log(
`(instance ${vmId}) [${lastTimestamp}] ${incompleteLine}`,
);
}
};

if (!options.follow) {
const spinner = ora("Fetching logs...").start();
const response = await fetchLogs(params);
if (response?.data?.length) {
spinner.succeed(`${response.data.length} logs fetched successfully.`);
processLogs(response.data);
} else {
spinner.info(
"No logs found. VMs take up to 10 minutes to spin-up, so it may not have started yet.",
);
}
return;
}

let sinceSeqnum: number | undefined;
const response = await fetchLogs(params);
if (response?.data?.length) {
processLogs(response.data);
sinceSeqnum = response.data[response.data.length - 1].seqnum + 1;
}

cmd.hook("postAction", flushIncompleteLine);

while (true) {
const newParams: VMLogsParams = {
instance_id: vmId,
limit: 2500,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Extract hardcoded 2500 limit into a named constant

Context Used: Rule from dashboard - Extract magic numbers into named constants at the top of files with documentation comments explainin... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/nodes/logs.ts
Line: 239:239

Comment:
**style:** Extract hardcoded 2500 limit into a named constant

**Context Used:** Rule from `dashboard` - Extract magic numbers into named constants at the top of files with documentation comments explainin... ([source](https://app.greptile.com/review/custom-context?memory=c50759b8-8cbe-4751-8359-e0fc9aa1254f))

How can I resolve this? If you propose a fix, please make it concise.

order_by: "seqnum_asc",
};

if (sinceSeqnum) {
newParams.since_seqnum = sinceSeqnum;
}

const newResponse = await fetchLogs(newParams);

if (newResponse?.data?.length) {
processLogs(newResponse.data);
sinceSeqnum = newResponse.data[newResponse.data.length - 1].seqnum +
1;
}

await new Promise((resolve) => setTimeout(resolve, 2000));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Extract 2000ms polling interval into a named constant

Context Used: Rule from dashboard - Extract magic numbers into named constants at the top of files with documentation comments explainin... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/nodes/logs.ts
Line: 255:255

Comment:
**style:** Extract 2000ms polling interval into a named constant

**Context Used:** Rule from `dashboard` - Extract magic numbers into named constants at the top of files with documentation comments explainin... ([source](https://app.greptile.com/review/custom-context?memory=c50759b8-8cbe-4751-8359-e0fc9aa1254f))

How can I resolve this? If you propose a fix, please make it concise.

}
} catch (err) {
handleNodesError(err);
}
});

export default logs;
Loading