-
Notifications
You must be signed in to change notification settings - Fork 19
feat(compute): compute logs <id> — container stdout/stderr from the CLI
#287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
4a8805e
51e7e36
7574fca
ae71eba
db86956
5e2168d
7d29de0
01f4759
6b0dcea
6bbdf43
525a8b7
bd25fde
cd3bb32
2cb375d
1b94f6f
e514b59
e1ca55c
e784f82
67ba77d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; | ||
| import type * as ErrorsModule from '../../lib/errors.js'; | ||
|
|
||
| const ossFetchMock = vi.hoisted(() => vi.fn()); | ||
| const outputJsonMock = vi.hoisted(() => vi.fn()); | ||
| vi.mock('../../lib/api/oss.js', () => ({ ossFetch: ossFetchMock })); | ||
| vi.mock('../../lib/credentials.js', () => ({ requireAuth: vi.fn().mockResolvedValue(undefined) })); | ||
| vi.mock('../../lib/skills.js', () => ({ reportCliUsage: vi.fn() })); | ||
| vi.mock('../../lib/output.js', () => ({ outputJson: outputJsonMock })); | ||
| vi.mock('../../lib/errors.js', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof ErrorsModule>(); | ||
| return { | ||
| ...actual, | ||
| handleError: (err: unknown) => { throw err; }, | ||
| }; | ||
| }); | ||
|
|
||
| import { Command } from 'commander'; | ||
| import { registerComputeLogsCommand, formatLogLine } from './logs.js'; | ||
|
|
||
| function run(args: string[]) { | ||
| const cmd = new Command(); | ||
| cmd.exitOverride(); | ||
| cmd.option('--json'); | ||
| const compute = cmd.command('compute'); | ||
| registerComputeLogsCommand(compute); | ||
| return cmd.parseAsync(['node', 'insforge', ...args]); | ||
| } | ||
|
|
||
| describe('compute logs', () => { | ||
| let logSpy: ReturnType<typeof vi.spyOn>; | ||
| beforeEach(() => { | ||
| ossFetchMock.mockReset(); | ||
| outputJsonMock.mockReset(); | ||
| logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); | ||
| }); | ||
| afterEach(() => logSpy.mockRestore()); | ||
|
|
||
| it('calls the container logs endpoint with a clamped limit', async () => { | ||
| ossFetchMock.mockResolvedValueOnce({ json: async () => ({ lines: [], nextToken: null }) }); | ||
| await run(['compute', 'logs', 'my api', '--limit', '5000']); | ||
| expect(ossFetchMock).toHaveBeenCalledWith('/api/compute/services/my%20api/logs?limit=1000'); | ||
| expect(logSpy).toHaveBeenCalledWith('No logs found.'); | ||
| }); | ||
|
|
||
| it('forwards --next-token as next_token', async () => { | ||
| ossFetchMock.mockResolvedValueOnce({ json: async () => ({ lines: [], nextToken: null }) }); | ||
| await run(['compute', 'logs', 'svc', '--next-token', 'abc']); | ||
| expect(ossFetchMock.mock.calls[0][0]).toBe('/api/compute/services/svc/logs?limit=100&next_token=abc'); | ||
| }); | ||
|
|
||
| it('prints formatted lines', async () => { | ||
| ossFetchMock.mockResolvedValueOnce({ | ||
| json: async () => ({ | ||
| lines: [{ timestamp: 0, message: 'hello', region: 'sjc', instance: 'abc123' }], | ||
| nextToken: 'tok', | ||
| }), | ||
| }); | ||
| await run(['compute', 'logs', 'svc']); | ||
| expect(logSpy).toHaveBeenCalledWith('1970-01-01T00:00:00.000Z [sjc abc123] hello'); | ||
| }); | ||
|
|
||
| it('emits the full result (with cursor) under --json', async () => { | ||
| const payload = { lines: [{ timestamp: 1, message: 'x' }], nextToken: 'tok' }; | ||
| ossFetchMock.mockResolvedValueOnce({ json: async () => payload }); | ||
| await run(['--json', 'compute', 'logs', 'svc']); | ||
| expect(outputJsonMock).toHaveBeenCalledWith(payload); | ||
| }); | ||
|
|
||
| it('formatLogLine omits the bracket when no region/instance', () => { | ||
| expect(formatLogLine({ timestamp: 0, message: 'm' })).toBe('1970-01-01T00:00:00.000Z m'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import type { Command } from 'commander'; | ||
| import { ossFetch } from '../../lib/api/oss.js'; | ||
| import { requireAuth } from '../../lib/credentials.js'; | ||
| import { handleError, getRootOpts } from '../../lib/errors.js'; | ||
| import { outputJson } from '../../lib/output.js'; | ||
| import { reportCliUsage } from '../../lib/skills.js'; | ||
|
|
||
| // `compute logs <id>` returns container stdout/stderr ("application logs") — | ||
| // the same data the dashboard Logs panel shows. For machine lifecycle events | ||
| // (start/stop/exit/restart) use `compute events <id>` instead. | ||
| // | ||
| // Endpoint: GET /api/compute/services/:id/logs?limit=&next_token= | ||
| // Response: { lines: { timestamp, message, instance?, region? }[], nextToken: string | null } | ||
| // `nextToken` is an opaque forward cursor; `--follow` polls with it. The | ||
| // endpoint is rate-limited server-side (dashboard polls every ~2s), so the | ||
| // follow interval stays at 2s. | ||
|
|
||
| export interface ComputeLogLine { | ||
| timestamp: number; | ||
| message: string; | ||
| instance?: string; | ||
| region?: string; | ||
| } | ||
|
|
||
| export interface ComputeLogsResult { | ||
| lines: ComputeLogLine[]; | ||
| nextToken: string | null; | ||
| } | ||
|
|
||
| const FOLLOW_INTERVAL_MS = 2000; | ||
|
|
||
| export function formatLogLine(line: ComputeLogLine): string { | ||
| const ts = new Date(line.timestamp).toISOString(); | ||
| const where = [line.region, line.instance].filter(Boolean).join(' '); | ||
| return where ? `${ts} [${where}] ${line.message}` : `${ts} ${line.message}`; | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| export async function fetchComputeLogs( | ||
| id: string, | ||
| opts: { limit: number; nextToken?: string }, | ||
| ): Promise<ComputeLogsResult> { | ||
| const params = new URLSearchParams({ limit: String(opts.limit) }); | ||
| if (opts.nextToken) params.set('next_token', opts.nextToken); | ||
| const res = await ossFetch( | ||
| `/api/compute/services/${encodeURIComponent(id)}/logs?${params.toString()}`, | ||
| ); | ||
| const body = await res.json() as Partial<ComputeLogsResult> | null; | ||
| return { | ||
| lines: Array.isArray(body?.lines) ? body.lines : [], | ||
| nextToken: typeof body?.nextToken === 'string' && body.nextToken.length > 0 ? body.nextToken : null, | ||
| }; | ||
| } | ||
|
|
||
| export function registerComputeLogsCommand(computeCmd: Command): void { | ||
| computeCmd | ||
| .command('logs <id>') | ||
| .description('Get compute service container logs (stdout/stderr)') | ||
| .option('--limit <n>', 'Max number of log lines per fetch (1-1000)', '100') | ||
| .option('-f, --follow', 'Keep polling for new lines (Ctrl+C to stop)') | ||
| .option('--next-token <token>', 'Resume from a cursor returned by a previous --json call') | ||
| .action(async (id: string, opts, cmd) => { | ||
| const { json } = getRootOpts(cmd); | ||
| try { | ||
| await requireAuth(); | ||
|
|
||
| const limit = Math.max(1, Math.min(Number(opts.limit) || 100, 1000)); | ||
| let result = await fetchComputeLogs(id, { limit, nextToken: opts.nextToken }); | ||
|
|
||
| if (json && !opts.follow) { | ||
| outputJson(result); | ||
| await reportCliUsage('cli.compute.logs', true); | ||
| return; | ||
| } | ||
|
|
||
| const print = (lines: ComputeLogLine[]) => { | ||
| for (const line of lines) { | ||
| console.log(json ? JSON.stringify(line) : formatLogLine(line)); | ||
| } | ||
|
Comment on lines
+169
to
+172
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When Knowledge Base Used: CLI command runtime |
||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (result.lines.length === 0 && !opts.follow) { | ||
| console.log('No logs found.'); | ||
| await reportCliUsage('cli.compute.logs', true); | ||
| return; | ||
| } | ||
| print(result.lines); | ||
|
|
||
| if (opts.follow) { | ||
| if (!json) console.error('Following logs... (Ctrl+C to stop)'); | ||
| let token = result.nextToken; | ||
| while (true) { | ||
| await new Promise((r) => setTimeout(r, FOLLOW_INTERVAL_MS)); | ||
| result = await fetchComputeLogs(id, { limit, nextToken: token ?? undefined }); | ||
| print(result.lines); | ||
| if (result.nextToken) token = result.nextToken; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| await reportCliUsage('cli.compute.logs', true); | ||
| } catch (err) { | ||
| await reportCliUsage('cli.compute.logs', false); | ||
| handleError(err, json); | ||
| } | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.