feat: adicionar painel de máquinas e autenticação por agente
This commit is contained in:
parent
e2a5b560b1
commit
ee18619519
52 changed files with 7598 additions and 1 deletions
24
src/app/admin/machines/page.tsx
Normal file
24
src/app/admin/machines/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { AdminMachinesOverview } from "@/components/admin/machines/admin-machines-overview"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function AdminMachinesPage() {
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Parque de máquinas"
|
||||
lead="Acompanhe quais dispositivos estão ativos, métricas recentes e a sincronização do agente."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
|
||||
<AdminMachinesOverview tenantId={DEFAULT_TENANT_ID} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
184
src/app/api/integrations/fleet/hosts/route.ts
Normal file
184
src/app/api/integrations/fleet/hosts/route.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
||||
const fleetHostSchema = z.object({
|
||||
host: z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
hostname: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
platform: z.string().optional(),
|
||||
os_version: z.string().optional(),
|
||||
hardware_model: z.string().optional(),
|
||||
hardware_serial: z.string().optional(),
|
||||
hardware_uuid: z.string().optional(),
|
||||
uuid: z.string().optional(),
|
||||
device_id: z.string().optional(),
|
||||
primary_ip: z.string().optional(),
|
||||
public_ip: z.string().optional(),
|
||||
primary_mac: z.string().optional(),
|
||||
macs: z.string().optional(),
|
||||
serial_number: z.string().optional(),
|
||||
memory: z.number().optional(),
|
||||
cpu_type: z.string().optional(),
|
||||
cpu_physical_cores: z.number().optional(),
|
||||
cpu_logical_cores: z.number().optional(),
|
||||
hardware_vendor: z.string().optional(),
|
||||
computer_name: z.string().optional(),
|
||||
detail_updated_at: z.string().optional(),
|
||||
platform_like: z.string().optional(),
|
||||
osquery_version: z.string().optional(),
|
||||
team_id: z.number().optional(),
|
||||
software: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
labels: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.transform((value) => value ?? {}),
|
||||
})
|
||||
|
||||
function extractMacs(host: z.infer<typeof fleetHostSchema>["host"]) {
|
||||
const macs = new Set<string>()
|
||||
const append = (input?: string | null) => {
|
||||
if (!input) return
|
||||
input
|
||||
.split(/[\s,]+/)
|
||||
.map((mac) => mac.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((mac) => macs.add(mac))
|
||||
}
|
||||
append(host.primary_mac)
|
||||
append(host.macs)
|
||||
return Array.from(macs)
|
||||
}
|
||||
|
||||
function extractSerials(host: z.infer<typeof fleetHostSchema>["host"]) {
|
||||
return [
|
||||
host.hardware_serial,
|
||||
host.hardware_uuid,
|
||||
host.uuid,
|
||||
host.serial_number,
|
||||
host.device_id,
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const fleetSecret = env.FLEET_SYNC_SECRET ?? env.MACHINE_PROVISIONING_SECRET
|
||||
if (!fleetSecret) {
|
||||
return NextResponse.json({ error: "Sincronização Fleet não configurada" }, { status: 500 })
|
||||
}
|
||||
|
||||
const providedSecret = request.headers.get("x-fleet-secret") ?? request.headers.get("authorization")?.replace(/^Bearer\s+/i, "")
|
||||
if (!providedSecret || providedSecret !== fleetSecret) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
const raw = await request.json()
|
||||
parsed = fleetHostSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const host = parsed.host
|
||||
const hostname = host.hostname ?? host.computer_name ?? host.display_name
|
||||
if (!hostname) {
|
||||
return NextResponse.json({ error: "Host sem hostname válido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const macAddresses = extractMacs(host)
|
||||
const serialNumbers = extractSerials(host)
|
||||
|
||||
if (macAddresses.length === 0 && serialNumbers.length === 0) {
|
||||
return NextResponse.json({ error: "Host sem identificadores de hardware (MAC ou serial)" }, { status: 400 })
|
||||
}
|
||||
|
||||
const osInfo = {
|
||||
name: host.os_version ?? host.platform ?? "desconhecido",
|
||||
version: host.os_version,
|
||||
architecture: host.platform_like,
|
||||
}
|
||||
|
||||
const inventory = {
|
||||
fleet: {
|
||||
id: host.id,
|
||||
teamId: host.team_id,
|
||||
detailUpdatedAt: host.detail_updated_at,
|
||||
osqueryVersion: host.osquery_version,
|
||||
},
|
||||
hardware: {
|
||||
vendor: host.hardware_vendor,
|
||||
model: host.hardware_model,
|
||||
serial: host.hardware_serial ?? host.serial_number,
|
||||
cpuType: host.cpu_type,
|
||||
physicalCores: host.cpu_physical_cores,
|
||||
logicalCores: host.cpu_logical_cores,
|
||||
memoryBytes: host.memory,
|
||||
},
|
||||
network: {
|
||||
primaryIp: host.primary_ip,
|
||||
publicIp: host.public_ip,
|
||||
macAddresses,
|
||||
},
|
||||
labels: host.labels,
|
||||
software: host.software?.slice(0, 50).map((item) => ({
|
||||
name: item.name,
|
||||
version: item.version,
|
||||
source: item.source,
|
||||
})),
|
||||
}
|
||||
|
||||
const metrics = {
|
||||
memoryBytes: host.memory,
|
||||
cpuPhysicalCores: host.cpu_physical_cores,
|
||||
cpuLogicalCores: host.cpu_logical_cores,
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.machines.upsertInventory, {
|
||||
provisioningSecret: fleetSecret,
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
hostname,
|
||||
companySlug: undefined,
|
||||
os: osInfo,
|
||||
macAddresses,
|
||||
serialNumbers,
|
||||
inventory,
|
||||
metrics,
|
||||
registeredBy: "fleet",
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, machineId: result.machineId, status: result.status })
|
||||
} catch (error) {
|
||||
console.error("[fleet.hosts] Falha ao sincronizar inventário", error)
|
||||
return NextResponse.json({ error: "Falha ao sincronizar inventário" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
51
src/app/api/machines/heartbeat/route.ts
Normal file
51
src/app/api/machines/heartbeat/route.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
hostname: z.string().optional(),
|
||||
os: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
version: z.string().optional(),
|
||||
architecture: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metrics: z.record(z.string(), z.unknown()).optional(),
|
||||
inventory: z.record(z.string(), z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (request.method !== "POST") {
|
||||
return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = heartbeatSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const response = await client.mutation(api.machines.heartbeat, payload)
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
console.error("[machines.heartbeat] Falha ao registrar heartbeat", error)
|
||||
return NextResponse.json({ error: "Falha ao registrar heartbeat" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
94
src/app/api/machines/register/route.ts
Normal file
94
src/app/api/machines/register/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { ensureMachineAccount } from "@/server/machines-auth"
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
provisioningSecret: z.string().min(1),
|
||||
tenantId: z.string().optional(),
|
||||
companySlug: z.string().optional(),
|
||||
hostname: z.string().min(1),
|
||||
os: z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.string().optional(),
|
||||
architecture: z.string().optional(),
|
||||
}),
|
||||
macAddresses: z.array(z.string()).default([]),
|
||||
serialNumbers: z.array(z.string()).default([]),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
registeredBy: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => (data.macAddresses && data.macAddresses.length > 0) || (data.serialNumbers && data.serialNumbers.length > 0),
|
||||
{ message: "Informe ao menos um MAC address ou número de série" }
|
||||
)
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (request.method !== "POST") {
|
||||
return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = registerSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const registration = await client.mutation(api.machines.register, {
|
||||
provisioningSecret: payload.provisioningSecret,
|
||||
tenantId: payload.tenantId ?? DEFAULT_TENANT_ID,
|
||||
companySlug: payload.companySlug ?? undefined,
|
||||
hostname: payload.hostname,
|
||||
os: payload.os,
|
||||
macAddresses: payload.macAddresses,
|
||||
serialNumbers: payload.serialNumbers,
|
||||
metadata: payload.metadata,
|
||||
registeredBy: payload.registeredBy,
|
||||
})
|
||||
|
||||
const account = await ensureMachineAccount({
|
||||
machineId: registration.machineId,
|
||||
tenantId: registration.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: payload.hostname,
|
||||
machineToken: registration.machineToken,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
authUserId: account.authUserId,
|
||||
authEmail: account.authEmail,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
machineId: registration.machineId,
|
||||
tenantId: registration.tenantId,
|
||||
companyId: registration.companyId,
|
||||
companySlug: registration.companySlug,
|
||||
machineToken: registration.machineToken,
|
||||
machineEmail: account.authEmail,
|
||||
expiresAt: registration.expiresAt,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[machines.register] Falha no provisionamento", error)
|
||||
return NextResponse.json({ error: "Falha ao provisionar máquina" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
96
src/app/api/machines/sessions/route.ts
Normal file
96
src/app/api/machines/sessions/route.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { ensureMachineAccount } from "@/server/machines-auth"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
const sessionSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
rememberMe: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (request.method !== "POST") {
|
||||
return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = sessionSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const resolved = await client.mutation(api.machines.resolveToken, { machineToken: payload.machineToken })
|
||||
let machineEmail = resolved.machine.authEmail ?? null
|
||||
|
||||
if (!machineEmail) {
|
||||
const account = await ensureMachineAccount({
|
||||
machineId: resolved.machine._id,
|
||||
tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: resolved.machine.hostname,
|
||||
machineToken: payload.machineToken,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
machineId: resolved.machine._id as Id<"machines">,
|
||||
authUserId: account.authUserId,
|
||||
authEmail: account.authEmail,
|
||||
})
|
||||
|
||||
machineEmail = account.authEmail
|
||||
}
|
||||
|
||||
const signIn = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: machineEmail,
|
||||
password: payload.machineToken,
|
||||
rememberMe: payload.rememberMe ?? true,
|
||||
},
|
||||
returnHeaders: true,
|
||||
})
|
||||
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
machine: {
|
||||
id: resolved.machine._id,
|
||||
hostname: resolved.machine.hostname,
|
||||
osName: resolved.machine.osName,
|
||||
osVersion: resolved.machine.osVersion,
|
||||
architecture: resolved.machine.architecture,
|
||||
status: resolved.machine.status,
|
||||
lastHeartbeatAt: resolved.machine.lastHeartbeatAt,
|
||||
companyId: resolved.machine.companyId,
|
||||
companySlug: resolved.machine.companySlug,
|
||||
metadata: resolved.machine.metadata,
|
||||
},
|
||||
session: signIn.response,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
|
||||
signIn.headers.forEach((value, key) => {
|
||||
response.headers.set(key, value)
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[machines.sessions] Falha ao criar sessão", error)
|
||||
return NextResponse.json({ error: "Falha ao autenticar máquina" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
543
src/components/admin/machines/admin-machines-overview.tsx
Normal file
543
src/components/admin/machines/admin-machines-overview.tsx
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { toast } from "sonner"
|
||||
import { ClipboardCopy, ServerCog } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type MachineMetrics = Record<string, unknown> | null
|
||||
|
||||
type MachineLabel = {
|
||||
id?: number | string
|
||||
name?: string
|
||||
}
|
||||
|
||||
type MachineSoftware = {
|
||||
name?: string
|
||||
version?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
type MachineInventory = {
|
||||
hardware?: {
|
||||
vendor?: string
|
||||
model?: string
|
||||
serial?: string
|
||||
cpuType?: string
|
||||
physicalCores?: number
|
||||
logicalCores?: number
|
||||
memoryBytes?: number
|
||||
memory?: number
|
||||
}
|
||||
network?: {
|
||||
primaryIp?: string
|
||||
publicIp?: string
|
||||
macAddresses?: string[]
|
||||
}
|
||||
software?: MachineSoftware[]
|
||||
labels?: MachineLabel[]
|
||||
fleet?: {
|
||||
id?: number | string
|
||||
teamId?: number | string
|
||||
detailUpdatedAt?: string
|
||||
osqueryVersion?: string
|
||||
}
|
||||
}
|
||||
|
||||
type MachinesQueryItem = {
|
||||
id: string
|
||||
tenantId: string
|
||||
hostname: string
|
||||
companyId: string | null
|
||||
companySlug: string | null
|
||||
osName: string | null
|
||||
osVersion: string | null
|
||||
architecture: string | null
|
||||
macAddresses: string[]
|
||||
serialNumbers: string[]
|
||||
authUserId: string | null
|
||||
authEmail: string | null
|
||||
status: string | null
|
||||
lastHeartbeatAt: number | null
|
||||
heartbeatAgeMs: number | null
|
||||
registeredBy: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
token: {
|
||||
expiresAt: number
|
||||
lastUsedAt: number | null
|
||||
usageCount: number
|
||||
} | null
|
||||
metrics: MachineMetrics
|
||||
inventory: MachineInventory | null
|
||||
}
|
||||
|
||||
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
||||
return (
|
||||
(useQuery(api.machines.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
}) ?? []) as MachinesQueryItem[]
|
||||
)
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
maintenance: "Manutenção",
|
||||
blocked: "Bloqueada",
|
||||
unknown: "Desconhecida",
|
||||
}
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
online: "border-emerald-500/20 bg-emerald-500/15 text-emerald-600",
|
||||
offline: "border-rose-500/20 bg-rose-500/15 text-rose-600",
|
||||
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
|
||||
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600",
|
||||
unknown: "border-slate-300 bg-slate-200 text-slate-700",
|
||||
}
|
||||
|
||||
function formatRelativeTime(date?: Date | null) {
|
||||
if (!date) return "Nunca"
|
||||
try {
|
||||
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
|
||||
} catch {
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date?: Date | null) {
|
||||
if (!date) return "—"
|
||||
return format(date, "dd/MM/yyyy HH:mm")
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number | null) {
|
||||
if (!bytes || Number.isNaN(bytes)) return "—"
|
||||
const units = ["B", "KB", "MB", "GB", "TB"]
|
||||
let value = bytes
|
||||
let unitIndex = 0
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function formatPercent(value?: number | null) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—"
|
||||
const normalized = value > 1 ? value : value * 100
|
||||
return `${normalized.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function getStatusVariant(status?: string | null) {
|
||||
if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown }
|
||||
const normalized = status.toLowerCase()
|
||||
return {
|
||||
label: statusLabels[normalized] ?? status,
|
||||
className: statusClasses[normalized] ?? statusClasses.unknown,
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||
const machines = useMachinesQuery(tenantId)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (machines.length === 0) {
|
||||
setSelectedId(null)
|
||||
return
|
||||
}
|
||||
if (!selectedId) {
|
||||
setSelectedId(machines[0]?.id ?? null)
|
||||
} else if (!machines.some((machine) => machine.id === selectedId)) {
|
||||
setSelectedId(machines[0]?.id ?? null)
|
||||
}
|
||||
}, [machines, selectedId])
|
||||
|
||||
const selectedMachine = useMemo(() => machines.find((item) => item.id === selectedId) ?? null, [machines, selectedId])
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,400px)]">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Máquinas registradas</CardTitle>
|
||||
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-hidden">
|
||||
{machines.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Hostname</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Último heartbeat</TableHead>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Plataforma</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{machines.map((machine: MachinesQueryItem) => (
|
||||
<TableRow
|
||||
key={machine.id}
|
||||
onClick={() => setSelectedId(machine.id)}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors hover:bg-muted/50",
|
||||
selectedId === machine.id ? "bg-muted/60" : undefined
|
||||
)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium">{machine.hostname}</div>
|
||||
<p className="text-xs text-muted-foreground">{machine.authEmail ?? "—"}</p>
|
||||
</TableCell>
|
||||
<TableCell className="space-y-1">
|
||||
<MachineStatusBadge status={machine.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatRelativeTime(machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm font-medium text-muted-foreground">{machine.companySlug ?? "—"}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-sm font-medium">
|
||||
{machine.osName ?? "—"}
|
||||
{machine.osVersion ? ` ${machine.osVersion}` : ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{machine.architecture ? machine.architecture.toUpperCase() : "—"}
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<MachineDetails machine={selectedMachine ?? null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MachineStatusBadge({ status }: { status?: string | null }) {
|
||||
const { label, className } = getStatusVariant(status)
|
||||
return <Badge className={cn("border", className)}>{label}</Badge>
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/50 py-12 text-center">
|
||||
<ServerCog className="size-10 text-slate-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-600">Nenhuma máquina registrada ainda</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Execute o agente local ou o webhook do Fleet para registrar as máquinas do tenant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MachineDetailsProps = {
|
||||
machine: MachinesQueryItem | null
|
||||
}
|
||||
|
||||
function MachineDetails({ machine }: MachineDetailsProps) {
|
||||
const metadata = machine?.inventory ?? null
|
||||
const metrics = machine?.metrics ?? null
|
||||
const hardware = metadata?.hardware ?? null
|
||||
const network = metadata?.network ?? null
|
||||
const software = metadata?.software ?? null
|
||||
const labels = metadata?.labels ?? null
|
||||
const fleet = metadata?.fleet ?? null
|
||||
|
||||
const lastHeartbeatDate = machine?.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
|
||||
const tokenExpiry = machine?.token?.expiresAt ? new Date(machine.token.expiresAt) : null
|
||||
const tokenLastUsed = machine?.token?.lastUsedAt ? new Date(machine.token.lastUsedAt) : null
|
||||
|
||||
const copyEmail = async () => {
|
||||
if (!machine?.authEmail) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(machine.authEmail)
|
||||
toast.success("E-mail da máquina copiado.")
|
||||
} catch {
|
||||
toast.error("Não foi possível copiar o e-mail da máquina.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Detalhes</CardTitle>
|
||||
<CardDescription>Resumo da máquina selecionada.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{!machine ? (
|
||||
<p className="text-sm text-muted-foreground">Selecione uma máquina para visualizar detalhes.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">{machine.hostname}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{machine.authEmail ?? "E-mail não definido"}
|
||||
</p>
|
||||
{machine.companySlug ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Empresa vinculada: <span className="font-medium text-foreground">{machine.companySlug}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<MachineStatusBadge status={machine.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
|
||||
{machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
|
||||
{machine.architecture?.toUpperCase() ?? "Arquitetura indefinida"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{machine.authEmail ? (
|
||||
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2">
|
||||
<ClipboardCopy className="size-4" />
|
||||
Copiar e-mail
|
||||
</Button>
|
||||
) : null}
|
||||
{machine.registeredBy ? (
|
||||
<Badge variant="outline">Registrada via {machine.registeredBy}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sincronização</h4>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Último heartbeat</span>
|
||||
<span className="text-right font-medium text-foreground">
|
||||
{formatRelativeTime(lastHeartbeatDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Criada em</span>
|
||||
<span className="text-right font-medium text-foreground">{formatDate(new Date(machine.createdAt))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Atualizada em</span>
|
||||
<span className="text-right font-medium text-foreground">{formatDate(new Date(machine.updatedAt))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Token expira</span>
|
||||
<span className="text-right font-medium text-foreground">
|
||||
{tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Token usado por último</span>
|
||||
<span className="text-right font-medium text-foreground">
|
||||
{tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span>Uso do token</span>
|
||||
<span className="text-right font-medium text-foreground">{machine.token?.usageCount ?? 0} trocas</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{metrics && typeof metrics === "object" ? (
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||
<MetricsGrid metrics={metrics} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hardware || network || (labels && labels.length > 0) ? (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Inventário</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dados sincronizados via agente ou Fleet.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
{hardware ? (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">Hardware</p>
|
||||
<div className="mt-2 grid gap-1">
|
||||
<DetailLine label="Fabricante" value={hardware.vendor} />
|
||||
<DetailLine label="Modelo" value={hardware.model} />
|
||||
<DetailLine label="Número de série" value={hardware.serial} />
|
||||
<DetailLine label="CPU" value={hardware.cpuType} />
|
||||
<DetailLine
|
||||
label="Núcleos"
|
||||
value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`}
|
||||
/>
|
||||
<DetailLine label="Memória" value={formatBytes(Number(hardware.memoryBytes ?? hardware.memory))} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{network ? (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">Rede</p>
|
||||
<div className="mt-2 grid gap-1">
|
||||
<DetailLine label="IP primário" value={network.primaryIp} />
|
||||
<DetailLine label="IP público" value={network.publicIp} />
|
||||
<DetailLine
|
||||
label="MAC addresses"
|
||||
value={
|
||||
Array.isArray(network.macAddresses)
|
||||
? (network.macAddresses as string[]).join(", ")
|
||||
: machine?.macAddresses.join(", ")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{labels && labels.length > 0 ? (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">Labels</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{labels.slice(0, 12).map((label, index) => (
|
||||
<Badge key={String(label.id ?? `${label.name ?? "label"}-${index}`)} variant="outline">
|
||||
{label.name ?? `Label ${index + 1}`}
|
||||
</Badge>
|
||||
))}
|
||||
{labels.length > 12 ? (
|
||||
<Badge variant="outline">+{labels.length - 12} outras</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{fleet ? (
|
||||
<section className="space-y-2 text-sm text-muted-foreground">
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<span>ID Fleet</span>
|
||||
<span className="font-medium text-foreground">{fleet.id ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Team ID</span>
|
||||
<span className="font-medium text-foreground">{fleet.teamId ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Detalhes atualizados</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{fleet.detailUpdatedAt ? formatDate(new Date(String(fleet.detailUpdatedAt))) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Versão osquery</span>
|
||||
<span className="font-medium text-foreground">{fleet.osqueryVersion ?? "—"}</span>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{software && software.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Softwares detectados</h4>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-200 bg-slate-100/80">
|
||||
<TableHead className="text-xs text-slate-500">Nome</TableHead>
|
||||
<TableHead className="text-xs text-slate-500">Versão</TableHead>
|
||||
<TableHead className="text-xs text-slate-500">Fonte</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{software.slice(0, 6).map((item, index) => (
|
||||
<TableRow key={`${item.name ?? "software"}-${index}`} className="border-slate-100">
|
||||
<TableCell className="text-sm text-foreground">{item.name ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{item.version ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{item.source ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{software.length > 6 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
+{software.length - 6} softwares adicionais sincronizados via Fleet.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailLine({ label, value }: { label: string; value?: string | number | null }) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>{label}</span>
|
||||
<span className="text-right font-medium text-foreground">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
|
||||
const data = (metrics ?? {}) as Record<string, unknown>
|
||||
const cpu = Number(data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? NaN)
|
||||
const memory = Number(data.memoryBytes ?? data.memory ?? data.memory_used ?? NaN)
|
||||
const disk = Number(data.diskUsage ?? data.disk ?? NaN)
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-md border border-slate-200 bg-slate-50/60 p-3 text-sm text-muted-foreground sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-500">CPU</p>
|
||||
<p className="text-sm font-semibold text-foreground">{formatPercent(cpu)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-500">Memória</p>
|
||||
<p className="text-sm font-semibold text-foreground">{formatBytes(memory)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-500">Disco</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{Number.isNaN(disk) ? "—" : `${formatPercent(disk)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,7 +11,8 @@ import {
|
|||
PanelsTopLeft,
|
||||
Users,
|
||||
Waypoints,
|
||||
Timer,
|
||||
Timer,
|
||||
MonitorCog,
|
||||
Layers3,
|
||||
UserPlus,
|
||||
} from "lucide-react"
|
||||
|
|
@ -90,6 +91,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
|
||||
{ title: "Empresas & clientes", url: "/admin/companies", icon: Users, requiredRole: "admin" },
|
||||
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
|
||||
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
|
||||
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
||||
{ title: "Alertas enviados", url: "/admin/alerts", icon: Gauge, requiredRole: "admin" },
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ const envSchema = z.object({
|
|||
NEXT_PUBLIC_CONVEX_URL: z.string().url().optional(),
|
||||
DATABASE_URL: z.string().min(1).optional(),
|
||||
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
|
||||
MACHINE_PROVISIONING_SECRET: z.string().optional(),
|
||||
MACHINE_TOKEN_TTL_MS: z.coerce.number().optional(),
|
||||
FLEET_SYNC_SECRET: z.string().optional(),
|
||||
SMTP_ADDRESS: z.string().optional(),
|
||||
SMTP_PORT: z.coerce.number().optional(),
|
||||
SMTP_DOMAIN: z.string().optional(),
|
||||
|
|
@ -30,6 +33,9 @@ export const env = {
|
|||
NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL,
|
||||
DATABASE_URL: parsed.data.DATABASE_URL,
|
||||
NEXT_PUBLIC_APP_URL: parsed.data.NEXT_PUBLIC_APP_URL,
|
||||
MACHINE_PROVISIONING_SECRET: parsed.data.MACHINE_PROVISIONING_SECRET,
|
||||
MACHINE_TOKEN_TTL_MS: parsed.data.MACHINE_TOKEN_TTL_MS,
|
||||
FLEET_SYNC_SECRET: parsed.data.FLEET_SYNC_SECRET,
|
||||
SMTP: parsed.data.SMTP_ADDRESS && parsed.data.SMTP_USERNAME && parsed.data.SMTP_PASSWORD
|
||||
? {
|
||||
host: parsed.data.SMTP_ADDRESS,
|
||||
|
|
|
|||
61
src/server/machines-auth.ts
Normal file
61
src/server/machines-auth.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
type EnsureMachineAccountParams = {
|
||||
machineId: string
|
||||
tenantId: string
|
||||
hostname: string
|
||||
machineToken: string
|
||||
}
|
||||
|
||||
export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
|
||||
const { machineId, tenantId, hostname, machineToken } = params
|
||||
const machineEmail = `machine-${machineId}@machines.local`
|
||||
const context = await auth.$context
|
||||
|
||||
const passwordHash = await context.password.hash(machineToken)
|
||||
const machineName = `Máquina ${hostname}`
|
||||
|
||||
const user = await prisma.authUser.upsert({
|
||||
where: { email: machineEmail },
|
||||
update: {
|
||||
name: machineName,
|
||||
tenantId,
|
||||
role: "machine",
|
||||
},
|
||||
create: {
|
||||
email: machineEmail,
|
||||
name: machineName,
|
||||
role: "machine",
|
||||
tenantId,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.authAccount.upsert({
|
||||
where: {
|
||||
providerId_accountId: {
|
||||
providerId: "credential",
|
||||
accountId: machineEmail,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
password: passwordHash,
|
||||
userId: user.id,
|
||||
},
|
||||
create: {
|
||||
providerId: "credential",
|
||||
accountId: machineEmail,
|
||||
userId: user.id,
|
||||
password: passwordHash,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.authSession.deleteMany({
|
||||
where: { userId: user.id },
|
||||
})
|
||||
|
||||
return {
|
||||
authUserId: user.id,
|
||||
authEmail: machineEmail,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue