feat: melhorar inventário e gestão de máquinas
This commit is contained in:
parent
b1d334045d
commit
3f0702d80b
5 changed files with 584 additions and 59 deletions
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
||||
|
|
@ -102,6 +103,8 @@ type MachineInventory = {
|
|||
logicalCores?: number
|
||||
memoryBytes?: number
|
||||
memory?: number
|
||||
primaryGpu?: { name?: string; memoryBytes?: number; driver?: string; vendor?: string }
|
||||
gpus?: Array<{ name?: string; memoryBytes?: number; driver?: string; vendor?: string }>
|
||||
}
|
||||
network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | Array<{ name?: string; mac?: string; ip?: string }>
|
||||
software?: MachineSoftware[]
|
||||
|
|
@ -113,9 +116,10 @@ type MachineInventory = {
|
|||
osqueryVersion?: string
|
||||
}
|
||||
// Dados enviados pelo agente desktop (inventário básico/estendido)
|
||||
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; totalBytes?: number; availableBytes?: number }>
|
||||
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }>
|
||||
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
|
||||
services?: Array<{ name?: string; status?: string; displayName?: string }>
|
||||
collaborator?: { email?: string; name?: string }
|
||||
}
|
||||
|
||||
export type MachinesQueryItem = {
|
||||
|
|
@ -354,7 +358,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
{machines.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<MachinesGrid machines={filteredMachines} />
|
||||
<MachinesGrid machines={filteredMachines} companyNameBySlug={companyNameBySlug} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -420,6 +424,7 @@ type MachineDetailsProps = {
|
|||
|
||||
export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const router = useRouter()
|
||||
// Company name lookup (by slug)
|
||||
const companies = useQuery(
|
||||
convexUserId && machine ? api.companies.list : "skip",
|
||||
|
|
@ -437,6 +442,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const linuxExt = extended?.linux ?? null
|
||||
const windowsExt = extended?.windows ?? null
|
||||
const macosExt = extended?.macos ?? null
|
||||
const hardwareGpus = Array.isArray((hardware as any)?.gpus)
|
||||
? (((hardware as any)?.gpus as Array<Record<string, unknown>>) ?? [])
|
||||
: []
|
||||
const primaryGpu = (hardware as any)?.primaryGpu as Record<string, unknown> | undefined
|
||||
|
||||
type WinCpuInfo = {
|
||||
Name?: string
|
||||
|
|
@ -502,6 +511,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const [dialogQuery, setDialogQuery] = useState("")
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const jsonText = useMemo(() => {
|
||||
const payload = {
|
||||
id: machine?.id,
|
||||
|
|
@ -551,7 +562,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</p>
|
||||
{machine.companySlug ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Empresa vinculada: <span className="font-medium text-foreground">{machine.companySlug}</span>
|
||||
Empresa vinculada: <span className="font-medium text-foreground">{companyName ?? machine.companySlug}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -707,6 +718,41 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`}
|
||||
/>
|
||||
<DetailLine label="Memória" value={formatBytes(Number(hardware.memoryBytes ?? hardware.memory))} />
|
||||
{hardwareGpus.length > 0 ? (
|
||||
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
<p className="font-semibold uppercase text-slate-500">GPUs</p>
|
||||
<ul className="space-y-1">
|
||||
{hardwareGpus.slice(0, 3).map((gpu, idx) => {
|
||||
const gpuObj = gpu as Record<string, unknown>
|
||||
const name =
|
||||
typeof gpuObj?.["name"] === "string"
|
||||
? (gpuObj["name"] as string)
|
||||
: typeof gpuObj?.["Name"] === "string"
|
||||
? (gpuObj["Name"] as string)
|
||||
: undefined
|
||||
const memoryBytes = parseNumberLike(gpuObj?.["memoryBytes"] ?? gpuObj?.["AdapterRAM"])
|
||||
const driver =
|
||||
typeof gpuObj?.["driver"] === "string"
|
||||
? (gpuObj["driver"] as string)
|
||||
: typeof gpuObj?.["DriverVersion"] === "string"
|
||||
? (gpuObj["DriverVersion"] as string)
|
||||
: undefined
|
||||
const vendor = typeof gpuObj?.["vendor"] === "string" ? (gpuObj["vendor"] as string) : undefined
|
||||
return (
|
||||
<li key={`gpu-${idx}`}>
|
||||
<span className="font-medium text-foreground">{name ?? "Adaptador de vídeo"}</span>
|
||||
{memoryBytes ? <span className="ml-1 text-muted-foreground">{formatBytes(memoryBytes)}</span> : null}
|
||||
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
|
||||
{driver ? <span className="ml-1 text-muted-foreground">· Driver {driver}</span> : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{hardwareGpus.length > 3 ? (
|
||||
<li className="text-muted-foreground">+{hardwareGpus.length - 3} adaptadores adicionais</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1215,6 +1261,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
{machine ? (
|
||||
<section className="space-y-2 rounded-md border border-rose-200 bg-rose-50/60 p-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold text-rose-700">Zona perigosa</h4>
|
||||
<p className="text-xs text-rose-600">
|
||||
Excluir a máquina revoga o token atual e remove os dados de inventário sincronizados.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" size="sm" onClick={() => setDeleteDialog(true)}>Excluir máquina</Button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
|
||||
<div className="flex justify-end">
|
||||
<DialogTrigger asChild>
|
||||
|
|
@ -1236,6 +1294,47 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteDialog} onOpenChange={(open) => { if (!open) setDeleting(false); setDeleteDialog(open) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir máquina</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>Tem certeza que deseja excluir <span className="font-semibold text-foreground">{machine?.hostname}</span>? Esta ação não pode ser desfeita.</p>
|
||||
<p>Os tokens ativos serão revogados e o inventário deixará de aparecer no painel.</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteDialog(false)} disabled={deleting}>Cancelar</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
if (!machine) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
const res = await fetch("/api/admin/machines/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineId: machine.id }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
toast.success("Máquina excluída")
|
||||
setDeleteDialog(false)
|
||||
router.push("/admin/machines")
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.error("Falha ao excluir máquina")
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleting ? "Excluindo..." : "Excluir máquina"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -1243,18 +1342,22 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function MachinesGrid({ machines }: { machines: MachinesQueryItem[] }) {
|
||||
function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQueryItem[]; companyNameBySlug: Map<string, string> }) {
|
||||
if (!machines || machines.length === 0) return <EmptyState />
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 [@supports(display:grid)]:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
||||
{machines.map((m) => (
|
||||
<MachineCard key={m.id} machine={m} />
|
||||
<MachineCard
|
||||
key={m.id}
|
||||
machine={m}
|
||||
companyName={m.companySlug ? companyNameBySlug.get(m.companySlug) ?? m.companySlug : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
||||
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
|
||||
const { className } = getStatusVariant(machine.status)
|
||||
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
|
||||
type AgentMetrics = {
|
||||
|
|
@ -1268,6 +1371,18 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
|||
const memTotal = mm?.memoryTotalBytes ?? NaN
|
||||
const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
|
||||
const cpuPct = mm?.cpuUsagePercent ?? NaN
|
||||
const collaborator = (() => {
|
||||
const inv = machine.inventory as unknown
|
||||
if (!inv || typeof inv !== "object") return null
|
||||
const raw = (inv as Record<string, unknown>).collaborator
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
const obj = raw as Record<string, unknown>
|
||||
const email = typeof obj.email === "string" ? obj.email : undefined
|
||||
const name = typeof obj.name === "string" ? obj.name : undefined
|
||||
if (!email) return null
|
||||
return { email, name }
|
||||
})()
|
||||
const companyLabel = companyName ?? machine.companySlug ?? null
|
||||
|
||||
return (
|
||||
<Link href={`/admin/machines/${machine.id}`} className="group">
|
||||
|
|
@ -1306,10 +1421,16 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
|||
{machine.architecture.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{machine.companySlug ? (
|
||||
<Badge variant="outline" className="text-xs">{machine.companySlug}</Badge>
|
||||
{companyLabel ? (
|
||||
<Badge variant="outline" className="text-xs">{companyLabel}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{collaborator?.email ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{collaborator.name ? `${collaborator.name} · ` : ""}
|
||||
{collaborator.email}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5">
|
||||
<Cpu className="size-4 text-slate-500" />
|
||||
|
|
@ -1339,6 +1460,17 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
|||
)
|
||||
}
|
||||
|
||||
function parseNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const numeric = Number(trimmed.replace(/[^0-9.]/g, ""))
|
||||
if (Number.isFinite(numeric)) return numeric
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function DetailLine({ label, value, classNameValue }: DetailLineProps) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue