feat: display platform agent status and version
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-08-06 16:13:37 +09:00
parent e7d93198a7
commit 46da0a3f37
5 changed files with 40 additions and 5 deletions
+1
View File
@@ -11,6 +11,7 @@ AGENT_PORT=3100
AGENT_GATEWAY_TOKEN= AGENT_GATEWAY_TOKEN=
HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org
HEADSCALE_API_KEY= HEADSCALE_API_KEY=
HEADSCALE_GATEWAY_USERS=local-platform-gateway,platform-gateway
HEADSCALE_TEACHING_DEVICE_OWNER=tagged-devices HEADSCALE_TEACHING_DEVICE_OWNER=tagged-devices
LMS_GATEWAY_TOKEN=change-me LMS_GATEWAY_TOKEN=change-me
+4
View File
@@ -21,6 +21,10 @@ export const config = {
headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'), headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'),
headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '', headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '',
headscaleTeachingDeviceOwner: (process.env.HEADSCALE_TEACHING_DEVICE_OWNER ?? 'tagged-devices').trim().replace(/@+$/, ''), headscaleTeachingDeviceOwner: (process.env.HEADSCALE_TEACHING_DEVICE_OWNER ?? 'tagged-devices').trim().replace(/@+$/, ''),
headscaleGatewayUsers: (process.env.HEADSCALE_GATEWAY_USERS ?? process.env.HEADSCALE_GATEWAY_USER ?? 'local-platform-gateway')
.split(',')
.map((user) => user.trim().replace(/@+$/, ''))
.filter(Boolean),
lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
launcherGatewayToken: process.env.LAUNCHER_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', launcherGatewayToken: process.env.LAUNCHER_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
+2
View File
@@ -143,6 +143,8 @@ lmsRoutes.get('/lms/platforms/:id/status', async (c) => {
sshUser: platform.sshUser ?? null, sshUser: platform.sshUser ?? null,
workspaceRoot: platform.workspaceRoot ?? null, workspaceRoot: platform.workspaceRoot ?? null,
lastSeenAt: platform.lastSeenAt, lastSeenAt: platform.lastSeenAt,
agentStartedAt: agentHealth.startedAt ?? null,
agentUptimeSeconds: agentHealth.uptimeSeconds ?? null,
state: platform.state, state: platform.state,
message: agentHealth.message ?? null, message: agentHealth.message ?? null,
}) })
+14 -3
View File
@@ -2,7 +2,12 @@ import { config } from '../config'
import type { Platform } from '../types' import type { Platform } from '../types'
export class AgentClient { export class AgentClient {
async health(platform: Platform): Promise<{ ok: boolean; message?: string }> { async health(platform: Platform): Promise<{
ok: boolean;
message?: string;
startedAt?: string | null;
uptimeSeconds?: number | null;
}> {
if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' } if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' }
const controller = new AbortController() const controller = new AbortController()
@@ -14,8 +19,14 @@ export class AgentClient {
signal: controller.signal signal: controller.signal
}) })
if (!response.ok) return { ok: false, message: `agent_health_failed:${response.status}` } if (!response.ok) return { ok: false, message: `agent_health_failed:${response.status}` }
const payload = await response.json().catch(() => ({ ok: true })) const payload = await response.json().catch(() => ({ ok: true })) as Record<string, unknown>
return { ok: payload?.ok !== false } return {
ok: payload?.ok !== false,
startedAt: typeof payload?.startedAt === 'string' ? payload.startedAt : null,
uptimeSeconds: typeof payload?.uptimeSeconds === 'number' && Number.isFinite(payload.uptimeSeconds)
? Math.max(0, Math.floor(payload.uptimeSeconds))
: null,
}
} catch (error) { } catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) } return { ok: false, message: error instanceof Error ? error.message : String(error) }
} finally { } finally {
+19 -2
View File
@@ -28,6 +28,7 @@ export type HeadscalePolicyStatus = {
} }
const generatedPrefix = 'group:lms-platform-' const generatedPrefix = 'group:lms-platform-'
const gatewayGroup = 'group:lms-platform-gateway'
export class HeadscaleAdapter { export class HeadscaleAdapter {
async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> { async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> {
@@ -77,8 +78,24 @@ export class HeadscaleAdapter {
}> { }> {
const nodes = await this.listNodes() const nodes = await this.listNodes()
const unresolved: string[] = [] const unresolved: string[] = []
const groups: Record<string, string[]> = {} const groups: Record<string, string[]> = {
const acls: HeadscalePolicy['acls'] = [] [gatewayGroup]: [...new Set(config.headscaleGatewayUsers.map((user) => this.toHeadscaleUserName(user)))],
}
const acls: HeadscalePolicy['acls'] = [
{
// The local bridge initiates discovery from the trusted gateway host.
action: 'accept',
src: [gatewayGroup],
dst: ['tag:teaching-device:*'],
},
{
// DDS discovery and topic payloads use independent UDP flows, so the
// teaching device must also be able to reach the bridge on the gateway.
action: 'accept',
src: ['tag:teaching-device'],
dst: [`${gatewayGroup}:*`],
},
]
assignments.forEach((assignment, index) => { assignments.forEach((assignment, index) => {
const users = [...new Set(assignment.studentEmails.map((email) => this.toHeadscaleUserName(email)).filter(Boolean))] const users = [...new Set(assignment.studentEmails.map((email) => this.toHeadscaleUserName(email)).filter(Boolean))]