feat: display platform agent status and version

This commit is contained in:
2026-08-06 16:02:27 +09:00
parent 4c8a9998b1
commit 89069b21ff
5 changed files with 96 additions and 3 deletions
@@ -0,0 +1,11 @@
ALTER TABLE "provisioned_devices"
ADD COLUMN "agent_version" TEXT,
ADD COLUMN "agent_updated_at" TIMESTAMPTZ(3),
ADD COLUMN "agent_last_seen_at" TIMESTAMPTZ(3);
CREATE INDEX "provisioned_devices_agent_last_seen_at_idx"
ON "provisioned_devices"("agent_last_seen_at");
COMMENT ON COLUMN "provisioned_devices"."agent_version" IS '에이전트가 마지막으로 보고한 실행 버전';
COMMENT ON COLUMN "provisioned_devices"."agent_updated_at" IS '현재 에이전트 버전이 처음 보고된 시각';
COMMENT ON COLUMN "provisioned_devices"."agent_last_seen_at" IS '에이전트 시작 시 업데이트 확인을 마지막으로 수행한 시각';
+4
View File
@@ -216,6 +216,9 @@ model ProvisionedDevice {
kernel String?
distro String?
device Json?
agentVersion String? @map("agent_version")
agentUpdatedAt DateTime? @map("agent_updated_at") @db.Timestamptz(3)
agentLastSeenAt DateTime? @map("agent_last_seen_at") @db.Timestamptz(3)
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@ -224,6 +227,7 @@ model ProvisionedDevice {
@@unique([accountEmail, tailscaleHostname])
@@index([accountEmail])
@@index([tailscaleHostname])
@@index([agentLastSeenAt])
@@map("provisioned_devices")
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class AgentUpdateCheckDto {
@IsString()
@IsNotEmpty()
deviceId!: string;
@IsString()
@IsNotEmpty()
version!: string;
}
@@ -7,6 +7,7 @@ import {
ConsumeDeviceAuthorizationDto,
CreateDeviceAuthorizationDto,
} from './dto/device-authorization.dto';
import { AgentUpdateCheckDto } from './dto/agent-update-check.dto';
import { ProvisioningService } from './provisioning.service';
import type { ProvisioningResponse } from './provisioning.service';
@@ -69,6 +70,14 @@ export class ProvisioningController {
return this.provisioningService.consumeDeviceAuthorization(body);
}
@Post('agent/update-check')
checkAgentUpdate(
@Headers('x-agent-gateway-token') token: string | undefined,
@Body() body: AgentUpdateCheckDto,
) {
return this.provisioningService.checkAgentUpdate(token, body);
}
@Get('device-authorizations/registration/:reference')
getDeviceAuthorizationByRegistrationReference(
@Headers('authorization') authorization: string | undefined,
+61 -3
View File
@@ -18,6 +18,7 @@ import {
ConsumeDeviceAuthorizationDto,
CreateDeviceAuthorizationDto,
} from './dto/device-authorization.dto';
import { AgentUpdateCheckDto } from './dto/agent-update-check.dto';
export type ProvisioningResponse = {
installTailscale: boolean;
@@ -27,6 +28,7 @@ export type ProvisioningResponse = {
tailscaleExtraArgs: string;
provisioningMode: 'personal' | 'class';
agentGatewayToken: string;
agentDeviceId: string;
};
type AuthLoginResponse = {
@@ -276,7 +278,7 @@ export class ProvisioningService {
provisioningDto.hostname || 'agent',
headscaleUser,
);
await this.recordProvisionedDevice(
const agentDeviceId = await this.recordProvisionedDevice(
provisioningDto,
authUser,
tailscaleHostname,
@@ -304,6 +306,46 @@ export class ProvisioningService {
'AGENT_GATEWAY_TOKEN',
'',
),
agentDeviceId,
};
}
async checkAgentUpdate(token: string | undefined, input: AgentUpdateCheckDto) {
const expectedToken = this.configService.get<string>('AGENT_GATEWAY_TOKEN', '');
if (!expectedToken || token !== expectedToken) {
throw new UnauthorizedException('invalid_agent_gateway_token');
}
const device = await this.prisma.provisionedDevice.findUnique({
where: { id: input.deviceId },
select: { id: true, agentVersion: true },
});
if (!device) {
throw new NotFoundException('Provisioned device was not found');
}
const now = new Date();
await this.prisma.provisionedDevice.update({
where: { id: device.id },
data: {
agentVersion: input.version,
agentLastSeenAt: now,
...(device.agentVersion === input.version ? {} : { agentUpdatedAt: now }),
},
});
const latestVersion = this.configService.get<string>('PLATFORM_AGENT_RELEASE_VERSION', '0.2.0');
const releaseBaseUrl = this.configService
.get<string>(
'PLATFORM_AGENT_RELEASE_BASE_URL',
'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent',
)
.replace(/\/+$/, '');
return {
latestVersion,
releaseBaseUrl,
updateAvailable: this.isVersionNewer(latestVersion, input.version),
};
}
@@ -390,7 +432,7 @@ export class ProvisioningService {
authUser: NonNullable<AuthLoginResponse['user']>,
tailscaleHostname: string,
provisioningMode: 'personal' | 'class',
) {
): Promise<string> {
const hostname = this.cleanString(
provisioningDto.hostname || provisioningDto.device?.hostname || 'agent',
) ?? 'agent';
@@ -401,7 +443,7 @@ export class ProvisioningService {
provisioningDto.workspaceRoot || provisioningDto.device?.workspaceRoot,
);
const now = new Date();
await this.prisma.provisionedDevice.upsert({
const device = await this.prisma.provisionedDevice.upsert({
where: {
accountEmail_tailscaleHostname: {
accountEmail: authUser.email,
@@ -450,6 +492,22 @@ export class ProvisioningService {
lastProvisionedAt: now,
},
});
return device.id;
}
private isVersionNewer(candidate: string, current: string): boolean {
const parse = (value: string) => value.replace(/^v/i, '').split('.').map((part) => {
const parsed = Number.parseInt(part, 10);
return Number.isFinite(parsed) ? parsed : 0;
});
const candidateParts = parse(candidate);
const currentParts = parse(current);
const length = Math.max(candidateParts.length, currentParts.length);
for (let index = 0; index < length; index += 1) {
const diff = (candidateParts[index] ?? 0) - (currentParts[index] ?? 0);
if (diff !== 0) return diff > 0;
}
return false;
}
private cleanString(value?: string | null) {