feat: connection manager — hosts + groups CRUD with search

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:08:00 -04:00
parent 3b9a0118b5
commit 41411b0cb8
9 changed files with 293 additions and 0 deletions

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HostsService } from './hosts.service';
import { HostsController } from './hosts.controller';
import { GroupsService } from './groups.service';
import { GroupsController } from './groups.controller';
@Module({
providers: [HostsService, GroupsService],
controllers: [HostsController, GroupsController],
exports: [HostsService],
})
export class ConnectionsModule {}

View File

@ -0,0 +1,14 @@
import { IsString, IsInt, IsOptional } from 'class-validator';
export class CreateGroupDto {
@IsString()
name: string;
@IsInt()
@IsOptional()
parentId?: number;
@IsInt()
@IsOptional()
sortOrder?: number;
}

View File

@ -0,0 +1,41 @@
import { IsString, IsInt, IsOptional, IsEnum, IsArray, Min, Max } from 'class-validator';
import { Protocol } from '@prisma/client';
export class CreateHostDto {
@IsString()
name: string;
@IsString()
hostname: string;
@IsInt()
@Min(1)
@Max(65535)
@IsOptional()
port?: number;
@IsEnum(Protocol)
@IsOptional()
protocol?: Protocol;
@IsInt()
@IsOptional()
groupId?: number;
@IsInt()
@IsOptional()
credentialId?: number;
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
@IsString()
@IsOptional()
notes?: string;
@IsString()
@IsOptional()
color?: string;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateGroupDto } from './create-group.dto';
export class UpdateGroupDto extends PartialType(CreateGroupDto) {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateHostDto } from './create-host.dto';
export class UpdateHostDto extends PartialType(CreateHostDto) {}

View File

@ -0,0 +1,41 @@
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, ParseIntPipe } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { GroupsService } from './groups.service';
import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto';
@UseGuards(JwtAuthGuard)
@Controller('groups')
export class GroupsController {
constructor(private groups: GroupsService) {}
@Get()
findAll() {
return this.groups.findAll();
}
@Get('tree')
findTree() {
return this.groups.findTree();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.groups.findOne(id);
}
@Post()
create(@Body() dto: CreateGroupDto) {
return this.groups.create(dto);
}
@Put(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupDto) {
return this.groups.update(id, dto);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.groups.remove(id);
}
}

View File

@ -0,0 +1,60 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateGroupDto } from './dto/create-group.dto';
import { UpdateGroupDto } from './dto/update-group.dto';
@Injectable()
export class GroupsService {
constructor(private prisma: PrismaService) {}
findAll() {
return this.prisma.hostGroup.findMany({
include: { children: true, hosts: { select: { id: true, name: true, protocol: true } } },
orderBy: { sortOrder: 'asc' },
});
}
findTree() {
return this.prisma.hostGroup.findMany({
where: { parentId: null },
include: {
hosts: { orderBy: { sortOrder: 'asc' } },
children: {
include: {
hosts: { orderBy: { sortOrder: 'asc' } },
children: {
include: {
hosts: { orderBy: { sortOrder: 'asc' } },
},
},
},
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { sortOrder: 'asc' },
});
}
async findOne(id: number) {
const group = await this.prisma.hostGroup.findUnique({
where: { id },
include: { hosts: true, children: true },
});
if (!group) throw new NotFoundException(`Group ${id} not found`);
return group;
}
create(dto: CreateGroupDto) {
return this.prisma.hostGroup.create({ data: dto });
}
async update(id: number, dto: UpdateGroupDto) {
await this.findOne(id);
return this.prisma.hostGroup.update({ where: { id }, data: dto });
}
async remove(id: number) {
await this.findOne(id);
return this.prisma.hostGroup.delete({ where: { id } });
}
}

View File

@ -0,0 +1,41 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, ParseIntPipe } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { HostsService } from './hosts.service';
import { CreateHostDto } from './dto/create-host.dto';
import { UpdateHostDto } from './dto/update-host.dto';
@UseGuards(JwtAuthGuard)
@Controller('hosts')
export class HostsController {
constructor(private hosts: HostsService) {}
@Get()
findAll(@Query('search') search?: string) {
return this.hosts.findAll(search);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.hosts.findOne(id);
}
@Post()
create(@Body() dto: CreateHostDto) {
return this.hosts.create(dto);
}
@Put(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateHostDto) {
return this.hosts.update(id, dto);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.hosts.remove(id);
}
@Post('reorder')
reorder(@Body() body: { ids: number[] }) {
return this.hosts.reorder(body.ids);
}
}

View File

@ -0,0 +1,76 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateHostDto } from './dto/create-host.dto';
import { UpdateHostDto } from './dto/update-host.dto';
@Injectable()
export class HostsService {
constructor(private prisma: PrismaService) {}
findAll(search?: string) {
const where = search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' as const } },
{ hostname: { contains: search, mode: 'insensitive' as const } },
{ tags: { has: search } },
],
}
: {};
return this.prisma.host.findMany({
where,
include: { group: true, credential: { select: { id: true, name: true, type: true } } },
orderBy: [{ lastConnectedAt: { sort: 'desc', nulls: 'last' } }, { sortOrder: 'asc' }],
});
}
async findOne(id: number) {
const host = await this.prisma.host.findUnique({
where: { id },
include: { group: true, credential: true },
});
if (!host) throw new NotFoundException(`Host ${id} not found`);
return host;
}
create(dto: CreateHostDto) {
return this.prisma.host.create({
data: {
name: dto.name,
hostname: dto.hostname,
port: dto.port ?? (dto.protocol === 'rdp' ? 3389 : 22),
protocol: dto.protocol ?? 'ssh',
groupId: dto.groupId,
credentialId: dto.credentialId,
tags: dto.tags ?? [],
notes: dto.notes,
color: dto.color,
},
include: { group: true },
});
}
async update(id: number, dto: UpdateHostDto) {
await this.findOne(id); // throws if not found
return this.prisma.host.update({ where: { id }, data: dto });
}
async remove(id: number) {
await this.findOne(id);
return this.prisma.host.delete({ where: { id } });
}
async touchLastConnected(id: number) {
return this.prisma.host.update({
where: { id },
data: { lastConnectedAt: new Date() },
});
}
async reorder(ids: number[]) {
const updates = ids.map((id, index) =>
this.prisma.host.update({ where: { id }, data: { sortOrder: index } }),
);
return this.prisma.$transaction(updates);
}
}