Compare commits
4 Commits
feat/node-
...
fix/logs-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd1dfc629d | ||
| 7b3dab142e | |||
|
|
4c127a7dc3 | ||
| c1e3e4aed6 |
@@ -42,6 +42,7 @@ interface TemplateRow {
|
|||||||
interface InstanceRow {
|
interface InstanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
server?: { name: string };
|
||||||
status: string;
|
status: string;
|
||||||
containerId: string | null;
|
containerId: string | null;
|
||||||
port: number | null;
|
port: number | null;
|
||||||
@@ -78,9 +79,9 @@ const templateColumns: Column<TemplateRow>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const instanceColumns: Column<InstanceRow>[] = [
|
const instanceColumns: Column<InstanceRow>[] = [
|
||||||
|
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
|
||||||
{ header: 'STATUS', key: 'status', width: 10 },
|
{ header: 'STATUS', key: 'status', width: 10 },
|
||||||
{ header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
|
{ header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
|
||||||
{ header: 'SERVER ID', key: 'serverId' },
|
|
||||||
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
|
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
|
||||||
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
|
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
|
||||||
{ header: 'ID', key: 'id' },
|
{ header: 'ID', key: 'id' },
|
||||||
|
|||||||
@@ -6,15 +6,84 @@ export interface LogsCommandDeps {
|
|||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InstanceInfo {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
containerId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a name/ID to an instance ID.
|
||||||
|
* Accepts: instance ID, server name, or server ID.
|
||||||
|
* For servers with multiple replicas, picks by --instance index or first RUNNING.
|
||||||
|
*/
|
||||||
|
async function resolveInstance(
|
||||||
|
client: ApiClient,
|
||||||
|
nameOrId: string,
|
||||||
|
instanceIndex?: number,
|
||||||
|
): Promise<{ instanceId: string; serverName?: string; replicaInfo?: string }> {
|
||||||
|
// Try as instance ID first
|
||||||
|
try {
|
||||||
|
await client.get(`/api/v1/instances/${nameOrId}`);
|
||||||
|
return { instanceId: nameOrId };
|
||||||
|
} catch {
|
||||||
|
// Not a valid instance ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as server name/ID → find its instances
|
||||||
|
const servers = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
|
||||||
|
const server = servers.find((s) => s.name === nameOrId || s.id === nameOrId);
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Instance or server '${nameOrId}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instances = await client.get<InstanceInfo[]>(`/api/v1/instances?serverId=${server.id}`);
|
||||||
|
if (instances.length === 0) {
|
||||||
|
throw new Error(`No instances found for server '${server.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select by index or pick first running
|
||||||
|
let selected: InstanceInfo | undefined;
|
||||||
|
if (instanceIndex !== undefined) {
|
||||||
|
if (instanceIndex < 0 || instanceIndex >= instances.length) {
|
||||||
|
throw new Error(`Instance index ${instanceIndex} out of range (server '${server.name}' has ${instances.length} instance${instances.length > 1 ? 's' : ''})`);
|
||||||
|
}
|
||||||
|
selected = instances[instanceIndex];
|
||||||
|
} else {
|
||||||
|
selected = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
throw new Error(`No instances found for server '${server.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: { instanceId: string; serverName?: string; replicaInfo?: string } = {
|
||||||
|
instanceId: selected.id,
|
||||||
|
serverName: server.name,
|
||||||
|
};
|
||||||
|
if (instances.length > 1) {
|
||||||
|
result.replicaInfo = `instance ${instances.indexOf(selected) + 1}/${instances.length}`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function createLogsCommand(deps: LogsCommandDeps): Command {
|
export function createLogsCommand(deps: LogsCommandDeps): Command {
|
||||||
const { client, log } = deps;
|
const { client, log } = deps;
|
||||||
|
|
||||||
return new Command('logs')
|
return new Command('logs')
|
||||||
.description('Get logs from an MCP server instance')
|
.description('Get logs from an MCP server instance')
|
||||||
.argument('<instance-id>', 'Instance ID')
|
.argument('<name>', 'Server name, server ID, or instance ID')
|
||||||
.option('-t, --tail <lines>', 'Number of lines to show')
|
.option('-t, --tail <lines>', 'Number of lines to show')
|
||||||
.action(async (id: string, opts: { tail?: string }) => {
|
.option('-i, --instance <index>', 'Instance/replica index (0-based, for servers with multiple replicas)')
|
||||||
let url = `/api/v1/instances/${id}/logs`;
|
.action(async (nameOrId: string, opts: { tail?: string; instance?: string }) => {
|
||||||
|
const instanceIndex = opts.instance !== undefined ? parseInt(opts.instance, 10) : undefined;
|
||||||
|
const { instanceId, serverName, replicaInfo } = await resolveInstance(client, nameOrId, instanceIndex);
|
||||||
|
|
||||||
|
if (replicaInfo) {
|
||||||
|
process.stderr.write(`Showing logs for ${serverName} (${replicaInfo})\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = `/api/v1/instances/${instanceId}/logs`;
|
||||||
if (opts.tail) {
|
if (opts.tail) {
|
||||||
url += `?tail=${opts.tail}`;
|
url += `?tail=${opts.tail}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,11 +69,13 @@ describe('get command', () => {
|
|||||||
|
|
||||||
it('lists instances with correct columns', async () => {
|
it('lists instances with correct columns', async () => {
|
||||||
const deps = makeDeps([
|
const deps = makeDeps([
|
||||||
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
{ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
||||||
]);
|
]);
|
||||||
const cmd = createGetCommand(deps);
|
const cmd = createGetCommand(deps);
|
||||||
await cmd.parseAsync(['node', 'test', 'instances']);
|
await cmd.parseAsync(['node', 'test', 'instances']);
|
||||||
|
expect(deps.output[0]).toContain('NAME');
|
||||||
expect(deps.output[0]).toContain('STATUS');
|
expect(deps.output[0]).toContain('STATUS');
|
||||||
|
expect(deps.output.join('\n')).toContain('my-grafana');
|
||||||
expect(deps.output.join('\n')).toContain('RUNNING');
|
expect(deps.output.join('\n')).toContain('RUNNING');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,16 +68,79 @@ describe('logs command', () => {
|
|||||||
output = [];
|
output = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows logs', async () => {
|
it('shows logs by instance ID', async () => {
|
||||||
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
|
vi.mocked(client.get)
|
||||||
|
.mockResolvedValueOnce({ id: 'inst-1', status: 'RUNNING' } as never) // instance lookup
|
||||||
|
.mockResolvedValueOnce({ stdout: 'hello world\n', stderr: '' } as never); // logs
|
||||||
const cmd = createLogsCommand({ client, log });
|
const cmd = createLogsCommand({ client, log });
|
||||||
await cmd.parseAsync(['inst-1'], { from: 'user' });
|
await cmd.parseAsync(['inst-1'], { from: 'user' });
|
||||||
|
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||||
expect(output.join('\n')).toContain('hello world');
|
expect(output.join('\n')).toContain('hello world');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves server name to instance ID', async () => {
|
||||||
|
vi.mocked(client.get)
|
||||||
|
.mockRejectedValueOnce(new Error('not found')) // instance lookup fails
|
||||||
|
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
|
||||||
|
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING', containerId: 'abc' }] as never) // instances for server
|
||||||
|
.mockResolvedValueOnce({ stdout: 'grafana logs\n', stderr: '' } as never); // logs
|
||||||
|
const cmd = createLogsCommand({ client, log });
|
||||||
|
await cmd.parseAsync(['my-grafana'], { from: 'user' });
|
||||||
|
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||||
|
expect(output.join('\n')).toContain('grafana logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks RUNNING instance over others', async () => {
|
||||||
|
vi.mocked(client.get)
|
||||||
|
.mockRejectedValueOnce(new Error('not found'))
|
||||||
|
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 'inst-err', status: 'ERROR', containerId: null },
|
||||||
|
{ id: 'inst-ok', status: 'RUNNING', containerId: 'abc' },
|
||||||
|
] as never)
|
||||||
|
.mockResolvedValueOnce({ stdout: 'running instance\n', stderr: '' } as never);
|
||||||
|
const cmd = createLogsCommand({ client, log });
|
||||||
|
await cmd.parseAsync(['ha-mcp'], { from: 'user' });
|
||||||
|
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-ok/logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects specific replica with --instance', async () => {
|
||||||
|
vi.mocked(client.get)
|
||||||
|
.mockRejectedValueOnce(new Error('not found'))
|
||||||
|
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ id: 'inst-0', status: 'RUNNING', containerId: 'a' },
|
||||||
|
{ id: 'inst-1', status: 'RUNNING', containerId: 'b' },
|
||||||
|
] as never)
|
||||||
|
.mockResolvedValueOnce({ stdout: 'replica 1\n', stderr: '' } as never);
|
||||||
|
const cmd = createLogsCommand({ client, log });
|
||||||
|
await cmd.parseAsync(['ha-mcp', '-i', '1'], { from: 'user' });
|
||||||
|
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on out-of-range --instance index', async () => {
|
||||||
|
vi.mocked(client.get)
|
||||||
|
.mockRejectedValueOnce(new Error('not found'))
|
||||||
|
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
|
||||||
|
.mockResolvedValueOnce([{ id: 'inst-0', status: 'RUNNING' }] as never);
|
||||||
|
const cmd = createLogsCommand({ client, log });
|
||||||
|
await expect(cmd.parseAsync(['ha-mcp', '-i', '5'], { from: 'user' })).rejects.toThrow('out of range');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when server has no instances', async () => {
|
||||||
|
vi.mocked(client.get)
|
||||||
|
.mockRejectedValueOnce(new Error('not found'))
|
||||||
|
.mockResolvedValueOnce([{ id: 'srv-1', name: 'empty-srv' }] as never)
|
||||||
|
.mockResolvedValueOnce([] as never);
|
||||||
|
const cmd = createLogsCommand({ client, log });
|
||||||
|
await expect(cmd.parseAsync(['empty-srv'], { from: 'user' })).rejects.toThrow('No instances found');
|
||||||
|
});
|
||||||
|
|
||||||
it('passes tail option', async () => {
|
it('passes tail option', async () => {
|
||||||
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
|
vi.mocked(client.get)
|
||||||
|
.mockResolvedValueOnce({ id: 'inst-1' } as never)
|
||||||
|
.mockResolvedValueOnce({ stdout: '', stderr: '' } as never);
|
||||||
const cmd = createLogsCommand({ client, log });
|
const cmd = createLogsCommand({ client, log });
|
||||||
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
|
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
|
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
|
|||||||
}
|
}
|
||||||
return this.prisma.mcpInstance.findMany({
|
return this.prisma.mcpInstance.findMany({
|
||||||
where,
|
where,
|
||||||
|
include: { server: { select: { name: true } } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user