sistema-de-chamados/src/components/admin/machines/admin-machines-overview.tsx
2025-11-01 02:09:16 -03:00

4758 lines
216 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { ReactNode } 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,
Cpu,
MemoryStick,
Monitor,
HardDrive,
Pencil,
ShieldCheck,
ShieldAlert,
Shield,
ShieldOff,
ShieldQuestion,
Lock,
Cloud,
RefreshCcw,
AlertTriangle,
Key,
Globe,
Apple,
Terminal,
Power,
PlayCircle,
Download,
} from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { Progress } from "@/components/ui/progress"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Separator } from "@/components/ui/separator"
import { ChartContainer } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
type MachineMetrics = Record<string, unknown> | null
type MachineLabel = {
id?: number | string
name?: string
}
type MachineSoftware = {
name?: string
version?: string
source?: string
}
type NormalizedSoftwareEntry = {
name: string
version?: string
publisher?: string
installDate?: Date | null
source?: string
}
type MachineAlertEntry = {
id: string
kind: string
message: string
severity: string
createdAt: number
}
type MachineTicketSummary = {
id: string
reference: number
subject: string
status: TicketStatus
priority: TicketPriority
updatedAt: number
createdAt: number
machine: { id: string | null; hostname: string | null } | null
assignee: { name: string | null; email: string | null } | null
}
type MachineOpenTicketsSummary = {
totalOpen: number
hasMore: boolean
tickets: MachineTicketSummary[]
}
type DetailLineProps = {
label: string
value?: string | number | null
classNameValue?: string
layout?: "spread" | "compact"
}
type GpuAdapter = {
name?: string
vendor?: string
driver?: string
memoryBytes?: number
}
type LinuxLsblkEntry = {
name?: string
mountPoint?: string
mountpoint?: string
fs?: string
fstype?: string
sizeBytes?: number
size?: number
}
type LinuxSmartEntry = {
smart_status?: { passed?: boolean }
model_name?: string
model_family?: string
serial_number?: string
device?: { name?: string }
}
type LinuxExtended = {
lsblk?: LinuxLsblkEntry[]
lspci?: string
lsusb?: string
pciList?: Array<{ text: string }>
usbList?: Array<{ text: string }>
smart?: LinuxSmartEntry[]
}
type WindowsCpuInfo = {
Name?: string
Manufacturer?: string
SocketDesignation?: string
NumberOfCores?: number
NumberOfLogicalProcessors?: number
L2CacheSize?: number
L3CacheSize?: number
MaxClockSpeed?: number
}
type WindowsMemoryModule = {
BankLabel?: string
Capacity?: number
Manufacturer?: string
PartNumber?: string
SerialNumber?: string
ConfiguredClockSpeed?: number
Speed?: number
ConfiguredVoltage?: number
}
type WindowsVideoController = {
Name?: string
AdapterRAM?: number
DriverVersion?: string
PNPDeviceID?: string
}
type WindowsDiskEntry = {
Model?: string
SerialNumber?: string
Size?: number
InterfaceType?: string
MediaType?: string
}
type WindowsExtended = {
software?: MachineSoftware[]
services?: Array<{ name?: string; status?: string; displayName?: string }>
defender?: Record<string, unknown>
hotfix?: Array<Record<string, unknown>>
cpu?: WindowsCpuInfo | WindowsCpuInfo[]
baseboard?: Record<string, unknown> | Array<Record<string, unknown>>
bios?: Record<string, unknown> | Array<Record<string, unknown>>
memoryModules?: WindowsMemoryModule[]
videoControllers?: WindowsVideoController[]
disks?: WindowsDiskEntry[]
osInfo?: WindowsOsInfo
bitLocker?: Array<Record<string, unknown>> | Record<string, unknown>
bitlocker?: Array<Record<string, unknown>> | Record<string, unknown>
tpm?: Record<string, unknown>
secureBoot?: Record<string, unknown>
deviceGuard?: Array<Record<string, unknown>> | Record<string, unknown>
firewallProfiles?: Array<Record<string, unknown>> | Record<string, unknown>
windowsUpdate?: Record<string, unknown>
computerSystem?: Record<string, unknown>
azureAdStatus?: Record<string, unknown>
}
type MacExtended = {
systemProfiler?: Record<string, unknown>
packages?: string[]
launchctl?: string
}
type NetworkInterface = { name?: string; mac?: string; ip?: string }
type MachineInventory = {
hardware?: {
vendor?: string
model?: string
serial?: string
cpuType?: string
physicalCores?: number
logicalCores?: number
memoryBytes?: number
memory?: number
primaryGpu?: GpuAdapter
gpus?: GpuAdapter[]
}
network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | NetworkInterface[]
software?: MachineSoftware[]
labels?: MachineLabel[]
fleet?: {
id?: number | string
teamId?: number | string
detailUpdatedAt?: string
osqueryVersion?: string
}
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; role?: string }
}
type MachineRemoteAccessEntry = {
id: string | null
clientId: string
provider: string | null
identifier: string | null
url: string | null
notes: string | null
lastVerifiedAt: number | null
metadata: Record<string, unknown> | null
}
export type MachineRemoteAccess = {
provider: string | null
identifier: string | null
url: string | null
notes: string | null
lastVerifiedAt: number | null
metadata: Record<string, unknown> | null
}
function collectInitials(name: string): string {
const words = name.split(/\s+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[1][0]).toUpperCase()
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object") return null
return value as Record<string, unknown>
}
function readString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const raw = record[key]
if (typeof raw === "string" && raw.trim().length > 0) {
return raw
}
}
return undefined
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | undefined {
for (const key of keys) {
const raw = record[key]
if (typeof raw === "number" && Number.isFinite(raw)) {
return raw
}
if (typeof raw === "string") {
const trimmed = raw.trim()
if (!trimmed) continue
const parsed = Number(trimmed)
if (!Number.isNaN(parsed)) return parsed
const digits = trimmed.replace(/[^0-9.]/g, "")
if (digits) {
const fallback = Number(digits)
if (!Number.isNaN(fallback)) return fallback
}
}
}
return undefined
}
function createRemoteAccessClientId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `ra-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`
}
function normalizeMachineRemoteAccessEntry(raw: unknown): MachineRemoteAccessEntry | null {
if (!raw) return null
if (typeof raw === "string") {
const trimmed = raw.trim()
if (!trimmed) return null
const isUrl = /^https?:\/\//i.test(trimmed)
return {
id: null,
clientId: createRemoteAccessClientId(),
provider: null,
identifier: isUrl ? null : trimmed,
url: isUrl ? trimmed : null,
notes: null,
lastVerifiedAt: null,
metadata: null,
}
}
const record = toRecord(raw)
if (!record) return null
const provider = readString(record, "provider", "tool", "vendor", "name") ?? null
const identifier =
readString(record, "identifier", "code", "id", "accessId") ??
readString(record, "value", "label")
const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null
const notes = readString(record, "notes", "note", "description", "obs") ?? null
const timestampCandidate =
readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ??
parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"])
const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null
const id = readString(record, "id") ?? null
return {
id,
clientId: id ?? createRemoteAccessClientId(),
provider,
identifier: identifier ?? url ?? null,
url,
notes,
lastVerifiedAt,
metadata: record,
}
}
export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess | null {
const entry = normalizeMachineRemoteAccessEntry(raw)
if (!entry) return null
const { provider, identifier, url, notes, lastVerifiedAt, metadata } = entry
return { provider, identifier, url, notes, lastVerifiedAt, metadata }
}
export function normalizeMachineRemoteAccessList(raw: unknown): MachineRemoteAccessEntry[] {
if (!raw) return []
const source = Array.isArray(raw) ? raw : [raw]
const seen = new Set<string>()
const entries: MachineRemoteAccessEntry[] = []
for (const item of source) {
const entry = normalizeMachineRemoteAccessEntry(item)
if (!entry) continue
let clientId = entry.clientId
while (seen.has(clientId)) {
clientId = createRemoteAccessClientId()
}
seen.add(clientId)
entries.push(clientId === entry.clientId ? entry : { ...entry, clientId })
}
return entries
}
const REMOTE_ACCESS_METADATA_IGNORED_KEYS = new Set([
"provider",
"tool",
"vendor",
"name",
"identifier",
"code",
"id",
"accessId",
"url",
"link",
"remoteUrl",
"console",
"viewer",
"notes",
"note",
"description",
"obs",
"lastVerifiedAt",
"verifiedAt",
"checkedAt",
"updatedAt",
])
function extractRemoteAccessMetadataEntries(metadata: Record<string, unknown> | null | undefined) {
if (!metadata) return [] as Array<[string, unknown]>
return Object.entries(metadata).filter(([key, value]) => {
if (REMOTE_ACCESS_METADATA_IGNORED_KEYS.has(key)) return false
if (value === null || value === undefined) return false
if (typeof value === "string" && value.trim().length === 0) return false
return true
})
}
function formatRemoteAccessMetadataKey(key: string) {
return key
.replace(/[_.-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function formatRemoteAccessMetadataValue(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (value instanceof Date) return formatAbsoluteDateTime(value)
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined {
const stringValue = readString(record, ...keys)
if (stringValue) return stringValue
const numberValue = readNumber(record, ...keys)
if (typeof numberValue === "number") return String(numberValue)
return undefined
}
function parseWindowsInstallDate(value: unknown): Date | null {
if (!value) return null
if (value instanceof Date) return value
if (typeof value === "number" && Number.isFinite(value)) {
if (value > 10_000_000_000) {
return new Date(value)
}
if (value > 1_000_000_000) {
return new Date(value * 1000)
}
return new Date(value * 1000)
}
if (typeof value !== "string") return null
const trimmed = value.trim()
if (!trimmed) return null
const wmiMatch = trimmed.match(/Date\((\d+)\)/)
if (wmiMatch) {
const timestamp = Number(wmiMatch[1])
return Number.isFinite(timestamp) ? new Date(timestamp) : null
}
const isoValue = Date.parse(trimmed)
if (!Number.isNaN(isoValue)) return new Date(isoValue)
const digitsOnly = trimmed.replace(/[^0-9]/g, "")
if (digitsOnly.length >= 8) {
const yyyy = Number(digitsOnly.slice(0, 4))
const mm = Number(digitsOnly.slice(4, 6))
const dd = Number(digitsOnly.slice(6, 8))
const hh = Number(digitsOnly.slice(8, 10) || "0")
const mi = Number(digitsOnly.slice(10, 12) || "0")
const ss = Number(digitsOnly.slice(12, 14) || "0")
if (yyyy > 1900 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) {
const parsed = new Date(Date.UTC(yyyy, mm - 1, dd, hh, mi, ss))
if (!Number.isNaN(parsed.getTime())) return parsed
}
const dd2 = Number(digitsOnly.slice(0, 2))
const mm2 = Number(digitsOnly.slice(2, 4))
const yyyy2 = Number(digitsOnly.slice(4, 8))
if (yyyy2 > 1900 && mm2 >= 1 && mm2 <= 12 && dd2 >= 1 && dd2 <= 31) {
const parsed = new Date(Date.UTC(yyyy2, mm2 - 1, dd2))
if (!Number.isNaN(parsed.getTime())) return parsed
}
}
const localMatch = trimmed.match(
/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
)
if (localMatch) {
const dd = Number(localMatch[1])
const mm = Number(localMatch[2])
const yyyy = Number(localMatch[3])
const hh = Number(localMatch[4] || "0")
const mi = Number(localMatch[5] || "0")
const ss = Number(localMatch[6] || "0")
if (yyyy > 1900 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) {
const parsed = new Date(Date.UTC(yyyy, mm - 1, dd, hh, mi, ss))
if (!Number.isNaN(parsed.getTime())) return parsed
}
}
return null
}
type WindowsOsInfo = {
productName?: string
caption?: string
editionId?: string
displayVersion?: string
releaseId?: string
version?: string
currentBuild?: string
currentBuildNumber?: string
licenseStatus?: number
isActivated?: boolean
licenseStatusText?: string
productId?: string
partialProductKey?: string
computerName?: string
registeredOwner?: string
installDate?: Date | null
experience?: string
}
function formatOsVersionDisplay(osName: string | null | undefined, osVersion: string | null | undefined) {
const name = (osName ?? "").trim()
const version = (osVersion ?? "").trim()
if (!version) return ""
// If Windows and version redundantly starts with the same major (e.g., "11 (26100)"), drop leading major
const winMatch = name.match(/^windows\s+(\d+)\b/i)
if (winMatch) {
const major = winMatch[1]
const re = new RegExp(`^\\s*${major}(?:\\b|\\.|-_|\\s)+(.*)$`, "i")
const m = version.match(re)
if (m) {
const rest = (m[1] ?? "").trim()
return rest
}
}
return version
}
function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null {
if (!raw) return null
const parseRecord = (value: Record<string, unknown>) => {
const read = (k: string, ...alts: string[]) => readString(value, k, ...alts)
const readNum = (...keys: string[]) => readNumber(value, ...keys)
const readFlexible = (...keys: string[]) => readText(value, ...keys)
const family = read("Family")
const edition = read("Edition")
const captionRaw = readFlexible("Caption", "caption")
const captionNormalized = captionRaw ? captionRaw.replace(/^Microsoft\s+/i, "").trim() : undefined
let productName =
readFlexible("ProductName", "productName", "Name", "name") ??
(family && edition ? `${family} ${edition}` : family ?? edition ?? undefined)
if (captionNormalized && /windows/i.test(captionNormalized)) {
productName = captionNormalized
}
const editionId =
readFlexible("EditionID", "editionId", "Edition", "edition", "SkuEdition", "skuEdition", "CompositionEditionID", "compositionEditionId") ??
undefined
const displayVersion = readFlexible("DisplayVersion", "displayVersion")
const versionValue =
readFlexible("Version", "version", "ReleaseId", "releaseId") ?? displayVersion
const baseBuild =
readFlexible("CurrentBuildNumber", "currentBuildNumber", "CurrentBuild", "currentBuild", "OSBuild", "osBuild", "BuildNumber", "buildNumber") ??
undefined
const ubrRaw = readFlexible("UBR")
const licenseStatus = readNum("LicenseStatus", "licenseStatus")
const licenseStatusTextRaw = readFlexible(
"LicenseStatusDescription",
"licenseStatusDescription",
"StatusDescription",
"statusDescription",
"Status",
"status"
)
const licenseStatusText = licenseStatusTextRaw ? licenseStatusTextRaw.trim() : undefined
const currentBuildNumber =
baseBuild && ubrRaw && /^\d+$/.test(ubrRaw) ? `${baseBuild}.${ubrRaw}` : baseBuild ?? readFlexible("BuildNumber", "buildNumber")
const currentBuild = baseBuild
const isActivatedRaw = value["IsActivated"] ?? value["isActivated"]
const isLicensedRaw = value["IsLicensed"] ?? value["isLicensed"]
const isActivated =
typeof isActivatedRaw === "boolean"
? isActivatedRaw
: typeof isActivatedRaw === "number"
? isActivatedRaw === 1
: typeof isActivatedRaw === "string"
? isActivatedRaw.toLowerCase() === "true"
: typeof isLicensedRaw === "boolean"
? isLicensedRaw
: typeof isLicensedRaw === "number"
? isLicensedRaw === 1
: typeof isLicensedRaw === "string"
? ["1", "true", "licensed", "license", "activated"].includes(isLicensedRaw.toLowerCase())
: licenseStatus === 1 || Boolean(licenseStatusText && /licensed|activated|licenciado/i.test(licenseStatusText))
const installDate =
parseWindowsInstallDate(value["InstallDate"]) ??
parseWindowsInstallDate(value["InstallationDate"]) ??
parseWindowsInstallDate(value["InstallDateTime"]) ??
parseWindowsInstallDate(value["InstalledOn"])
const experience = (() => {
const exp = readFlexible("Experience", "experience")
if (exp) return exp
const pack = readFlexible("FeatureExperiencePack", "featureExperiencePack")
if (pack) {
const trimmed = pack.trim()
if (trimmed) return `Pacote de experiência ${trimmed}`
}
return currentBuildNumber ? `OS Build ${currentBuildNumber}` : undefined
})()
const productId = readFlexible("ProductID", "productID", "ProductId", "productId", "ProductKeyId", "productKeyId")
const partialProductKey = readFlexible("PartialProductKey", "partialProductKey")
const computerName = readFlexible("DeviceName", "deviceName", "ComputerName", "computerName", "CSName", "csName", "HostName", "hostName")
const registeredOwner = readFlexible("RegisteredOwner", "registeredOwner", "RegisteredOrganization", "registeredOrganization")
return {
productName,
editionId,
displayVersion,
version: versionValue,
releaseId: read("ReleaseId", "releaseId"),
currentBuild,
currentBuildNumber,
licenseStatus,
isActivated,
licenseStatusText,
productId,
partialProductKey,
computerName,
registeredOwner,
installDate,
experience,
caption: captionNormalized ?? captionRaw ?? undefined,
}
}
if (Array.isArray(raw)) {
for (const entry of raw) {
const record = toRecord(entry)
if (record) {
return parseRecord(record)
}
}
return null
}
if (typeof raw === "string") {
return { productName: raw }
}
const record = toRecord(raw)
if (!record) return null
return parseRecord(record)
}
function normalizeWindowsSoftwareEntry(value: unknown): NormalizedSoftwareEntry | null {
const record = toRecord(value)
if (!record) return null
const name =
readString(record, "DisplayName", "displayName", "Name", "name") ??
readText(record, "Title", "title") ??
""
if (!name) return null
const version = readString(record, "DisplayVersion", "displayVersion", "Version", "version") ?? undefined
const publisher = readString(record, "Publisher", "publisher", "Vendor", "vendor") ?? undefined
const installDate =
parseWindowsInstallDate(record["InstallDate"]) ??
parseWindowsInstallDate(record["InstallDateUTC"]) ??
parseWindowsInstallDate(record["InstallDateTime"]) ??
parseWindowsInstallDate(record["InstallDateFromRegistry"]) ??
parseWindowsInstallDate(record["InstallDateFromRegistryUTC"]) ??
parseWindowsInstallDate(record["InstalledDate"]) ??
parseWindowsInstallDate(record["InstalledOn"]) ??
null
const source = readString(record, "ParentDisplayName", "ParentKeyName", "SystemComponent") ?? undefined
return {
name,
version,
publisher,
installDate,
source,
}
}
function parseBytesLike(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = trimmed.replace(",", ".")
const match = normalized.match(/^([\d.]+)\s*(ti|tb|tib|gb|gib|mb|mib|kb|kib|b)?$/i)
if (match) {
const amount = Number(match[1])
if (Number.isNaN(amount)) return undefined
const unit = match[2]?.toLowerCase()
const base = 1024
const unitMap: Record<string, number> = {
b: 1,
kb: base,
kib: base,
mb: base ** 2,
mib: base ** 2,
gb: base ** 3,
gib: base ** 3,
tb: base ** 4,
tib: base ** 4,
ti: base ** 4,
}
if (unit) {
const multiplier = unitMap[unit]
if (multiplier) {
return amount * multiplier
}
}
return amount
}
const digits = normalized.replace(/[^0-9.]/g, "")
if (digits) {
const fallback = Number(digits)
if (!Number.isNaN(fallback)) return fallback
}
}
return undefined
}
function deriveVendor(record: Record<string, unknown>): string | undefined {
const direct = readString(record, "vendor", "Vendor", "AdapterCompatibility")
if (direct) return direct
const pnp = readString(record, "PNPDeviceID")
if (!pnp) return undefined
const match = pnp.match(/VEN_([0-9A-F]{4})/i)
if (match) {
const vendorCode = match[1].toUpperCase()
const vendorMap: Record<string, string> = {
"10DE": "NVIDIA",
"1002": "AMD",
"1022": "AMD",
"8086": "Intel",
"8087": "Intel",
"1AF4": "Red Hat",
}
return vendorMap[vendorCode] ?? `VEN_${vendorCode}`
}
const segments = pnp.split("\\")
const last = segments.pop()
return last && last.trim().length > 0 ? last : pnp
}
function normalizeGpuSource(value: unknown): GpuAdapter | null {
if (typeof value === "string") {
const name = value.trim()
return name ? { name } : null
}
const record = toRecord(value)
if (!record) return null
const name = readString(record, "name", "Name", "_name", "AdapterCompatibility")
const vendor = deriveVendor(record)
const driver = readString(record, "driver", "DriverVersion", "driverVersion")
const memoryBytes =
readNumber(record, "memoryBytes", "MemoryBytes", "AdapterRAM", "VRAM", "vramBytes") ??
parseBytesLike(record["AdapterRAM"] ?? record["VRAM"] ?? record["vram"])
if (!name && !vendor && !driver && memoryBytes === undefined) {
return null
}
return { name, vendor, driver, memoryBytes }
}
function uniqueBy<T>(items: T[], keyFn: (item: T) => string): T[] {
const seen = new Set<string>()
const result: T[] = []
items.forEach((item) => {
const key = keyFn(item)
if (key && !seen.has(key)) {
seen.add(key)
result.push(item)
}
})
return result
}
export type MachinesQueryItem = {
id: string
tenantId: string
hostname: string
companyId: string | null
companySlug: string | null
companyName: string | null
osName: string | null
osVersion: string | null
architecture: string | null
macAddresses: string[]
serialNumbers: string[]
authUserId: string | null
authEmail: string | null
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
status: string | null
isActive: boolean
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
postureAlerts?: Array<Record<string, unknown>> | null
lastPostureAt?: number | null
linkedUsers?: Array<{ id: string; email: string; name: string }>
remoteAccessEntries: MachineRemoteAccessEntry[]
}
export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem {
const { remoteAccess, ...rest } = raw as Record<string, unknown> & { remoteAccess?: unknown }
return {
...(rest as MachinesQueryItem),
remoteAccessEntries: normalizeMachineRemoteAccessList(remoteAccess),
}
}
function useMachinesQuery(tenantId: string): { machines: MachinesQueryItem[]; isLoading: boolean } {
const result = useQuery(api.machines.listByTenant, {
tenantId,
includeMetadata: true,
}) as Array<Record<string, unknown>> | undefined
const machines = useMemo(() => (result ?? []).map((item) => normalizeMachineItem(item)), [result])
return {
machines,
isLoading: result === undefined,
}
}
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
const DEFAULT_STALE_THRESHOLD_MS = DEFAULT_OFFLINE_THRESHOLD_MS * 12
function parseThreshold(raw: string | undefined, fallback: number) {
if (!raw) return fallback
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
return parsed
}
const MACHINE_OFFLINE_THRESHOLD_MS = parseThreshold(process.env.NEXT_PUBLIC_MACHINE_OFFLINE_THRESHOLD_MS, DEFAULT_OFFLINE_THRESHOLD_MS)
const MACHINE_STALE_THRESHOLD_MS = parseThreshold(process.env.NEXT_PUBLIC_MACHINE_STALE_THRESHOLD_MS, DEFAULT_STALE_THRESHOLD_MS)
const statusLabels: Record<string, string> = {
online: "Online",
offline: "Offline",
stale: "Sem sinal",
maintenance: "Manutenção",
blocked: "Bloqueada",
deactivated: "Desativada",
unknown: "Desconhecida",
}
const TICKET_PRIORITY_META: Record<string, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-600" },
MEDIUM: { label: "Média", badgeClass: "border border-sky-200 bg-sky-100 text-sky-700" },
HIGH: { label: "Alta", badgeClass: "border border-amber-200 bg-amber-50 text-amber-700" },
URGENT: { label: "Urgente", badgeClass: "border border-rose-200 bg-rose-50 text-rose-700" },
}
function getTicketPriorityMeta(priority: TicketPriority | string | null | undefined) {
if (!priority) {
return { label: "Sem prioridade", badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600" }
}
const normalized = priority.toUpperCase()
return (
TICKET_PRIORITY_META[normalized] ?? {
label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase(),
badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600",
}
)
}
const statusClasses: Record<string, string> = {
online: "border-emerald-200 text-emerald-600",
offline: "border-rose-200 text-rose-600",
stale: "border-amber-200 text-amber-600",
maintenance: "border-amber-300 text-amber-700",
blocked: "border-orange-200 text-orange-600",
deactivated: "border-slate-200 bg-slate-50 text-slate-500",
unknown: "border-slate-200 text-slate-600",
}
const REMOTE_ACCESS_PROVIDERS = [
{ value: "TEAMVIEWER", label: "TeamViewer" },
{ value: "ANYDESK", label: "AnyDesk" },
{ value: "SUPREMO", label: "Supremo" },
{ value: "RUSTDESK", label: "RustDesk" },
{ value: "QUICKSUPPORT", label: "TeamViewer QS" },
{ value: "CHROME_REMOTE_DESKTOP", label: "Chrome Remote Desktop" },
{ value: "DW_SERVICE", label: "DWService" },
{ value: "OTHER", label: "Outro" },
] as const
type RemoteAccessProviderValue = (typeof REMOTE_ACCESS_PROVIDERS)[number]["value"]
const POSTURE_ALERT_LABELS: Record<string, string> = {
CPU_HIGH: "CPU alta",
SERVICE_DOWN: "Serviço indisponível",
SMART_FAIL: "Falha SMART",
}
function formatPostureAlertKind(raw?: string | null): string {
if (!raw) return "Alerta"
const normalized = raw.toUpperCase()
if (POSTURE_ALERT_LABELS[normalized]) {
return POSTURE_ALERT_LABELS[normalized]
}
return raw
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function postureSeverityClass(severity?: string | null) {
return (severity ?? "warning").toLowerCase() === "critical"
? "border-rose-500/20 bg-rose-500/10"
: "border-amber-500/20 bg-amber-500/10"
}
function formatRelativeTime(date?: Date | null) {
if (!date) return "Nunca"
try {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
} catch {
return "—"
}
}
function formatInstallDate(date?: Date | null) {
if (!date) return null
return `${formatAbsoluteDateTime(date)} (${formatRelativeTime(date)})`
}
function formatDate(date?: Date | null) {
if (!date) return "—"
return format(date, "dd/MM/yyyy HH:mm")
}
function formatAbsoluteDateTime(date?: Date | null) {
if (!date) return "—"
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(date)
}
function parseDateish(value: unknown): Date | null {
if (!value) return null
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
if (typeof value === "number" && Number.isFinite(value)) {
const numeric = value > 1e12 ? value : value > 1e9 ? value * 1000 : value
const date = new Date(numeric)
return Number.isNaN(date.getTime()) ? null : date
}
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return null
const numericValue = Number(trimmed)
if (Number.isFinite(numericValue)) {
const asMs = trimmed.length >= 13 ? numericValue : numericValue * 1000
const date = new Date(asMs)
if (!Number.isNaN(date.getTime())) return date
}
const date = new Date(trimmed)
if (!Number.isNaN(date.getTime())) return date
}
return null
}
function getMetricsTimestamp(metrics: MachineMetrics): Date | null {
if (!metrics || typeof metrics !== "object") return null
const data = metrics as Record<string, unknown>
const candidates = [
data["collectedAt"],
data["collected_at"],
data["collected_at_iso"],
data["collected_at_ms"],
data["timestamp"],
data["updatedAt"],
data["updated_at"],
data["createdAt"],
data["created_at"],
]
for (const candidate of candidates) {
const parsed = parseDateish(candidate)
if (parsed) return parsed
}
return null
}
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)}%`
}
const BADGE_POSITIVE = "gap-1 border-emerald-500/20 bg-emerald-500/15 text-emerald-700"
const BADGE_WARNING = "gap-1 border-amber-500/20 bg-amber-100 text-amber-700"
const BADGE_NEUTRAL = "gap-1 border-slate-300 bg-slate-100 text-slate-600"
function parseBooleanLike(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value
if (typeof value === "number") {
if (value === 1) return true
if (value === 0) return false
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase()
if (["yes", "sim", "true", "enabled", "on", "ativo", "active", "y", "1"].includes(normalized)) return true
if (["no", "não", "nao", "false", "disabled", "off", "inativo", "not joined", "n", "0"].includes(normalized)) return false
}
return undefined
}
function toNumberArray(value: unknown): number[] {
if (!value) return []
if (Array.isArray(value)) {
return value
.map((item) => {
if (typeof item === "number") return item
if (typeof item === "string") {
const parsed = Number(item)
return Number.isNaN(parsed) ? null : parsed
}
return null
})
.filter((item): item is number => item !== null)
}
if (typeof value === "number") return [value]
if (typeof value === "string" && value.trim().length > 0) {
return value
.replace(/[^\d,]/g, " ")
.split(/[,\s]+/)
.map((part) => Number(part))
.filter((num) => !Number.isNaN(num))
}
return []
}
function describeDomainRole(role?: number | null): string | null {
if (role === null || role === undefined) return null
const map: Record<number, string> = {
0: "Estação isolada",
1: "Estação em domínio",
2: "Servidor isolado",
3: "Servidor em domínio",
4: "Controlador de domínio (backup)",
5: "Controlador de domínio (primário)",
}
return map[role] ?? `Função ${role}`
}
function describePcSystemType(code?: number | null): string | null {
if (code === null || code === undefined) return null
const map: Record<number, string> = {
0: "Não especificado",
1: "Desktop",
2: "Portátil / Laptop",
3: "Workstation",
4: "Servidor corporativo",
5: "Servidor SOHO",
8: "Tablet / Slate",
9: "Conversível",
10: "Sistema baseado em detecção",
}
return map[code] ?? `Tipo ${code}`
}
function describeDeviceGuardService(code: number): string {
const map: Record<number, string> = {
1: "Credential Guard",
2: "HVCI (Kernel Integrity)",
3: "Secure Boot com DMA",
4: "Hypervisor com Device Guard",
5: "Aplicação protegida",
}
return map[code] ?? `Serviço ${code}`
}
function describeVbsStatus(code?: number | null): string | null {
switch (code) {
case 0:
return "VBS desabilitado"
case 1:
return "VBS habilitado (inativo)"
case 2:
return "VBS ativo (sem serviços)"
case 3:
return "VBS ativo com proteções"
default:
return code != null ? `VBS status ${code}` : null
}
}
function describeAuOption(value?: number | null): string | null {
switch (value ?? -1) {
case 1:
return "Não configurado"
case 2:
return "Notificar antes de baixar"
case 3:
return "Baixar automático e notificar"
case 4:
return "Baixar e instalar automaticamente"
case 5:
return "Administrador local define"
default:
return value != null ? `Opção ${value}` : null
}
}
function describeScheduledDay(value?: number | null): string | null {
if (value === null || value === undefined) return null
if (value === 0) return "Todos os dias"
const map = ["Domingo", "Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado"]
return map[value - 1] ?? `Dia ${value}`
}
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,
}
}
function resolveMachineStatus(machine: { status?: string | null; lastHeartbeatAt?: number | null; isActive?: boolean | null }): string {
if (machine.isActive === false) return "deactivated"
const manualStatus = (machine.status ?? "").toLowerCase()
if (["maintenance", "blocked"].includes(manualStatus)) {
return manualStatus
}
const heartbeat = machine.lastHeartbeatAt
if (typeof heartbeat === "number" && Number.isFinite(heartbeat) && heartbeat > 0) {
const age = Date.now() - heartbeat
if (age <= MACHINE_OFFLINE_THRESHOLD_MS) return "online"
if (age <= MACHINE_STALE_THRESHOLD_MS) return "offline"
return "stale"
}
return machine.status ?? "unknown"
}
function OsIcon({ osName }: { osName?: string | null }) {
const name = (osName ?? "").toLowerCase()
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return <Apple className="size-4 text-black" />
if (name.includes("linux")) return <Terminal className="size-4 text-black" />
// fallback para Windows/outros como monitor genérico
return <Monitor className="size-4 text-black" />
}
export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "all" }: { tenantId: string; initialCompanyFilterSlug?: string }) {
const { machines, isLoading } = useMachinesQuery(tenantId)
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
const [companySearch, setCompanySearch] = useState<string>("")
const [isCompanyPopoverOpen, setIsCompanyPopoverOpen] = useState(false)
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false)
const [exportSelection, setExportSelection] = useState<string[]>([])
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState(0)
const [exportError, setExportError] = useState<string | null>(null)
const { convexUserId } = useAuth()
const companies = useQuery(
convexUserId ? api.companies.list : undefined,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : undefined
) as Array<{ id: string; name: string; slug?: string }> | undefined
const companyNameBySlug = useMemo(() => {
const map = new Map<string, string>()
machines.forEach((m) => {
if (m.companySlug && m.companyName) {
map.set(m.companySlug, m.companyName)
}
})
;(companies ?? []).forEach((c) => {
if (c.slug) {
map.set(c.slug, c.name)
}
})
return map
}, [machines, companies])
const companyOptions = useMemo(() => {
if (companies && companies.length > 0) {
return companies
.map((c) => ({ slug: c.slug ?? c.id, name: c.name }))
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}
const fallback = new Map<string, string>()
machines.forEach((m) => {
if (m.companySlug) {
fallback.set(m.companySlug, m.companyName ?? m.companySlug)
}
})
return Array.from(fallback.entries())
.map(([slug, name]) => ({ slug, name }))
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}, [companies, machines])
const filteredMachines = useMemo(() => {
const text = q.trim().toLowerCase()
return machines.filter((m) => {
if (onlyAlerts && !(Array.isArray(m.postureAlerts) && m.postureAlerts.length > 0)) return false
if (statusFilter !== "all") {
const s = resolveMachineStatus(m).toLowerCase()
if (s !== statusFilter) return false
}
if (companyFilterSlug !== "all" && (m.companySlug ?? "") !== companyFilterSlug) return false
if (!text) return true
const hay = [
m.hostname,
m.authEmail ?? "",
(m.macAddresses ?? []).join(" "),
(m.serialNumbers ?? []).join(" "),
]
.join(" ")
.toLowerCase()
return hay.includes(text)
})
}, [machines, q, statusFilter, companyFilterSlug, onlyAlerts])
const handleOpenExportDialog = useCallback(() => {
if (filteredMachines.length === 0) {
toast.info("Não há máquinas para exportar com os filtros atuais.")
return
}
setExportSelection(filteredMachines.map((m) => m.id))
setExportProgress(0)
setExportError(null)
setIsExporting(false)
setIsExportDialogOpen(true)
}, [filteredMachines])
const handleExportDialogOpenChange = useCallback((open: boolean) => {
if (!open && isExporting) return
setIsExportDialogOpen(open)
}, [isExporting])
useEffect(() => {
if (!isExportDialogOpen) {
setExportSelection([])
setExportProgress(0)
setExportError(null)
setIsExporting(false)
}
}, [isExportDialogOpen])
useEffect(() => {
if (!isExportDialogOpen) return
const allowed = new Set(filteredMachines.map((m) => m.id))
setExportSelection((prev) => {
const next = prev.filter((id) => allowed.has(id))
return next.length === prev.length ? prev : next
})
}, [filteredMachines, isExportDialogOpen])
const handleToggleMachineSelection = useCallback((machineId: string, checked: boolean) => {
setExportSelection((prev) => {
if (checked) {
if (prev.includes(machineId)) return prev
return [...prev, machineId]
}
return prev.filter((id) => id !== machineId)
})
}, [])
const handleSelectAllMachines = useCallback((checked: boolean) => {
if (checked) {
setExportSelection(filteredMachines.map((m) => m.id))
} else {
setExportSelection([])
}
}, [filteredMachines])
const handleConfirmExport = useCallback(async () => {
const orderedSelection = filteredMachines.map((m) => m.id).filter((id) => exportSelection.includes(id))
if (orderedSelection.length === 0) {
toast.info("Selecione ao menos uma máquina para exportar.")
return
}
setIsExporting(true)
setExportError(null)
setExportProgress(5)
try {
const params = new URLSearchParams()
if (companyFilterSlug !== "all") {
params.set("companyId", companyFilterSlug)
}
orderedSelection.forEach((id) => params.append("machineId", id))
const qs = params.toString()
const url = `/api/reports/machines-inventory.xlsx${qs ? `?${qs}` : ""}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Export failed with status ${response.status}`)
}
const contentLengthHeader = response.headers.get("Content-Length")
const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : Number.NaN
const hasLength = Number.isFinite(totalBytes) && totalBytes > 0
const disposition = response.headers.get("Content-Disposition")
const filenameMatch = disposition?.match(/filename="?([^";]+)"?/i)
const filename = filenameMatch?.[1] ?? `machines-inventory-${new Date().toISOString().slice(0, 10)}.xlsx`
let blob: Blob
if (!response.body || typeof response.body.getReader !== "function") {
blob = await response.blob()
setExportProgress(100)
} else {
const reader = response.body.getReader()
const chunks: ArrayBuffer[] = []
let received = 0
while (true) {
const { value, done } = await reader.read()
if (done) break
if (value) {
chunks.push(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength))
received += value.length
if (hasLength) {
const percent = Math.min(99, Math.round((received / totalBytes) * 100))
setExportProgress(percent)
} else {
setExportProgress((prev) => (prev >= 95 ? prev : prev + 5))
}
}
}
blob = new Blob(chunks, { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })
setExportProgress(100)
}
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(downloadUrl)
toast.success(`Exportação gerada para ${orderedSelection.length} máquina${orderedSelection.length === 1 ? "" : "s"}.`)
setIsExporting(false)
setIsExportDialogOpen(false)
} catch (error) {
console.error("Failed to export machines inventory", error)
setIsExporting(false)
setExportProgress(0)
setExportError("Não foi possível gerar o arquivo. Tente novamente.")
}
}, [companyFilterSlug, exportSelection, filteredMachines])
const exportableCount = filteredMachines.length
const selectedCount = exportSelection.length
const selectAllState: boolean | "indeterminate" = exportableCount === 0
? false
: selectedCount === exportableCount
? true
: selectedCount > 0
? "indeterminate"
: false
return (
<div className="grid gap-6">
<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">
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-[220px] flex-1">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar hostname, e-mail, MAC, serial..." />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-36">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos status</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
<SelectItem value="stale">Sem sinal</SelectItem>
<SelectItem value="unknown">Desconhecido</SelectItem>
</SelectContent>
</Select>
<Popover open={isCompanyPopoverOpen} onOpenChange={setIsCompanyPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-56 justify-between">
{(() => {
if (companyFilterSlug === "all") return "Todas empresas"
const found = companyOptions.find((c) => c.slug === companyFilterSlug)
return found?.name ?? companyFilterSlug
})()}
<span className="ml-2 text-slate-400"></span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="space-y-2">
<Input
value={companySearch}
onChange={(e) => setCompanySearch(e.target.value)}
placeholder="Buscar empresa..."
/>
<div className="max-h-64 overflow-auto rounded-md border border-slate-200">
<button
type="button"
onClick={() => { setCompanyFilterSlug("all"); setCompanySearch(""); setIsCompanyPopoverOpen(false) }}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
>
Todas empresas
</button>
{companyOptions
.filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase()))
.map((c) => (
<button
key={c.slug}
type="button"
onClick={() => { setCompanyFilterSlug(c.slug); setCompanySearch(""); setIsCompanyPopoverOpen(false) }}
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
>
{c.name}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
<label className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-1.5 text-sm">
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span>
</label>
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setCompanyFilterSlug("all"); setCompanySearch(""); setOnlyAlerts(false) }}>Limpar</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenExportDialog}>
<Download className="size-4" />
Exportar XLSX
</Button>
</div>
{isLoading ? (
<LoadingState />
) : machines.length === 0 ? (
<EmptyState />
) : (
<MachinesGrid machines={filteredMachines} companyNameBySlug={companyNameBySlug} />
)}
</CardContent>
</Card>
<Dialog open={isExportDialogOpen} onOpenChange={handleExportDialogOpenChange}>
<DialogContent className="max-w-3xl space-y-5">
<DialogHeader>
<DialogTitle>Exportar inventário</DialogTitle>
<DialogDescription>Revise as máquinas antes de gerar o XLSX.</DialogDescription>
</DialogHeader>
{filteredMachines.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-muted-foreground">
Nenhuma máquina disponível para exportar com os filtros atuais.
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
<span>
{selectedCount} de {filteredMachines.length} selecionadas
</span>
<label className="inline-flex items-center gap-2 font-medium text-slate-600">
<Checkbox
checked={selectAllState}
onCheckedChange={(value) => handleSelectAllMachines(value === true || value === "indeterminate")}
disabled={isExporting}
/>
<span>Selecionar todas</span>
</label>
</div>
<div className="max-h-80 overflow-y-auto rounded-md border border-slate-200">
<ul className="divide-y divide-slate-100">
{filteredMachines.map((machine) => {
const statusKey = resolveMachineStatus(machine)
const statusLabel = statusLabels[statusKey] ?? statusKey
const isChecked = exportSelection.includes(machine.id)
const osParts = [machine.osName ?? "", machine.osVersion ?? ""].filter(Boolean)
const osLabel = osParts.join(" ")
return (
<li key={machine.id}>
<label className="flex cursor-pointer items-start gap-3 px-3 py-3 hover:bg-slate-50">
<Checkbox
checked={isChecked}
onCheckedChange={(value) => handleToggleMachineSelection(machine.id, value === true || value === "indeterminate")}
disabled={isExporting}
/>
<div className="flex flex-col gap-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-slate-900">{machine.hostname || machine.authEmail || "Máquina"}</span>
<Badge variant="outline" className="text-[11px] font-medium uppercase tracking-wide">
{statusLabel}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span>{machine.companyName ?? "Sem empresa"}</span>
{osLabel ? (
<>
<span className="text-slate-300"></span>
<span>{osLabel}</span>
</>
) : null}
{machine.architecture ? (
<>
<span className="text-slate-300"></span>
<span>{machine.architecture}</span>
</>
) : null}
</div>
</div>
</label>
</li>
)
})}
</ul>
</div>
</div>
)}
{isExporting ? (
<div className="space-y-2 rounded-md border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
<span>Gerando planilha...</span>
<span>{Math.min(100, Math.max(0, Math.round(exportProgress)))}%</span>
</div>
<Progress value={exportProgress} className="h-2" />
</div>
) : null}
{exportError ? <p className="text-sm text-destructive">{exportError}</p> : null}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="outline" onClick={() => handleExportDialogOpenChange(false)} disabled={isExporting}>
Cancelar
</Button>
<Button type="button" onClick={handleConfirmExport} disabled={isExporting || selectedCount === 0} className="gap-2">
{isExporting ? (
<>
<Spinner className="mr-2 size-4" />
Exportando...
</>
) : (
<>
<Download className="size-4" />
Exportar{selectedCount > 0 ? ` (${selectedCount})` : ""}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function MachineStatusBadge({ status }: { status?: string | null }) {
const { label, className } = getStatusVariant(status)
const s = String(status ?? "").toLowerCase()
const colorClass =
s === "online"
? "bg-emerald-500"
: s === "offline"
? "bg-rose-500"
: s === "stale"
? "bg-amber-500"
: s === "maintenance"
? "bg-amber-500"
: s === "blocked"
? "bg-orange-500"
: s === "deactivated"
? "bg-slate-500"
: "bg-slate-400"
const ringClass =
s === "online"
? "bg-emerald-400/30"
: s === "offline"
? "bg-rose-400/30"
: s === "stale"
? "bg-amber-400/30"
: s === "maintenance"
? "bg-amber-400/30"
: s === "blocked"
? "bg-orange-400/30"
: s === "deactivated"
? "bg-slate-400/40"
: "bg-slate-300/30"
const isOnline = s === "online"
return (
<Badge
variant="outline"
className={cn(
"inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-600 shadow-sm sm:text-sm",
className
)}
>
<span className="relative inline-flex size-4 items-center justify-center">
<span className={cn("size-2.5 rounded-full", colorClass)} />
{isOnline ? (
<span
className={cn(
"absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]",
ringClass
)}
/>
) : null}
</span>
{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>
)
}
function LoadingState() {
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">
<div className="inline-flex size-12 items-center justify-center rounded-full border border-slate-200 bg-white shadow-sm">
<Spinner className="size-6 text-slate-500" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-600">Carregando máquinas...</p>
<p className="text-sm text-muted-foreground">
Sincronizando o inventário em tempo real. Isso leva apenas alguns instantes.
</p>
</div>
</div>
)
}
type MachineDetailsProps = {
machine: MachinesQueryItem | null
}
export function MachineDetails({ machine }: MachineDetailsProps) {
const router = useRouter()
const { role: viewerRole } = useAuth()
const normalizedViewerRole = (viewerRole ?? "").toLowerCase()
const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent"
const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown"
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(machine?.isActive ?? true)
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
const alertsHistory = useQuery(
machine ? api.machines.listAlerts : undefined,
machine ? { machineId: machine.id as Id<"machines">, limit: 50 } : undefined
) as MachineAlertEntry[] | undefined
const machineAlertsHistory = alertsHistory ?? []
const openTickets = useQuery(
machine ? api.machines.listOpenTickets : undefined,
machine ? { machineId: machine.id as Id<"machines">, limit: 6 } : undefined
) as MachineOpenTicketsSummary | undefined
const machineTickets = openTickets?.tickets ?? []
const totalOpenTickets = openTickets?.totalOpen ?? machineTickets.length
const displayLimit = 3
const displayedMachineTickets = machineTickets.slice(0, displayLimit)
const hasAdditionalOpenTickets = totalOpenTickets > displayedMachineTickets.length
const machineTicketsHref = machine ? `/admin/machines/${machine.id}/tickets` : null
const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null
const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics])
// Live refresh the relative time label every second when we have a capture timestamp
const [, setRelativeTick] = useState(0)
useEffect(() => {
if (!metricsCapturedAt) return
const id = setInterval(() => setRelativeTick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [metricsCapturedAt])
const lastUpdateRelative = metricsCapturedAt ? formatRelativeTime(metricsCapturedAt) : null
const hardware = metadata?.hardware
const network = metadata?.network ?? null
const networkInterfaces = Array.isArray(network) ? network : null
const networkSummary = !Array.isArray(network) && network ? network : null
const software = metadata?.software ?? null
const labels = metadata?.labels ?? null
const fleet = metadata?.fleet ?? null
const disks = Array.isArray(metadata?.disks) ? metadata.disks : []
const extended = metadata?.extended ?? null
const linuxExt = extended?.linux ?? null
const windowsExt = extended?.windows ?? null
const macosExt = extended?.macos ?? null
const windowsOsInfo = parseWindowsOsInfo(windowsExt?.osInfo)
const windowsActivationStatus = windowsOsInfo?.isActivated ?? (typeof windowsOsInfo?.licenseStatus === "number" ? windowsOsInfo.licenseStatus === 1 : null)
const windowsMemoryModulesRaw = windowsExt?.memoryModules
const windowsVideoControllersRaw = windowsExt?.videoControllers
const windowsDiskEntriesRaw = windowsExt?.disks
const windowsServicesRaw = windowsExt?.services
const windowsSoftwareRaw = windowsExt?.software
const windowsBaseboardRaw = windowsExt?.baseboard
const windowsBaseboard = Array.isArray(windowsBaseboardRaw)
? windowsBaseboardRaw[0]
: windowsBaseboardRaw && typeof windowsBaseboardRaw === "object"
? windowsBaseboardRaw
: null
const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined
const windowsMemoryModules = useMemo(() => {
if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw
if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") return [windowsMemoryModulesRaw]
return []
}, [windowsMemoryModulesRaw])
const windowsVideoControllers = useMemo(() => {
if (Array.isArray(windowsVideoControllersRaw)) return windowsVideoControllersRaw
if (windowsVideoControllersRaw && typeof windowsVideoControllersRaw === "object") return [windowsVideoControllersRaw]
return []
}, [windowsVideoControllersRaw])
const windowsDiskEntries = useMemo(() => {
if (Array.isArray(windowsDiskEntriesRaw)) return windowsDiskEntriesRaw
if (windowsDiskEntriesRaw && typeof windowsDiskEntriesRaw === "object") return [windowsDiskEntriesRaw]
return []
}, [windowsDiskEntriesRaw])
const windowsServices = useMemo(() => {
if (Array.isArray(windowsServicesRaw)) return windowsServicesRaw
if (windowsServicesRaw && typeof windowsServicesRaw === "object") return [windowsServicesRaw]
return []
}, [windowsServicesRaw])
const windowsSoftware = useMemo(() => {
if (Array.isArray(windowsSoftwareRaw)) return windowsSoftwareRaw
if (windowsSoftwareRaw && typeof windowsSoftwareRaw === "object") return [windowsSoftwareRaw]
return []
}, [windowsSoftwareRaw])
const normalizedWindowsSoftware = useMemo(() => {
return windowsSoftware
.map((item) => normalizeWindowsSoftwareEntry(item))
.filter((entry): entry is NormalizedSoftwareEntry => Boolean(entry))
.sort((a, b) => {
const aTime = a.installDate ? a.installDate.getTime() : 0
const bTime = b.installDate ? b.installDate.getTime() : 0
if (aTime !== bTime) return bTime - aTime
return a.name.localeCompare(b.name, "pt-BR")
})
}, [windowsSoftware])
const windowsEditionLabel = useMemo(() => {
const raw =
windowsOsInfo?.productName ??
windowsOsInfo?.caption ??
windowsOsInfo?.editionId ??
null
if (!raw) return null
return raw.replace(/^Microsoft\s+/i, "").trim()
}, [windowsOsInfo?.productName, windowsOsInfo?.caption, windowsOsInfo?.editionId])
const windowsVersionLabel = windowsOsInfo?.displayVersion ?? windowsOsInfo?.version ?? windowsOsInfo?.releaseId ?? null
const windowsBuildLabel = windowsOsInfo?.currentBuildNumber ?? windowsOsInfo?.currentBuild ?? null
const windowsInstallDateLabel = windowsOsInfo?.installDate ? formatAbsoluteDateTime(windowsOsInfo.installDate) : null
const windowsExperienceLabel = windowsOsInfo?.experience ?? null
const windowsProductId = windowsOsInfo?.productId ?? null
const windowsPartialProductKey = windowsOsInfo?.partialProductKey ?? null
const windowsComputerName = windowsOsInfo?.computerName ?? hardware?.model ?? machine?.hostname ?? null
const windowsRegisteredOwner = windowsOsInfo?.registeredOwner ?? null
const windowsLicenseStatusLabel = (() => {
if (windowsOsInfo?.licenseStatusText) {
return windowsOsInfo.licenseStatusText
}
switch (windowsOsInfo?.licenseStatus) {
case 0:
return "Sem licença"
case 1:
return "Licenciado"
case 2:
return "Período inicial (OOB Grace)"
case 3:
return "Período exposto (OOT Grace)"
case 4:
return "Período não genuíno"
case 5:
return "Notificação"
case 6:
return "Período estendido"
default:
return null
}
})()
const windowsBitLockerRaw = windowsExt?.bitLocker ?? windowsExt?.bitlocker ?? null
const windowsBitLockerVolumes = useMemo(() => {
if (Array.isArray(windowsBitLockerRaw)) return windowsBitLockerRaw
if (windowsBitLockerRaw && typeof windowsBitLockerRaw === "object") return [windowsBitLockerRaw]
return []
}, [windowsBitLockerRaw])
const windowsBitLockerSummary = useMemo(() => {
if (windowsBitLockerVolumes.length === 0) return null
let protectedCount = 0
let lockedCount = 0
windowsBitLockerVolumes.forEach((volume) => {
const record = toRecord(volume) ?? {}
const protection = readString(record, "ProtectionStatus", "protectionStatus")?.toLowerCase()
if (protection && (protection.includes("on") || protection.includes("ativo"))) {
protectedCount += 1
}
const lockStatus = readString(record, "LockStatus", "lockStatus")?.toLowerCase()
if (lockStatus && (lockStatus.includes("locked") || lockStatus.includes("bloqueado"))) {
lockedCount += 1
}
})
return { total: windowsBitLockerVolumes.length, protectedCount, lockedCount }
}, [windowsBitLockerVolumes])
const windowsTpm = toRecord(windowsExt?.tpm ?? (windowsExt as Record<string, unknown> | undefined)?.["TPM"])
const windowsSecureBoot = toRecord(windowsExt?.secureBoot ?? (windowsExt as Record<string, unknown> | undefined)?.["secureboot"])
const windowsDeviceGuardRaw = windowsExt?.deviceGuard ?? (windowsExt as Record<string, unknown> | undefined)?.["deviceguard"] ?? null
const windowsDeviceGuard = useMemo(() => {
if (Array.isArray(windowsDeviceGuardRaw)) return windowsDeviceGuardRaw
if (windowsDeviceGuardRaw && typeof windowsDeviceGuardRaw === "object") return [windowsDeviceGuardRaw]
return []
}, [windowsDeviceGuardRaw])
const windowsDeviceGuardDetails = useMemo(() => {
if (!windowsDeviceGuard.length) return null
const primary = toRecord(windowsDeviceGuard[0]) ?? {}
const configured = toNumberArray(primary?.["SecurityServicesConfigured"])
const running = toNumberArray(primary?.["SecurityServicesRunning"])
const required = toNumberArray(primary?.["RequiredSecurityProperties"])
const available = toNumberArray(primary?.["AvailableSecurityProperties"])
const vbsRaw = primary?.["VirtualizationBasedSecurityStatus"]
const vbs =
typeof vbsRaw === "number"
? vbsRaw
: typeof vbsRaw === "string" && vbsRaw.trim().length > 0
? Number(vbsRaw)
: undefined
return { primary, configured, running, required, available, vbs }
}, [windowsDeviceGuard])
const deviceGuardConfiguredLabels = windowsDeviceGuardDetails?.configured?.map((code) => describeDeviceGuardService(code)) ?? []
const deviceGuardRunningLabels = windowsDeviceGuardDetails?.running?.map((code) => describeDeviceGuardService(code)) ?? []
const windowsFirewallProfilesRaw =
windowsExt?.firewallProfiles ?? (windowsExt as Record<string, unknown> | undefined)?.["firewallprofiles"] ?? null
const windowsFirewallProfiles = useMemo(() => {
if (Array.isArray(windowsFirewallProfilesRaw)) return windowsFirewallProfilesRaw
if (windowsFirewallProfilesRaw && typeof windowsFirewallProfilesRaw === "object") return [windowsFirewallProfilesRaw]
return []
}, [windowsFirewallProfilesRaw])
const windowsUpdateSettings = toRecord(windowsExt?.windowsUpdate ?? (windowsExt as Record<string, unknown> | undefined)?.["windowsupdate"])
const windowsUpdateLastSuccess = useMemo(() => {
if (!windowsUpdateSettings) return null
const candidates = [
windowsUpdateSettings["LastSuccessTime"],
windowsUpdateSettings["lastSuccessTime"],
windowsUpdateSettings["LastSuccessTimeUtc"],
windowsUpdateSettings["lastSuccessTimeUtc"],
]
for (const candidate of candidates) {
const parsed = parseDateish(candidate)
if (parsed) return parsed
if (typeof candidate === "string" && candidate.trim().length > 0) {
const parsedIso = new Date(candidate)
if (!Number.isNaN(parsedIso.getTime())) return parsedIso
}
}
return null
}, [windowsUpdateSettings])
const windowsUpdateLastSuccessLabel = windowsUpdateLastSuccess ? formatAbsoluteDateTime(windowsUpdateLastSuccess) : null
const windowsComputerSystem = toRecord(
windowsExt?.computerSystem ?? (windowsExt as Record<string, unknown> | undefined)?.["computersystem"]
)
const windowsAzureAdStatusRaw = toRecord(
windowsExt?.azureAdStatus ?? (windowsExt as Record<string, unknown> | undefined)?.["azureadstatus"]
)
const windowsAzureAdStatus = useMemo(() => {
if (!windowsAzureAdStatusRaw) return null
return Object.entries(windowsAzureAdStatusRaw).reduce<Record<string, Record<string, unknown>>>((acc, [section, value]) => {
acc[section] = toRecord(value) ?? {}
return acc
}, {})
}, [windowsAzureAdStatusRaw])
const secureBootSupported = windowsSecureBoot
? parseBooleanLike(windowsSecureBoot["Supported"] ?? windowsSecureBoot["supported"])
: undefined
const secureBootEnabled = windowsSecureBoot
? parseBooleanLike(windowsSecureBoot["Enabled"] ?? windowsSecureBoot["enabled"])
: undefined
const secureBootError = windowsSecureBoot ? readString(windowsSecureBoot, "Error", "error") : undefined
const tpmPresent = windowsTpm ? parseBooleanLike(windowsTpm["TpmPresent"] ?? windowsTpm["tpmPresent"]) : undefined
const tpmReady = windowsTpm ? parseBooleanLike(windowsTpm["TpmReady"] ?? windowsTpm["tpmReady"]) : undefined
const tpmEnabled = windowsTpm ? parseBooleanLike(windowsTpm["TpmEnabled"] ?? windowsTpm["tpmEnabled"]) : undefined
const tpmActivated = windowsTpm ? parseBooleanLike(windowsTpm["TpmActivated"] ?? windowsTpm["tpmActivated"]) : undefined
const tpmManufacturer =
windowsTpm ? readString(windowsTpm, "ManufacturerIdTxt", "manufacturerIdTxt", "ManufacturerId", "manufacturerId") : undefined
const tpmVersion =
windowsTpm
? readString(windowsTpm, "ManufacturerVersionFull20", "manufacturerVersionFull20", "ManufacturerVersion", "manufacturerVersion", "SpecVersion")
: undefined
const windowsFirewallNormalized = useMemo(() => {
return windowsFirewallProfiles.map((profile) => {
const record = toRecord(profile) ?? {}
return {
name: readString(record, "Name", "name") ?? "Perfil",
enabled: parseBooleanLike(record["Enabled"] ?? record["enabled"]),
inboundAction: readString(record, "DefaultInboundAction", "defaultInboundAction"),
outboundAction: readString(record, "DefaultOutboundAction", "defaultOutboundAction"),
notifyOnListen: parseBooleanLike(record["NotifyOnListen"] ?? record["notifyOnListen"]),
}
})
}, [windowsFirewallProfiles])
const firewallEnabledCount = windowsFirewallNormalized.filter((profile) => profile.enabled !== false).length
const windowsUpdateMode = windowsUpdateSettings ? describeAuOption(readNumber(windowsUpdateSettings, "AUOptions", "auOptions")) : null
const windowsUpdateDay = windowsUpdateSettings
? describeScheduledDay(readNumber(windowsUpdateSettings, "ScheduledInstallDay", "scheduledInstallDay"))
: null
const windowsUpdateHourRaw = windowsUpdateSettings
? readNumber(windowsUpdateSettings, "ScheduledInstallTime", "scheduledInstallTime")
: undefined
const windowsUpdateHour =
windowsUpdateHourRaw != null ? `${windowsUpdateHourRaw.toString().padStart(2, "0")}h` : null
const windowsUpdateDisabled = windowsUpdateSettings
? parseBooleanLike(windowsUpdateSettings["NoAutoUpdate"] ?? windowsUpdateSettings["noAutoUpdate"])
: undefined
const windowsUpdateDetectionEnabled = windowsUpdateSettings
? parseBooleanLike(
windowsUpdateSettings["DetectionFrequency"] ??
windowsUpdateSettings["DetectionFrequencyEnabled"] ??
windowsUpdateSettings["detectionFrequencyEnabled"],
)
: undefined
const windowsUpdateScheduleLabel =
windowsUpdateDay || windowsUpdateHour
? `${windowsUpdateDay ?? ""}${windowsUpdateDay && windowsUpdateHour ? ` · ${windowsUpdateHour}` : windowsUpdateHour ?? ""}`.trim()
: null
const computerDomain = windowsComputerSystem ? readString(windowsComputerSystem, "Domain", "domain") : undefined
const computerWorkgroup = windowsComputerSystem ? readString(windowsComputerSystem, "Workgroup", "workgroup") : undefined
const computerPartOfDomain = windowsComputerSystem
? parseBooleanLike(windowsComputerSystem["PartOfDomain"] ?? windowsComputerSystem["partOfDomain"])
: undefined
const computerDomainRole = windowsComputerSystem
? readNumber(windowsComputerSystem, "DomainRole", "domainRole")
: undefined
const computerManufacturer = windowsComputerSystem
? readString(windowsComputerSystem, "Manufacturer", "manufacturer")
: undefined
const computerModel = windowsComputerSystem ? readString(windowsComputerSystem, "Model", "model") : undefined
const computerPcType = windowsComputerSystem
? readNumber(windowsComputerSystem, "PCSystemType", "pcSystemType", "PCSystemTypeEx", "pcSystemTypeEx")
: undefined
const computerTotalMemory = windowsComputerSystem
? readNumber(windowsComputerSystem, "TotalPhysicalMemory", "totalPhysicalMemory")
: undefined
const computerDomainRoleLabel = describeDomainRole(computerDomainRole)
const computerPcTypeLabel = describePcSystemType(computerPcType)
const computerTotalMemoryLabel = typeof computerTotalMemory === "number" ? formatBytes(computerTotalMemory) : null
const azureDeviceState = windowsAzureAdStatus ? windowsAzureAdStatus["Device State"] ?? null : null
const azureTenantDetails = windowsAzureAdStatus ? windowsAzureAdStatus["Tenant Details"] ?? null : null
const azureUserState = windowsAzureAdStatus ? windowsAzureAdStatus["User State"] ?? null : null
const azureAdJoined = azureDeviceState ? parseBooleanLike(azureDeviceState["AzureAdJoined"]) : undefined
const azureDomainJoined = azureDeviceState ? parseBooleanLike(azureDeviceState["DomainJoined"]) : undefined
const azureEnterpriseJoined = azureDeviceState ? parseBooleanLike(azureDeviceState["EnterpriseJoined"]) : undefined
const azureTenantName =
(azureDeviceState ? readString(azureDeviceState, "TenantName") : undefined) ??
(azureTenantDetails ? readString(azureTenantDetails, "TenantName") : undefined)
const azureDeviceId =
(azureDeviceState ? readString(azureDeviceState, "DeviceId") : undefined) ??
(azureTenantDetails ? readString(azureTenantDetails, "DeviceId") : undefined)
const azureUserSso = azureUserState
? readString(azureUserState, "AzureAdPrt") ??
(azureUserState["AzureAdPrt"] ? String(azureUserState["AzureAdPrt"]) : undefined)
: undefined
const windowsDefender = windowsExt?.defender ?? null
const defenderAntivirus = windowsDefender
? parseBooleanLike(windowsDefender["AntivirusEnabled"] ?? windowsDefender["antivirusEnabled"])
: undefined
const defenderRealtime = windowsDefender
? parseBooleanLike(windowsDefender["RealTimeProtectionEnabled"] ?? windowsDefender["realTimeProtectionEnabled"])
: undefined
const defenderMode = windowsDefender ? readString(windowsDefender, "AMRunningMode", "amRunningMode") : undefined
const windowsHotfixes = useMemo(() => {
if (!Array.isArray(windowsExt?.hotfix)) return []
return windowsExt.hotfix
.map((entry) => {
const record = toRecord(entry) ?? {}
const id =
readString(record, "HotFixID", "hotFixId", "HotfixId", "Id", "id") ??
"Atualização"
const installedAt = parseDateish(record["InstalledOn"] ?? record["installedOn"])
const installedLabel = installedAt
? formatAbsoluteDateTime(installedAt)
: readString(record, "InstalledOn", "installedOn") ?? "—"
return { id, installedAt, installedLabel }
})
.sort((a, b) => {
if (a.installedAt && b.installedAt) {
return b.installedAt.getTime() - a.installedAt.getTime()
}
if (a.installedAt) return -1
if (b.installedAt) return 1
return a.id.localeCompare(b.id)
})
}, [windowsExt?.hotfix])
const osNameDisplay = useMemo(() => {
const base = machine?.osName?.trim()
const edition = windowsEditionLabel?.trim()
if (edition) {
if (!base) return edition
const baseLower = base.toLowerCase()
const editionLower = edition.toLowerCase()
if (editionLower.includes(baseLower) || baseLower.includes(editionLower)) {
return edition
}
if (baseLower.startsWith("windows") && editionLower.startsWith("windows")) {
return edition
}
return `${base} ${edition}`.replace(/\s+/g, " ").trim()
}
return base ?? ""
}, [machine?.osName, windowsEditionLabel])
const linuxLsblk = linuxExt?.lsblk ?? []
const linuxSmartEntries = linuxExt?.smart ?? []
const normalizedHardwareGpus = Array.isArray(hardware?.gpus)
? hardware.gpus.map((gpu) => normalizeGpuSource(gpu)).filter((gpu): gpu is GpuAdapter => Boolean(gpu))
: []
const hardwarePrimaryGpu = hardware?.primaryGpu ? normalizeGpuSource(hardware.primaryGpu) : null
const windowsCpuRaw = windowsExt?.cpu
const winCpu = windowsCpuRaw
? (Array.isArray(windowsCpuRaw) ? windowsCpuRaw[0] ?? null : windowsCpuRaw)
: null
const winMemTotal = windowsMemoryModules.reduce((acc, module) => acc + (parseBytesLike(module?.Capacity) ?? 0), 0)
const normalizedWindowsGpus = windowsVideoControllers
.map((controller) => normalizeGpuSource(controller))
.filter((gpu): gpu is GpuAdapter => Boolean(gpu))
const combinedGpus = uniqueBy(
[
...(hardwarePrimaryGpu ? [hardwarePrimaryGpu] : []),
...normalizedHardwareGpus,
...normalizedWindowsGpus,
],
(gpu) => `${gpu.name ?? ""}|${gpu.vendor ?? ""}|${gpu.driver ?? ""}`
)
const displayGpus = [...combinedGpus].sort(
(a, b) => (b.memoryBytes ?? 0) - (a.memoryBytes ?? 0)
)
const primaryGpu = hardwarePrimaryGpu ?? displayGpus[0] ?? null
const windowsPrimaryGpu = [...normalizedWindowsGpus].sort(
(a, b) => (b.memoryBytes ?? 0) - (a.memoryBytes ?? 0)
)[0] ?? null
const windowsCpuDetails = windowsCpuRaw
? Array.isArray(windowsCpuRaw)
? windowsCpuRaw
: [windowsCpuRaw]
: []
const winDiskStats = windowsDiskEntries.length > 0
? {
count: windowsDiskEntries.length,
total: windowsDiskEntries.reduce((acc, disk) => acc + (parseBytesLike(disk?.Size) ?? 0), 0),
}
: { count: 0, total: 0 }
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.")
}
}
// collaborator (from machine assignment or metadata)
type Collaborator = { email?: string; name?: string; role?: string }
const collaborator: Collaborator | null = useMemo(() => {
if (machine?.assignedUserEmail) {
return {
email: machine.assignedUserEmail ?? undefined,
name: machine.assignedUserName ?? undefined,
role: machine.persona ?? machine.assignedUserRole ?? undefined,
}
}
if (!metadata || typeof metadata !== "object") return null
const inv = metadata as Record<string, unknown>
const c = inv["collaborator"]
if (c && typeof c === "object") {
const base = c as Record<string, unknown>
return {
email: typeof base.email === "string" ? base.email : undefined,
name: typeof base.name === "string" ? base.name : undefined,
role: typeof base.role === "string" ? (base.role as string) : undefined,
}
}
return null
}, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata])
const primaryLinkedUser: Collaborator | null = useMemo(() => {
const firstLinked = machine?.linkedUsers?.find((user) => typeof user?.email === "string" && user.email.trim().length > 0)
if (firstLinked) {
return {
email: firstLinked.email,
name: firstLinked.name ?? undefined,
role: collaborator?.role ?? machine?.persona ?? undefined,
}
}
if (collaborator?.email) {
return collaborator
}
if (machine?.authEmail) {
return {
email: machine.authEmail ?? undefined,
name: undefined,
role: machine?.persona ?? undefined,
}
}
return null
}, [collaborator, machine?.authEmail, machine?.linkedUsers, machine?.persona])
const personaRole = (primaryLinkedUser?.role ?? collaborator?.role ?? machine?.persona ?? "").toLowerCase()
const personaLabel = personaRole === "manager" ? "Gestor" : "Colaborador"
const remoteAccessEntries = useMemo(() => machine?.remoteAccessEntries ?? [], [machine?.remoteAccessEntries])
const hasRemoteAccess = remoteAccessEntries.length > 0
const summaryChips = useMemo(() => {
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
const osName = osNameDisplay || "Sistema desconhecido"
const osVersionRaw = machine?.osVersion ?? windowsVersionLabel ?? ""
const osVersion = formatOsVersionDisplay(osNameDisplay, osVersionRaw)
chips.push({
key: "os",
label: "Sistema",
value: [osName, osVersion].filter(Boolean).join(" ").trim(),
icon: <OsIcon osName={machine?.osName} />,
})
if (machine?.architecture) {
chips.push({
key: "arch",
label: "Arquitetura",
value: machine.architecture.toUpperCase(),
icon: <Cpu className="size-4 text-neutral-500" />,
})
}
if (windowsBuildLabel) {
chips.push({
key: "build",
label: "Build",
value: windowsBuildLabel,
icon: <ServerCog className="size-4 text-neutral-500" />,
})
}
if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) {
chips.push({
key: "activation",
label: "Licença",
value: windowsActivationStatus ? "Ativada" : "Não ativada",
icon: windowsActivationStatus ? <ShieldCheck className="size-4 text-emerald-500" /> : <ShieldAlert className="size-4 text-amber-500" />,
tone: windowsActivationStatus ? undefined : "warning",
})
}
if (primaryLinkedUser?.email) {
const collaboratorValue = primaryLinkedUser.name ? `${primaryLinkedUser.name} · ${primaryLinkedUser.email}` : primaryLinkedUser.email
chips.push({
key: "collaborator",
label: personaLabel,
value: collaboratorValue,
icon: <ShieldCheck className="size-4 text-neutral-500" />,
})
}
const primaryRemoteAccess = remoteAccessEntries[0]
if (primaryRemoteAccess && (primaryRemoteAccess.identifier || primaryRemoteAccess.url)) {
const value = primaryRemoteAccess.identifier ?? primaryRemoteAccess.url ?? "—"
const label = primaryRemoteAccess.provider ? `Acesso (${primaryRemoteAccess.provider})` : "Acesso remoto"
chips.push({
key: "remote-access",
label,
value,
icon: <Key className="size-4 text-neutral-500" />,
})
}
return chips
}, [
osNameDisplay,
machine?.osVersion,
machine?.architecture,
windowsVersionLabel,
windowsBuildLabel,
windowsActivationStatus,
primaryLinkedUser?.email,
primaryLinkedUser?.name,
personaLabel,
machine?.osName,
remoteAccessEntries,
])
const companyName = machine?.companyName ?? machine?.companySlug ?? null
const [renaming, setRenaming] = useState(false)
const [newName, setNewName] = useState<string>(machine?.hostname ?? "")
const [openDialog, setOpenDialog] = useState(false)
const [dialogQuery, setDialogQuery] = useState("")
const [deleteDialog, setDeleteDialog] = useState(false)
const [deleting, setDeleting] = useState(false)
const [accessDialog, setAccessDialog] = useState(false)
const [accessEmail, setAccessEmail] = useState<string>(primaryLinkedUser?.email ?? "")
const [accessName, setAccessName] = useState<string>(primaryLinkedUser?.name ?? "")
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator")
const [savingAccess, setSavingAccess] = useState(false)
const [remoteAccessDialog, setRemoteAccessDialog] = useState(false)
const [editingRemoteAccessClientId, setEditingRemoteAccessClientId] = useState<string | null>(null)
const [remoteAccessProviderOption, setRemoteAccessProviderOption] = useState<RemoteAccessProviderValue>(
REMOTE_ACCESS_PROVIDERS[0].value,
)
const [remoteAccessCustomProvider, setRemoteAccessCustomProvider] = useState("")
const [remoteAccessIdentifierInput, setRemoteAccessIdentifierInput] = useState("")
const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("")
const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("")
const [remoteAccessSaving, setRemoteAccessSaving] = useState(false)
const editingRemoteAccess = useMemo(
() => remoteAccessEntries.find((entry) => entry.clientId === editingRemoteAccessClientId) ?? null,
[editingRemoteAccessClientId, remoteAccessEntries]
)
const [togglingActive, setTogglingActive] = useState(false)
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
const jsonText = useMemo(() => {
const payload = {
id: machine?.id,
hostname: machine?.hostname,
status: machine?.status,
lastHeartbeatAt: machine?.lastHeartbeatAt,
metrics,
inventory: metadata,
postureAlerts: machine?.postureAlerts ?? null,
lastPostureAt: machine?.lastPostureAt ?? null,
}
return JSON.stringify(payload, null, 2)
}, [machine, metrics, metadata])
const handleDownloadInventoryJson = useCallback(() => {
if (!machine) return
const safeHostname = machine.hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase()
const fileName = `${safeHostname || "machine"}_${machine.id}.json`
const blob = new Blob([jsonText], { type: "application/json" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, [jsonText, machine])
const filteredJsonHtml = useMemo(() => {
if (!dialogQuery.trim()) return jsonText
const q = dialogQuery.trim().toLowerCase()
// highlight simples
return jsonText.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (m) => `__HIGHLIGHT__${m}__END__`)
}, [jsonText, dialogQuery])
// removed copy/export inventory JSON buttons as requested
useEffect(() => {
setAccessEmail(primaryLinkedUser?.email ?? "")
setAccessName(primaryLinkedUser?.name ?? "")
setAccessRole(personaRole === "manager" ? "manager" : "collaborator")
}, [machine?.id, primaryLinkedUser?.email, primaryLinkedUser?.name, personaRole])
useEffect(() => {
setIsActiveLocal(machine?.isActive ?? true)
}, [machine?.isActive])
useEffect(() => {
if (!remoteAccessDialog) return
const providerName = editingRemoteAccess?.provider ?? ""
const matched = REMOTE_ACCESS_PROVIDERS.find(
(option) => option.value !== "OTHER" && option.label.toLowerCase() === providerName.toLowerCase(),
)
if (matched) {
setRemoteAccessProviderOption(matched.value)
setRemoteAccessCustomProvider("")
} else {
setRemoteAccessProviderOption(providerName ? "OTHER" : REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider(providerName ?? "")
}
setRemoteAccessIdentifierInput(editingRemoteAccess?.identifier ?? "")
setRemoteAccessUrlInput(editingRemoteAccess?.url ?? "")
setRemoteAccessNotesInput(editingRemoteAccess?.notes ?? "")
}, [remoteAccessDialog, editingRemoteAccess])
useEffect(() => {
if (remoteAccessDialog) return
if (!editingRemoteAccessClientId) {
setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider("")
setRemoteAccessIdentifierInput("")
setRemoteAccessUrlInput("")
setRemoteAccessNotesInput("")
}
}, [editingRemoteAccessClientId, remoteAccessDialog])
useEffect(() => {
setShowAllWindowsSoftware(false)
}, [machine?.id])
const displayedWindowsSoftware = useMemo(
() => (showAllWindowsSoftware ? normalizedWindowsSoftware : normalizedWindowsSoftware.slice(0, 12)),
[showAllWindowsSoftware, normalizedWindowsSoftware]
)
const handleSaveAccess = async () => {
if (!machine) return
if (!accessEmail.trim()) {
toast.error("Informe o e-mail do colaborador ou gestor.")
return
}
setSavingAccess(true)
try {
const response = await fetch("/api/admin/machines/access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
persona: accessRole,
email: accessEmail.trim(),
name: accessName.trim() || undefined,
}),
})
if (!response.ok) {
throw new Error(await response.text())
}
toast.success("Perfil de acesso atualizado.")
setAccessDialog(false)
} catch (error) {
console.error(error)
toast.error("Falha ao atualizar acesso da máquina.")
} finally {
setSavingAccess(false)
}
}
const handleSaveRemoteAccess = useCallback(async () => {
if (!machine) return
if (!canManageRemoteAccess) {
toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.")
return
}
const providerOption = REMOTE_ACCESS_PROVIDERS.find((option) => option.value === remoteAccessProviderOption)
const providerName =
remoteAccessProviderOption === "OTHER"
? remoteAccessCustomProvider.trim()
: providerOption?.label ?? ""
if (!providerName) {
toast.error("Informe a ferramenta de acesso remoto.")
return
}
const identifier = remoteAccessIdentifierInput.trim()
if (!identifier) {
toast.error("Informe o ID ou código do acesso remoto.")
return
}
let normalizedUrl: string | undefined
const rawUrl = remoteAccessUrlInput.trim()
if (rawUrl.length > 0) {
const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`
try {
new URL(candidate)
normalizedUrl = candidate
} catch {
toast.error("Informe uma URL válida (ex: https://example.com).")
return
}
}
const notes = remoteAccessNotesInput.trim()
toast.dismiss("remote-access")
toast.loading("Salvando acesso remoto...", { id: "remote-access" })
setRemoteAccessSaving(true)
try {
if (editingRemoteAccess && !editingRemoteAccess.id) {
const cleanupPayload: Record<string, unknown> = {
machineId: machine.id,
action: "delete",
}
if (editingRemoteAccess.provider) cleanupPayload.provider = editingRemoteAccess.provider
if (editingRemoteAccess.identifier) cleanupPayload.identifier = editingRemoteAccess.identifier
if (editingRemoteAccess.clientId) cleanupPayload.entryId = editingRemoteAccess.clientId
await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cleanupPayload),
}).catch(() => null)
}
const response = await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
provider: providerName,
identifier,
url: normalizedUrl,
notes: notes.length ? notes : undefined,
action: "upsert",
entryId: editingRemoteAccess?.id ?? undefined,
}),
})
const responsePayload = await response.json().catch(() => null)
if (!response.ok) {
const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao atualizar acesso remoto."
const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null
throw new Error(detailMessage ? `${message}. ${detailMessage}` : message)
}
toast.success("Acesso remoto atualizado.", { id: "remote-access" })
setRemoteAccessDialog(false)
setEditingRemoteAccessClientId(null)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao atualizar acesso remoto."
toast.error(message, { id: "remote-access" })
} finally {
setRemoteAccessSaving(false)
}
}, [
machine,
canManageRemoteAccess,
remoteAccessProviderOption,
remoteAccessCustomProvider,
remoteAccessIdentifierInput,
remoteAccessUrlInput,
remoteAccessNotesInput,
editingRemoteAccess,
])
const handleRemoveRemoteAccess = useCallback(async (entry: MachineRemoteAccessEntry) => {
if (!machine) return
if (!canManageRemoteAccess) {
toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.")
return
}
toast.dismiss("remote-access")
toast.loading("Removendo acesso remoto...", { id: "remote-access" })
setRemoteAccessSaving(true)
try {
const requestPayload: Record<string, unknown> = {
machineId: machine.id,
action: "delete",
}
if (entry.id) {
requestPayload.entryId = entry.id
} else if (entry.clientId) {
requestPayload.entryId = entry.clientId
}
if (entry.provider) {
requestPayload.provider = entry.provider
}
if (entry.identifier) {
requestPayload.identifier = entry.identifier
}
const response = await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestPayload),
})
const responsePayload = await response.json().catch(() => null)
if (!response.ok) {
const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao remover acesso remoto."
const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null
throw new Error(detailMessage ? `${message}. ${detailMessage}` : message)
}
toast.success("Acesso remoto removido.", { id: "remote-access" })
setRemoteAccessDialog(false)
setEditingRemoteAccessClientId(null)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao remover acesso remoto."
toast.error(message, { id: "remote-access" })
} finally {
setRemoteAccessSaving(false)
}
}, [machine, canManageRemoteAccess])
const handleToggleActive = async () => {
if (!machine) return
const nextActive = !isActiveLocal
setIsActiveLocal(nextActive)
setTogglingActive(true)
try {
const response = await fetch("/api/admin/machines/toggle-active", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id, active: nextActive }),
credentials: "include",
})
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string }
throw new Error(payload?.error ?? "Falha ao atualizar status")
}
toast.success(nextActive ? "Máquina reativada" : "Máquina desativada")
} catch (error) {
console.error(error)
setIsActiveLocal(!nextActive)
toast.error("Não foi possível atualizar o status da máquina.")
} finally {
setTogglingActive(false)
}
}
const handleCopyRemoteIdentifier = useCallback(async (identifier: string | null | undefined) => {
if (!identifier) return
try {
await navigator.clipboard.writeText(identifier)
toast.success("Identificador de acesso remoto copiado.")
} catch (error) {
console.error(error)
toast.error("Não foi possível copiar o identificador.")
}
}, [])
return (
<Card className="border-slate-200">
<CardHeader className="gap-1">
<CardTitle>Detalhes</CardTitle>
<CardDescription>Resumo da máquina selecionada</CardDescription>
{machine ? (
<CardAction>
<div className="flex flex-col items-end gap-2 text-xs sm:text-sm">
{companyName ? (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 font-semibold text-neutral-600 shadow-sm">
{companyName}
</div>
) : null}
{!isDeactivated ? <MachineStatusBadge status={effectiveStatus} /> : null}
{!isActiveLocal ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
Máquina desativada
</Badge>
) : null}
</div>
</CardAction>
) : null}
</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 flex-wrap items-start gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{machine.hostname}</h1>
<Button size="icon" variant="ghost" className="size-7" onClick={() => { setNewName(machine.hostname); setRenaming(true) }}>
<Pencil className="size-4" />
<span className="sr-only">Renomear máquina</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
{machine.authEmail ?? "E-mail não definido"}
</p>
</div>
</div>
{/* ping integrado na badge de status */}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{summaryChips.map((chip) => (
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
))}
</div>
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{machineTicketsHref ? (
<Link
href={machineTicketsHref}
className="text-xs font-semibold text-accent-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
) : null}
</div>
{totalOpenTickets === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Nenhum chamado em aberto registrado diretamente por esta máquina.
</p>
) : (
<div className="space-y-2">
{hasAdditionalOpenTickets ? (
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70">
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados
em aberto
</p>
) : null}
<ul className="space-y-2">
{displayedMachineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<li key={ticket.id}>
<Link
href={`/tickets/${ticket.id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
</li>
)
})}
</ul>
</div>
)}
</div>
<div className="self-center justify-self-end">
<div className="flex h-12 min-w-[72px] items-center justify-center rounded-2xl border border-[color:var(--accent)] bg-white px-5 shadow-sm sm:min-w-[88px]">
<span className="text-2xl font-semibold leading-none text-accent-foreground tabular-nums sm:text-3xl">
{totalOpenTickets}
</span>
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{machine.authEmail ? (
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
<ClipboardCopy className="size-4" />
Copiar e-mail
</Button>
) : null}
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
<Button
size="sm"
variant={isActiveLocal ? "outline" : "default"}
className={cn(
"gap-2 border-dashed",
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)}
onClick={handleToggleActive}
disabled={togglingActive}
>
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
{machine.registeredBy ? (
<span
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-2 border-dashed border-slate-200 bg-background cursor-default select-text text-neutral-700 hover:bg-background hover:text-neutral-700 focus-visible:outline-none"
)}
>
Registrada via {machine.registeredBy}
</span>
) : null}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Acesso remoto</h4>
{hasRemoteAccess ? (
<Badge variant="outline" className="border-slate-200 bg-slate-100 text-[11px] font-semibold text-slate-700">
{remoteAccessEntries.length === 1
? remoteAccessEntries[0].provider ?? "Configuração única"
: `${remoteAccessEntries.length} acessos`}
</Badge>
) : null}
</div>
{canManageRemoteAccess ? (
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed"
onClick={() => {
setEditingRemoteAccessClientId(null)
setRemoteAccessDialog(true)
}}
>
<Key className="size-4" />
{hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"}
</Button>
) : null}
</div>
{hasRemoteAccess ? (
<div className="space-y-3">
{remoteAccessEntries.map((entry) => {
const metadataEntries = extractRemoteAccessMetadataEntries(entry.metadata)
const lastVerifiedDate =
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
? new Date(entry.lastVerifiedAt)
: null
return (
<div key={entry.clientId} className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-700">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{entry.provider ? (
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-semibold text-slate-700">
{entry.provider}
</Badge>
) : null}
{entry.identifier ? (
<span className="font-semibold text-neutral-800">{entry.identifier}</span>
) : null}
{entry.identifier ? (
<Button
variant="ghost"
size="sm"
className="h-7 gap-2 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
>
<ClipboardCopy className="size-3.5" /> Copiar ID
</Button>
) : null}
</div>
{entry.url ? (
<a
href={entry.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-slate-600 underline-offset-4 hover:text-slate-900 hover:underline"
>
Abrir console remoto
</a>
) : null}
{entry.notes ? (
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{entry.notes}</p>
) : null}
{lastVerifiedDate ? (
<p className="text-[11px] text-slate-500">
Atualizado {formatRelativeTime(lastVerifiedDate)}{" "}
<span className="text-slate-400">({formatAbsoluteDateTime(lastVerifiedDate)})</span>
</p>
) : null}
</div>
{canManageRemoteAccess ? (
<div className="flex items-start gap-2">
<Button
size="sm"
variant="outline"
className="gap-2 border-slate-300"
onClick={() => {
setEditingRemoteAccessClientId(entry.clientId)
setRemoteAccessDialog(true)
}}
>
<Pencil className="size-3.5" /> Editar
</Button>
<Button
size="sm"
variant="ghost"
className="gap-2 text-rose-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
onClick={() => void handleRemoveRemoteAccess(entry)}
disabled={remoteAccessSaving}
>
<ShieldOff className="size-3.5" /> Remover
</Button>
</div>
) : null}
</div>
{metadataEntries.length ? (
<details className="mt-3 rounded-lg border border-slate-200 bg-white/70 px-3 py-2 text-[11px] text-slate-600">
<summary className="cursor-pointer font-semibold text-slate-700 outline-none transition-colors hover:text-slate-900">
Metadados adicionais
</summary>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{metadataEntries.map(([key, value]) => (
<div key={`${entry.clientId}-${key}`} className="flex items-center justify-between gap-3 rounded-md border border-slate-200 bg-white px-2 py-1 shadow-sm">
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
</div>
))}
</div>
</details>
) : null}
</div>
)
})}
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-600">
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
</div>
)}
</div>
</section>
<section className="space-y-2">
<h4 className="text-sm font-semibold">Usuários vinculados</h4>
<div className="space-y-2">
{primaryLinkedUser?.email ? (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-2 text-sm">
<ShieldCheck className="size-4 text-slate-500" />
<span className="font-medium text-neutral-800">{primaryLinkedUser.name || primaryLinkedUser.email}</span>
<span className="text-neutral-500">{primaryLinkedUser.name ? `· ${primaryLinkedUser.email}` : ""}</span>
<span className="ml-2 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-600">Principal</span>
</div>
) : null}
{Array.isArray(machine.linkedUsers) && machine.linkedUsers.length > 0 ? (
<ul className="divide-y divide-slate-200 overflow-hidden rounded-md border border-slate-200 bg-slate-50/60">
{machine.linkedUsers.map((u) => (
<li key={`lu-${u.id}`} className="flex items-center gap-2 px-3 py-2 text-sm">
<span className="font-medium text-neutral-800">{u.name || u.email}</span>
<span className="text-neutral-500">{u.name ? `· ${u.email}` : ''}</span>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
onClick={async () => {
try {
const res = await fetch(`/api/admin/machines/links?machineId=${machine.id}&userId=${u.id}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
toast.success('Vínculo removido')
} catch (e) {
console.error(e)
toast.error('Falha ao remover vínculo')
}
}}
>
Remover
</Button>
</div>
</li>
))}
</ul>
) : null}
{!primaryLinkedUser?.email && (!machine.linkedUsers || machine.linkedUsers.length === 0) ? (
<p className="text-xs text-neutral-500">Nenhum usuário vinculado.</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Input
value={accessEmail}
onChange={(e) => setAccessEmail(e.target.value)}
placeholder="e-mail do usuário para vincular"
className="max-w-xs"
type="email"
/>
<Button
variant="outline"
onClick={async () => {
if (!accessEmail.trim()) {
toast.error('Informe um e-mail para vincular')
return
}
try {
const res = await fetch('/api/admin/machines/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ machineId: machine.id, email: accessEmail.trim() }),
})
if (!res.ok) throw new Error('HTTP ' + res.status)
toast.success('Usuário vinculado')
setAccessEmail('')
} catch (e) {
console.error(e)
toast.error('Falha ao vincular usuário')
}
}}
>
Adicionar vínculo
</Button>
<span className="text-xs text-neutral-500">Somente colaboradores/gestores.</span>
<Link href="/admin/users" className="text-xs underline underline-offset-4">Gerenciar usuários</Link>
</div>
</div>
</section>
{/* Renomear máquina */}
<Dialog open={renaming} onOpenChange={setRenaming}>
<DialogContent>
<DialogHeader>
<DialogTitle>Renomear máquina</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Novo hostname" />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRenaming(false)}>Cancelar</Button>
<Button
onClick={async () => {
if (!machine) return
const name = (newName ?? "").trim()
if (name.length < 2) {
toast.error("Informe um nome válido")
return
}
try {
const res = await fetch("/api/admin/machines/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id, hostname: name }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
toast.success("Máquina renomeada")
setRenaming(false)
} catch (err) {
console.error(err)
toast.error("Falha ao renomear máquina")
}
}}
>
Salvar
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={accessDialog} onOpenChange={setAccessDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ajustar acesso da máquina</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-2">
<label className="text-sm font-medium">Perfil</label>
<Select value={accessRole} onValueChange={(value) => setAccessRole((value as "collaborator" | "manager") ?? "collaborator")}>
<SelectTrigger>
<SelectValue placeholder="Selecione o perfil" />
</SelectTrigger>
<SelectContent>
<SelectItem value="collaborator">Colaborador (portal)</SelectItem>
<SelectItem value="manager">Gestor (painel completo)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">E-mail</label>
<Input type="email" value={accessEmail} onChange={(e) => setAccessEmail(e.target.value)} placeholder="colaborador@empresa.com" />
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Nome (opcional)</label>
<Input value={accessName} onChange={(e) => setAccessName(e.target.value)} placeholder="Nome completo" />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAccessDialog(false)} disabled={savingAccess}>Cancelar</Button>
<Button onClick={handleSaveAccess} disabled={savingAccess || !accessEmail.trim()}>
{savingAccess ? "Salvando..." : "Salvar"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={remoteAccessDialog}
onOpenChange={(open) => {
setRemoteAccessDialog(open)
if (!open) {
setRemoteAccessSaving(false)
setEditingRemoteAccessClientId(null)
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingRemoteAccess ? "Editar acesso remoto" : "Adicionar acesso remoto"}</DialogTitle>
<DialogDescription>
Registre os detalhes do acesso remoto utilizado por esta máquina.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault()
void handleSaveRemoteAccess()
}}
className="space-y-4"
>
<div className="grid gap-2">
<label className="text-sm font-medium">Ferramenta</label>
<Select
value={remoteAccessProviderOption}
onValueChange={(value) => setRemoteAccessProviderOption(value as RemoteAccessProviderValue)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o provedor" />
</SelectTrigger>
<SelectContent>
{REMOTE_ACCESS_PROVIDERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{remoteAccessProviderOption === "OTHER" ? (
<div className="grid gap-2">
<label className="text-sm font-medium">Nome da ferramenta</label>
<Input
value={remoteAccessCustomProvider}
onChange={(event) => setRemoteAccessCustomProvider(event.target.value)}
placeholder="Ex: Supremo, Zoho Assist..."
autoFocus
/>
</div>
) : null}
<div className="grid gap-2">
<label className="text-sm font-medium">ID / código</label>
<Input
value={remoteAccessIdentifierInput}
onChange={(event) => setRemoteAccessIdentifierInput(event.target.value)}
placeholder="Ex: 123 456 789"
required
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Link (opcional)</label>
<Input
value={remoteAccessUrlInput}
onChange={(event) => setRemoteAccessUrlInput(event.target.value)}
placeholder="https://"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Observações</label>
<Textarea
value={remoteAccessNotesInput}
onChange={(event) => setRemoteAccessNotesInput(event.target.value)}
rows={3}
placeholder="Credencial compartilhada, PIN adicional, instruções..."
/>
</div>
<DialogFooter className="flex flex-wrap items-center justify-between gap-2">
{editingRemoteAccess ? (
<Button
type="button"
variant="outline"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
onClick={() => editingRemoteAccess && void handleRemoveRemoteAccess(editingRemoteAccess)}
disabled={remoteAccessSaving}
>
Remover acesso
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setRemoteAccessDialog(false)}
disabled={remoteAccessSaving}
>
Cancelar
</Button>
<Button type="submit" disabled={remoteAccessSaving}>
{remoteAccessSaving ? "Salvando..." : editingRemoteAccess ? "Atualizar" : "Salvar"}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<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>
<section className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
{lastUpdateRelative ? (
<span className="text-xs font-medium text-muted-foreground">
Última atualização {lastUpdateRelative}
</span>
) : null}
</div>
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
</section>
{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))} />
{displayGpus.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">
{displayGpus.slice(0, 3).map((gpu, idx) => {
const { name, memoryBytes, driver, vendor } = gpu
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>
)
})}
{displayGpus.length > 3 ? (
<li className="text-muted-foreground">+{displayGpus.length - 3} adaptadores adicionais</li>
) : null}
</ul>
</div>
) : null}
</div>
</div>
) : null}
{networkInterfaces ? (
<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 (interfaces)</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Interface</TableHead>
<TableHead className="text-xs text-slate-500">MAC</TableHead>
<TableHead className="text-xs text-slate-500">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{networkInterfaces.map((iface, idx) => (
<TableRow key={`iface-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{iface?.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.mac ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.ip ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : networkSummary ? (
<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={networkSummary.primaryIp} />
<DetailLine label="IP público" value={networkSummary.publicIp} />
<DetailLine
label="MAC addresses"
value={
Array.isArray(networkSummary.macAddresses)
? networkSummary.macAddresses.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}
{/* Discos (agente) */}
{disks.length > 0 ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Discos e partições</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">Mount</TableHead>
<TableHead className="text-xs text-slate-500">FS</TableHead>
<TableHead className="text-xs text-slate-500">Capacidade</TableHead>
<TableHead className="text-xs text-slate-500">Livre</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{disks.map((d, idx) => (
<TableRow key={`disk-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{d.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.mountPoint ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.fs ?? "—"}</TableCell>
<TableCell className="text-sm text-foreground">{formatBytes(Number(d.totalBytes))}</TableCell>
<TableCell className="text-sm text-muted-foreground">{formatBytes(Number(d.availableBytes))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
) : null}
{/* Inventário estendido por SO */}
{extended ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário estendido</h4>
<p className="text-xs text-muted-foreground">Dados ricos coletados pelo agente, variam por sistema operacional.</p>
</div>
{/* Linux */}
{linuxExt ? (
<div className="space-y-3">
{linuxLsblk.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">Montagens (lsblk)</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<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">Ponto de montagem</TableHead>
<TableHead className="text-xs text-slate-500">FS</TableHead>
<TableHead className="text-xs text-slate-500">Tamanho</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{linuxLsblk.slice(0, 18).map((entry, idx) => {
const name = entry.name ?? "—"
const mp = entry.mountPoint ?? entry.mountpoint ?? "—"
const fs = entry.fs ?? entry.fstype ?? "—"
const sizeRaw = typeof entry.sizeBytes === "number" ? entry.sizeBytes : entry.size
return (
<TableRow key={`lsblk-${idx}`} className="border-slate-100">
<TableCell className="text-sm text-foreground">{name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{mp || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fs || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{typeof sizeRaw === "number" ? formatBytes(sizeRaw) : "—"}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{linuxSmartEntries.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-emerald-50/40 p-3 dark:bg-emerald-900/10">
<p className="text-xs font-semibold uppercase text-slate-500">SMART</p>
<div className="mt-2 grid gap-2">
{linuxSmartEntries.map((smartEntry, idx) => {
const ok = smartEntry.smart_status?.passed !== false
const model = smartEntry.model_name ?? smartEntry.model_family ?? "Disco"
const serial = smartEntry.serial_number ?? smartEntry.device?.name ?? "—"
return (
<div
key={`smart-${idx}`}
className={cn(
"flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm",
ok
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-700"
: "border-rose-500/20 bg-rose-500/15 text-rose-700"
)}
>
<span className="font-medium text-foreground">
{model} <span className="text-muted-foreground">({serial})</span>
</span>
<Badge variant="outline" className="text-xs uppercase">
{ok ? "OK" : "ALERTA"}
</Badge>
</div>
)
})}
</div>
</div>
) : null}
{linuxExt.lspci ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">PCI</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lspci}</pre>
</div>
) : null}
{linuxExt.lsusb ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">USB</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lsusb}</pre>
</div>
) : null}
</div>
) : null}
{/* Windows */}
{windowsExt ? (
<div className="space-y-4">
{/* Cards resumidos: CPU / RAM / GPU / Discos */}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<Cpu className="size-5 text-slate-500" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">CPU</p>
<p className="break-words text-sm font-semibold text-foreground">{winCpu?.Name ?? "—"}</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<MemoryStick className="size-5 text-slate-500" />
<div>
<p className="text-xs text-muted-foreground">Memória total</p>
<p className="text-sm font-semibold text-foreground">{formatBytes(winMemTotal)}</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<Terminal className="size-5 text-slate-500" />
<div className="min-w-0 space-y-0.5">
<p className="text-xs text-muted-foreground">Windows</p>
<p className="break-words text-sm font-semibold text-foreground">
{windowsEditionLabel ?? machine?.osName ?? "—"}
</p>
<p className="break-words text-xs text-muted-foreground">
{windowsVersionLabel ?? windowsBuildLabel ?? "Versão desconhecida"}
</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<Monitor className="size-5 text-slate-500" />
<div className="min-w-0 space-y-0.5">
<p className="text-xs text-muted-foreground">GPU</p>
<p className="break-words text-sm font-semibold text-foreground">
{(windowsPrimaryGpu ?? primaryGpu)?.name ?? "—"}
</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<HardDrive className="size-5 text-slate-500" />
<div>
<p className="text-xs text-muted-foreground">Discos</p>
<p className="text-sm font-semibold text-foreground">{winDiskStats.count} · {formatBytes(winDiskStats.total)}</p>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Monitor className="size-4 text-slate-500" />
Sistema operacional
</CardTitle>
<CardDescription>Build, licença e data de instalação</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
{windowsVersionLabel ? (
<Badge className={BADGE_NEUTRAL}>
<RefreshCcw className="size-3" />
{windowsVersionLabel}
</Badge>
) : null}
{windowsBuildLabel ? (
<Badge className={BADGE_NEUTRAL}>Build {windowsBuildLabel}</Badge>
) : null}
{windowsLicenseStatusLabel ? (
<Badge className={windowsActivationStatus ? BADGE_POSITIVE : BADGE_WARNING}>
{windowsActivationStatus ? <ShieldCheck className="size-3" /> : <ShieldAlert className="size-3" />}
{windowsLicenseStatusLabel}
</Badge>
) : null}
</div>
<div className="grid gap-2">
<DetailLine label="Nome do dispositivo" value={windowsComputerName ?? machine?.hostname ?? "—"} classNameValue="break-words" />
<DetailLine label="Edição" value={windowsEditionLabel ?? windowsOsInfo?.productName ?? "—"} classNameValue="break-words" />
<DetailLine label="Experiência" value={windowsExperienceLabel ?? "—"} />
<DetailLine label="Instalação" value={windowsInstallDateLabel ?? "—"} />
<DetailLine label="ID do produto" value={windowsProductId ?? "—"} classNameValue="break-words" />
<DetailLine label="Chave parcial" value={windowsPartialProductKey ?? "—"} classNameValue="break-words" />
<DetailLine label="Proprietário registrado" value={windowsRegisteredOwner ?? "—"} classNameValue="break-words" />
<DetailLine label="Número de série" value={windowsSerialNumber ?? "—"} />
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Globe className="size-4 text-slate-500" />
Identidade e dispositivo
</CardTitle>
<CardDescription>Associação a domínio, Azure AD e detalhes físicos</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="grid gap-2">
<DetailLine label="Fabricante" value={computerManufacturer ?? "—"} />
<DetailLine label="Modelo" value={computerModel ?? "—"} />
<DetailLine label="Tipo de sistema" value={computerPcTypeLabel ?? "—"} />
<DetailLine label="Domínio" value={computerDomain ?? "—"} />
<DetailLine label="Workgroup" value={computerWorkgroup ?? "—"} />
<DetailLine label="Função" value={computerDomainRoleLabel ?? "—"} />
<DetailLine label="Memória física" value={computerTotalMemoryLabel ?? "—"} />
</div>
<div className="flex flex-wrap gap-2 text-xs">
{computerPartOfDomain !== undefined ? (
<Badge className={computerPartOfDomain ? BADGE_POSITIVE : BADGE_WARNING}>
{computerPartOfDomain ? <ShieldCheck className="size-3" /> : <ShieldQuestion className="size-3" />}
{computerPartOfDomain ? `Domínio ativo${computerDomain ? ` (${computerDomain})` : ""}` : "Fora de domínio"}
</Badge>
) : null}
{azureAdJoined !== undefined ? (
<Badge className={azureAdJoined ? BADGE_POSITIVE : BADGE_NEUTRAL}>
<Cloud className="size-3" />
{azureAdJoined ? "Azure AD conectado" : "Azure AD não conectado"}
</Badge>
) : null}
{azureDomainJoined !== undefined ? (
<Badge className={BADGE_NEUTRAL}>
<ShieldQuestion className="size-3" />
Domínio local: {azureDomainJoined ? "Sim" : "Não"}
</Badge>
) : null}
{azureEnterpriseJoined !== undefined ? (
<Badge className={BADGE_NEUTRAL}>
<ShieldQuestion className="size-3" />
Enterprise: {azureEnterpriseJoined ? "Sim" : "Não"}
</Badge>
) : null}
</div>
{azureTenantName ? (
<p className="text-xs text-muted-foreground">Tenant: {azureTenantName}</p>
) : null}
{azureDeviceId ? (
<p className="break-words text-xs text-muted-foreground">Device ID: {azureDeviceId}</p>
) : null}
{azureUserSso ? (
<p className="text-xs text-muted-foreground">Usuário SSO: {azureUserSso}</p>
) : null}
</CardContent>
</Card>
<Card className="border-slate-200 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<RefreshCcw className="size-4 text-slate-500" />
Atualizações do Windows
</CardTitle>
<CardDescription>Configurações automáticas e histórico recente</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm lg:flex-row lg:items-start lg:gap-8">
<div className="flex-1 space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{windowsUpdateDisabled !== undefined ? (
<Badge className={windowsUpdateDisabled ? BADGE_WARNING : BADGE_POSITIVE}>
{windowsUpdateDisabled ? <ShieldOff className="size-3" /> : <ShieldCheck className="size-3" />}
{windowsUpdateDisabled ? "Atualizações desativadas" : "Atualizações automáticas"}
</Badge>
) : null}
{windowsUpdateDetectionEnabled === false ? (
<Badge className={BADGE_WARNING}>
<AlertTriangle className="size-3" />
Sem detecção automática
</Badge>
) : null}
</div>
<div className="grid gap-2">
<DetailLine label="Modo" value={windowsUpdateMode ?? "—"} layout="compact" />
<DetailLine label="Agendamento" value={windowsUpdateScheduleLabel ?? "—"} layout="compact" />
<DetailLine label="Último sucesso" value={windowsUpdateLastSuccessLabel ?? "—"} layout="compact" />
</div>
</div>
{windowsHotfixes.length > 0 ? (
<div className="w-full rounded-md border border-slate-200 bg-slate-50/80 p-3 lg:ml-auto lg:w-[320px] lg:basis-[320px] lg:flex-shrink-0 lg:self-stretch">
<p className="text-xs font-semibold uppercase text-slate-500">Atualizações recentes</p>
<ul className="mt-2 space-y-1 text-xs text-muted-foreground">
{windowsHotfixes.slice(0, 3).map((fix) => (
<li key={fix.id} className="flex flex-col gap-1 border-b border-slate-200 pb-1 last:border-b-0 last:pb-0">
<span className="font-medium text-foreground">{fix.id}</span>
<span className="text-[11px] uppercase tracking-wide text-slate-500">{fix.installedLabel}</span>
</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
<Card className="border-slate-200 lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Shield className="size-4 text-slate-500" />
Segurança do dispositivo
</CardTitle>
<CardDescription>Proteções do Windows, criptografia e firewall</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex flex-wrap gap-2 text-xs">
{windowsActivationStatus !== null && windowsActivationStatus !== undefined ? (
<Badge className={windowsActivationStatus ? BADGE_POSITIVE : BADGE_WARNING}>
{windowsActivationStatus ? <ShieldCheck className="size-3" /> : <ShieldAlert className="size-3" />}
{windowsActivationStatus ? "Licença ativada" : "Licença pendente"}
</Badge>
) : null}
{defenderAntivirus !== undefined ? (
<Badge className={defenderAntivirus ? BADGE_POSITIVE : BADGE_WARNING}>
{defenderAntivirus ? <ShieldCheck className="size-3" /> : <ShieldAlert className="size-3" />}
Antivírus {defenderAntivirus ? "ativo" : "inativo"}
</Badge>
) : null}
{defenderRealtime !== undefined ? (
<Badge className={defenderRealtime ? BADGE_POSITIVE : BADGE_WARNING}>
{defenderRealtime ? <ShieldCheck className="size-3" /> : <ShieldAlert className="size-3" />}
Tempo real {defenderRealtime ? "ativo" : "inativo"}
</Badge>
) : null}
{secureBootSupported !== undefined ? (
<Badge
className={
secureBootEnabled === true
? BADGE_POSITIVE
: secureBootSupported
? BADGE_WARNING
: BADGE_NEUTRAL
}
>
{secureBootEnabled
? <Shield className="size-3" />
: secureBootSupported
? <ShieldQuestion className="size-3" />
: <ShieldOff className="size-3" />}
{secureBootSupported
? secureBootEnabled === true
? "Secure Boot ativo"
: "Secure Boot desabilitado"
: "Secure Boot não suportado"}
</Badge>
) : null}
{windowsBitLockerSummary ? (
<Badge
className={
windowsBitLockerSummary.protectedCount === windowsBitLockerSummary.total
? BADGE_POSITIVE
: BADGE_WARNING
}
>
<Lock className="size-3" />
{windowsBitLockerSummary.protectedCount}/{windowsBitLockerSummary.total} BitLocker
</Badge>
) : null}
{tpmPresent !== undefined ? (
<Badge className={tpmPresent ? BADGE_POSITIVE : BADGE_WARNING}>
<Key className="size-3" />
{tpmPresent ? (tpmReady ? "TPM pronto" : "TPM detectado") : "TPM ausente"}
</Badge>
) : null}
{windowsDeviceGuardDetails ? (
<Badge className={windowsDeviceGuardDetails.vbs === 3 ? BADGE_POSITIVE : BADGE_NEUTRAL}>
<ShieldQuestion className="size-3" />
{describeVbsStatus(windowsDeviceGuardDetails.vbs) ?? "VBS desconhecido"}
</Badge>
) : null}
{windowsFirewallNormalized.length > 0 ? (
<Badge
className={
firewallEnabledCount === windowsFirewallNormalized.length ? BADGE_POSITIVE : BADGE_WARNING
}
>
<Shield className="size-3" />
Firewall {firewallEnabledCount}/{windowsFirewallNormalized.length}
</Badge>
) : null}
</div>
{windowsDefender ? (
<div>
<p className="text-xs font-semibold uppercase text-slate-500">Defender</p>
<div className="mt-1 grid gap-1 text-xs text-muted-foreground">
<span>Modo: {defenderMode ?? "—"}</span>
<span>
Antivírus: {defenderAntivirus === undefined ? "—" : defenderAntivirus ? "Ativo" : "Inativo"}
</span>
<span>
Tempo real: {defenderRealtime === undefined ? "—" : defenderRealtime ? "Ativo" : "Inativo"}
</span>
</div>
</div>
) : null}
{secureBootError ? (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span>{secureBootError}</span>
</div>
) : null}
{windowsBitLockerVolumes.length > 0 ? (
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-slate-500">BitLocker</p>
<Badge variant="outline" className="text-[11px] text-slate-600">
{windowsBitLockerSummary
? `${windowsBitLockerSummary.protectedCount}/${windowsBitLockerSummary.total} protegidos`
: `${windowsBitLockerVolumes.length} volumes`}
</Badge>
</div>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Volume</TableHead>
<TableHead className="text-xs text-slate-500">Proteção</TableHead>
<TableHead className="text-xs text-slate-500">Bloqueio</TableHead>
<TableHead className="text-xs text-slate-500">Método</TableHead>
<TableHead className="text-xs text-slate-500">% Cript.</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsBitLockerVolumes.map((volume, idx) => {
const record = toRecord(volume) ?? {}
const mount = readString(record, "MountPoint", "mountPoint") ?? "—"
const protection = readString(record, "ProtectionStatus", "protectionStatus") ?? "—"
const lock = readString(record, "LockStatus", "lockStatus") ?? "—"
const method = readString(record, "EncryptionMethod", "encryptionMethod") ?? "—"
const percent = readNumber(record, "EncryptionPercentage", "encryptionPercentage")
return (
<TableRow key={`bit-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{mount}</TableCell>
<TableCell className="text-sm text-muted-foreground">{protection}</TableCell>
<TableCell className="text-sm text-muted-foreground">{lock}</TableCell>
<TableCell className="text-sm text-muted-foreground">{method}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{percent != null ? formatPercent(percent) : "—"}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{windowsTpm ? (
<div className="grid gap-1 text-xs text-muted-foreground">
<p className="text-xs font-semibold uppercase text-slate-500">TPM</p>
<span>Fabricante: {tpmManufacturer ?? "—"}</span>
<span>Versão: {tpmVersion ?? "—"}</span>
<span>
Status:{" "}
{tpmPresent === undefined
? "—"
: [
tpmPresent ? "Presente" : "Ausente",
tpmEnabled ? "Habilitado" : "Desabilitado",
tpmReady ? "Pronto" : "Não pronto",
tpmActivated ? "Ativado" : "Inativo",
].join(" · ")}
</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
TPM: não foi possível coletar informações (requer privilégios administrativos na máquina).
</p>
)}
{windowsDeviceGuardDetails ? (
<div className="grid gap-1 text-xs text-muted-foreground">
<p className="text-xs font-semibold uppercase text-slate-500">Device Guard</p>
<span>{describeVbsStatus(windowsDeviceGuardDetails.vbs) ?? "—"}</span>
{deviceGuardConfiguredLabels.length > 0 ? (
<span>Configurado: {deviceGuardConfiguredLabels.join(", ")}</span>
) : null}
{deviceGuardRunningLabels.length > 0 ? (
<span>Em execução: {deviceGuardRunningLabels.join(", ")}</span>
) : null}
</div>
) : null}
{windowsFirewallNormalized.length > 0 ? (
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-slate-500">Perfis de firewall</p>
<Badge variant="outline" className="text-[11px] text-slate-600">
{firewallEnabledCount}/{windowsFirewallNormalized.length} ativos
</Badge>
</div>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Perfil</TableHead>
<TableHead className="text-xs text-slate-500">Entrada</TableHead>
<TableHead className="text-xs text-slate-500">Saída</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsFirewallNormalized.map((profile, idx) => (
<TableRow key={`fw-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{profile.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{profile.inboundAction ?? "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{profile.outboundAction ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : null}
</CardContent>
</Card>
</div>
{windowsCpuDetails.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">CPU</p>
{windowsCpuDetails.slice(0, 1).map((cpuRecord, idx) => (
<div key={`cpu-${idx}`} className="mt-2 grid gap-1 text-sm text-muted-foreground">
<DetailLine label="Modelo" value={cpuRecord?.Name ?? "—"} classNameValue="break-words" />
<DetailLine label="Fabricante" value={cpuRecord?.Manufacturer ?? "—"} />
<DetailLine label="Socket" value={cpuRecord?.SocketDesignation ?? "—"} />
<DetailLine label="Núcleos" value={cpuRecord?.NumberOfCores != null ? `${cpuRecord.NumberOfCores}` : "—"} />
<DetailLine label="Threads" value={cpuRecord?.NumberOfLogicalProcessors != null ? `${cpuRecord.NumberOfLogicalProcessors}` : "—"} />
<DetailLine label="L2" value={cpuRecord?.L2CacheSize != null ? `${cpuRecord.L2CacheSize} KB` : "—"} />
<DetailLine label="L3" value={cpuRecord?.L3CacheSize != null ? `${cpuRecord.L3CacheSize} KB` : "—"} />
<DetailLine label="Clock máx" value={cpuRecord?.MaxClockSpeed != null ? `${cpuRecord.MaxClockSpeed} MHz` : "—"} />
</div>
))}
</div>
) : null}
{windowsExt.baseboard || windowsExt.bios ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Placa-mãe / BIOS</p>
<div className="mt-2 grid gap-1 text-sm text-muted-foreground">
{(() => {
const b = Array.isArray(windowsExt.baseboard) ? windowsExt.baseboard[0] : windowsExt.baseboard
const bios = Array.isArray(windowsExt.bios) ? windowsExt.bios[0] : windowsExt.bios
return (
<>
<DetailLine label="Board" value={b ? `${String(b?.["Manufacturer"] ?? "")} ${String(b?.["Product"] ?? "")}`.trim() : "—"} />
<DetailLine label="Board SN" value={b ? String(b?.["SerialNumber"] ?? "—") : "—"} />
<DetailLine label="BIOS" value={bios ? `${String(bios?.["Manufacturer"] ?? "")} ${String(bios?.["SMBIOSBIOSVersion"] ?? "")}`.trim() : "—"} />
</>
)
})()}
</div>
</div>
) : null}
{windowsServices.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">Serviços</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<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">Exibição</TableHead>
<TableHead className="text-xs text-slate-500">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsServices.slice(0, 10).map((service, index) => {
const record = toRecord(service) ?? {}
const name = readString(record, "Name", "name") ?? "—"
const displayName = readString(record, "DisplayName", "displayName") ?? "—"
const status = readString(record, "Status", "status") ?? "—"
return (
<TableRow key={`svc-${index}`} className="border-slate-100">
<TableCell className="text-sm">{name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{displayName}</TableCell>
<TableCell className="text-sm text-muted-foreground">{status}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{normalizedWindowsSoftware.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold uppercase text-slate-500">Aplicativos instalados</p>
{normalizedWindowsSoftware.length > 12 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-600 hover:bg-slate-200/60"
onClick={() => setShowAllWindowsSoftware((prev) => !prev)}
>
{showAllWindowsSoftware ? "Mostrar menos" : `Ver todos (${normalizedWindowsSoftware.length})`}
</Button>
) : null}
</div>
<ul className="mt-2 space-y-2">
{displayedWindowsSoftware.map((softwareItem, index) => {
const initials = collectInitials(softwareItem.name)
const installedAt = formatInstallDate(softwareItem.installDate)
return (
<li key={`sw-${index}`} className="flex items-start gap-3 rounded-md border border-slate-200 bg-white px-3 py-2">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold uppercase text-slate-700">
{initials}
</span>
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">{softwareItem.name}</span>
{softwareItem.version ? (
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-[11px] text-slate-700">
{softwareItem.version}
</Badge>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
{softwareItem.publisher ? <span>{softwareItem.publisher}</span> : null}
{installedAt ? <span>Instalado em {installedAt}</span> : null}
</div>
</div>
</li>
)
})}
</ul>
</div>
) : null}
{windowsMemoryModules.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">Módulos de memória</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Banco</TableHead>
<TableHead className="text-xs text-slate-500">Capacidade</TableHead>
<TableHead className="text-xs text-slate-500">Fabricante</TableHead>
<TableHead className="text-xs text-slate-500">PartNumber</TableHead>
<TableHead className="text-xs text-slate-500">Clock</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsMemoryModules.map((module, idx) => {
const record = toRecord(module) ?? {}
const bank = readString(record, "BankLabel", "bankLabel") ?? "—"
const capacityBytes =
parseBytesLike(record["Capacity"]) ?? parseBytesLike(record["capacity"]) ?? 0
const manufacturer = readString(record, "Manufacturer", "manufacturer") ?? "—"
const partNumber = readString(record, "PartNumber", "partNumber") ?? "—"
const clockValue = readNumber(
record,
"ConfiguredClockSpeed",
"configuredClockSpeed",
"Speed",
"speed"
)
const clockLabel =
typeof clockValue === "number" && Number.isFinite(clockValue)
? `${clockValue} MHz`
: "—"
return (
<TableRow key={`mem-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{bank}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{capacityBytes > 0 ? formatBytes(capacityBytes) : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{manufacturer}</TableCell>
<TableCell className="text-sm text-muted-foreground">{partNumber}</TableCell>
<TableCell className="text-sm text-muted-foreground">{clockLabel}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{windowsVideoControllers.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">Adaptadores de vídeo</p>
<ul className="mt-2 grid gap-1 text-xs text-muted-foreground">
{windowsVideoControllers.map((controller, idx) => {
const record = toRecord(controller) ?? {}
const normalized = normalizeGpuSource(record)
const name =
normalized?.name ??
readString(record, "Name", "name") ??
"—"
const ram =
normalized?.memoryBytes ??
parseBytesLike(record["AdapterRAM"]) ??
undefined
const driver = normalized?.driver ?? readString(record, "DriverVersion", "driverVersion")
const vendor = normalized?.vendor ?? readString(record, "AdapterCompatibility")
return (
<li key={`vid-${idx}`}>
<span className="font-medium text-foreground">{name}</span>
{typeof ram === "number" && ram > 0 ? <span className="ml-1">{formatBytes(ram)}</span> : null}
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
{driver ? <span className="ml-1">· Driver {driver}</span> : null}
</li>
)
})}
</ul>
</div>
) : null}
{windowsDiskEntries.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">Discos físicos</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Modelo</TableHead>
<TableHead className="text-xs text-slate-500">Tamanho</TableHead>
<TableHead className="text-xs text-slate-500">Interface</TableHead>
<TableHead className="text-xs text-slate-500">Tipo</TableHead>
<TableHead className="text-xs text-slate-500">Serial</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsDiskEntries.map((disk, idx) => {
const record = toRecord(disk) ?? {}
const model = readString(record, "Model", "model")
const serial = readString(record, "SerialNumber", "serialNumber")
const size = parseBytesLike(record["Size"])
const iface = readString(record, "InterfaceType", "interfaceType")
const media = readString(record, "MediaType", "mediaType")
return (
<TableRow key={`diskp-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{model ?? serial ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{typeof size === "number" && size > 0 ? formatBytes(size) : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{media ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{serial ?? "—"}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
</div>
) : null}
{/* macOS */}
{macosExt ? (
<div className="space-y-3">
{Array.isArray(macosExt.packages) && macosExt.packages.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">Pacotes</p>
<p className="mt-1 text-xs text-muted-foreground">{macosExt.packages.slice(0, 8).join(", ")}</p>
</div>
) : null}
{macosExt.launchctl ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Launchctl</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{macosExt.launchctl}</pre>
</div>
) : null}
</div>
) : null}
</section>
) : null}
{/* Postura/Alertas */}
{Array.isArray(machine?.postureAlerts) && machine?.postureAlerts?.length ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Alertas de postura</h4>
<div className="space-y-2">
{machine?.postureAlerts?.map((a: { kind?: string; message?: string; severity?: string }, i: number) => (
<div
key={`alert-${i}`}
className={cn(
"flex items-center justify-between rounded-md border px-3 py-2 text-sm",
postureSeverityClass(a?.severity)
)}
>
<span className="font-medium text-foreground">{a?.message ?? formatPostureAlertKind(a?.kind)}</span>
<Badge variant="outline">{formatPostureAlertKind(a?.kind)}</Badge>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Última avaliação: {machine?.lastPostureAt ? formatRelativeTime(new Date(machine.lastPostureAt)) : "—"}
</p>
</section>
) : null}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
{machineAlertsHistory.length > 0 ? (
<span className="text-xs text-muted-foreground">
Últimos {machineAlertsHistory.length} {machineAlertsHistory.length === 1 ? "evento" : "eventos"}
</span>
) : null}
</div>
{machineAlertsHistory.length > 0 ? (
<div className="relative max-h-64 overflow-y-auto pr-2">
<div className="absolute left-3 top-3 bottom-3 w-px bg-slate-200" />
<ol className="space-y-3 pl-6">
{machineAlertsHistory.map((alert) => {
const date = new Date(alert.createdAt)
return (
<li key={alert.id} className="relative rounded-md border border-slate-200/80 bg-white px-3 py-2 text-xs shadow-sm">
<span className="absolute -left-5 top-3 inline-flex size-3 items-center justify-center rounded-full border border-white bg-slate-200 ring-2 ring-white" />
<div className={cn("flex items-center justify-between", postureSeverityClass(alert.severity))}>
<span className="text-xs font-medium uppercase tracking-wide text-slate-600">{formatPostureAlertKind(alert.kind)}</span>
<span className="text-xs text-slate-500">{formatRelativeTime(date)}</span>
</div>
<p className="mt-1 text-sm text-foreground">{alert.message ?? formatPostureAlertKind(alert.kind)}</p>
<p className="mt-1 text-[11px] text-muted-foreground">{format(date, "dd/MM/yyyy HH:mm:ss")}</p>
</li>
)
})}
</ol>
</div>
) : (
<p className="text-xs text-muted-foreground">Nenhum alerta registrado para esta máquina.</p>
)}
</section>
<div className="flex flex-wrap gap-2 pt-2">
{Array.isArray(software) && software.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(software, "softwares.csv")}>Softwares CSV</Button>
) : null}
{Array.isArray(metadata?.services) && metadata.services.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(metadata.services as Array<Record<string, unknown>>, "servicos.csv")}>Serviços CSV</Button>
) : null}
</div>
{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}
{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 flex-wrap items-center justify-end gap-2">
{machine ? (
<Button size="sm" variant="outline" asChild className="inline-flex items-center gap-2">
<a href={`/api/admin/machines/${machine.id}/inventory.xlsx`} download>
<Download className="size-4" /> Exportar planilha
</a>
</Button>
) : null}
<DialogTrigger asChild>
<Button size="sm" variant="outline" onClick={() => setOpenDialog(true)}>Inventário completo</Button>
</DialogTrigger>
</div>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Inventário completo {machine.hostname}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder="Buscar no JSON"
value={dialogQuery}
onChange={(e) => setDialogQuery(e.target.value)}
className="sm:flex-1"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownloadInventoryJson}
className="inline-flex items-center gap-2"
>
<Download className="size-4" /> Baixar JSON
</Button>
{machine ? (
<Button type="button" variant="outline" size="sm" asChild className="inline-flex items-center gap-2">
<a href={`/api/admin/machines/${machine.id}/inventory.xlsx`} download>
<Download className="size-4" /> Baixar planilha
</a>
</Button>
) : null}
</div>
</div>
<div className="max-h-[60vh] overflow-auto rounded-md border border-slate-200 bg-slate-50/60 p-3 text-xs">
<pre className="whitespace-pre-wrap break-words text-muted-foreground" dangerouslySetInnerHTML={{ __html: filteredJsonHtml
.replaceAll("__HIGHLIGHT__", '<mark class="bg-yellow-200 text-foreground">')
.replaceAll("__END__", '</mark>')
}} />
</div>
</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 }),
credentials: "include",
})
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>
</Card>
);
}
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}
companyName={m.companyName ?? (m.companySlug ? companyNameBySlug.get(m.companySlug) ?? m.companySlug : null)}
/>
))}
</div>
)
}
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
const effectiveStatus = resolveMachineStatus(machine)
const isActive = machine.isActive
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
type AgentMetrics = {
memoryUsedBytes?: number
memoryTotalBytes?: number
memoryUsedPercent?: number
cpuUsagePercent?: number
}
const mm = (machine.metrics ?? null) as unknown as AgentMetrics | null
const memUsed = mm?.memoryUsedBytes ?? NaN
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 = (() => {
if (machine.assignedUserEmail) {
return {
email: machine.assignedUserEmail ?? undefined,
name: machine.assignedUserName ?? undefined,
role: machine.persona ?? machine.assignedUserRole ?? undefined,
}
}
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
if (!email) return null
return {
email,
name: typeof obj.name === "string" ? obj.name : undefined,
role: typeof obj.role === "string" ? (obj.role as string) : undefined,
}
})()
const persona = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
const companyLabel = companyName ?? machine.companySlug ?? null
return (
<Link href={`/admin/machines/${machine.id}`} className="group">
<Card className={cn("relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300", !isActive && "border-slate-300 bg-slate-50") }>
<div className="absolute right-2 top-2">
<span
aria-hidden
className={cn(
"relative block size-2 rounded-full",
effectiveStatus === "online"
? "bg-emerald-500"
: effectiveStatus === "offline"
? "bg-rose-500"
: effectiveStatus === "maintenance"
? "bg-amber-500"
: effectiveStatus === "blocked"
? "bg-orange-500"
: effectiveStatus === "deactivated"
? "bg-slate-500"
: "bg-slate-400"
)}
/>
{effectiveStatus === "online" ? (
<span className="absolute left-1/2 top-1/2 -z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400/30 animate-ping" />
) : null}
</div>
<CardHeader className="pb-2">
<CardTitle className="line-clamp-1 flex items-center gap-2 text-base font-semibold">
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
</CardTitle>
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
{collaborator?.email ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-600">
<span className="rounded-full border border-slate-300 bg-slate-100 px-2 py-0.5">
Usuário vinculado: {collaborator.name ? `${collaborator.name} · ` : ""}{collaborator.email}
</span>
<Link href="/admin/users" className="underline underline-offset-4">Gerenciar usuários</Link>
</div>
) : null}
{!isActive ? (
<Badge variant="outline" className="mt-2 w-fit border-rose-200 bg-rose-50 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
Desativada
</Badge>
) : null}
</CardHeader>
<CardContent className="flex grow flex-col gap-3 text-sm">
<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">
{(() => {
const name = machine.osName ?? "SO"
const ver = formatOsVersionDisplay(machine.osName, machine.osVersion)
return [name, ver].filter(Boolean).join(" ").trim()
})()}
</Badge>
{machine.architecture ? (
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
{machine.architecture.toUpperCase()}
</Badge>
) : null}
{companyLabel ? (
<Badge variant="outline" className="text-xs">{companyLabel}</Badge>
) : null}
</div>
{collaborator?.email ? (
<p className="text-[11px] text-muted-foreground">
{persona}: {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" />
<span className="text-xs font-medium text-slate-800">{formatPercent(cpuPct)}</span>
</div>
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5">
<MemoryStick className="size-4 text-slate-500" />
<span className="text-xs font-medium text-slate-800">
{Number.isFinite(memUsed) && Number.isFinite(memTotal)
? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}`
: formatPercent(memPct)}
</span>
</div>
</div>
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<HardDrive className="size-3.5 text-slate-500" />
{Array.isArray(machine.inventory?.disks) ? `${machine.inventory?.disks?.length ?? 0} discos` : "—"}
</span>
<span>
{lastHeartbeat ? formatRelativeTime(lastHeartbeat) : "sem heartbeat"}
</span>
</div>
</CardContent>
</Card>
</Link>
)
}
function DetailLine({ label, value, classNameValue, layout = "spread" }: DetailLineProps) {
if (value === null || value === undefined) return null
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {
return null
}
return (
<div
className={cn(
"items-center",
layout === "compact"
? "grid grid-cols-[auto_minmax(0,1fr)] gap-3"
: "flex justify-between gap-4"
)}
>
<span>{label}</span>
<span
className={cn(
"font-medium text-foreground",
layout === "compact" ? "text-left" : "text-right",
classNameValue
)}
>
{value}
</span>
</div>
)
}
function InfoChip({ label, value, icon, tone = "default" }: { label: string; value: string; icon?: ReactNode; tone?: "default" | "warning" | "muted" }) {
const toneClasses =
tone === "warning"
? "border-amber-200 bg-amber-50 text-amber-700"
: tone === "muted"
? "border-slate-200 bg-slate-50 text-neutral-600"
: "border-slate-200 bg-white text-neutral-800"
return (
<div className={cn("flex items-center gap-3 rounded-xl border px-3 py-2 shadow-sm", toneClasses)}>
{icon ? <span className="text-neutral-500">{icon}</span> : null}
<div className="min-w-0 leading-tight">
<p className="text-xs uppercase text-neutral-500">{label}</p>
<p className="truncate text-sm font-semibold">{value}</p>
</div>
</div>
)
}
function clampPercent(raw: number): number {
if (!Number.isFinite(raw)) return 0
const normalized = raw > 1 && raw <= 100 ? raw : raw <= 1 ? raw * 100 : raw
return Math.max(0, Math.min(100, normalized))
}
function deriveUsageMetrics({
metrics,
hardware,
disks,
}: {
metrics: MachineMetrics
hardware?: MachineInventory["hardware"]
disks?: MachineInventory["disks"]
}) {
const data = (metrics ?? {}) as Record<string, unknown>
const cpuRaw = Number(
data.cpuUsagePercent ?? data.cpuUsage ?? data.cpu_percent ?? data.cpu ?? NaN
)
const cpuPercent = Number.isFinite(cpuRaw) ? clampPercent(cpuRaw) : null
const totalCandidates = [
data.memoryTotalBytes,
data.memory_total,
data.memoryTotal,
hardware?.memoryBytes,
hardware?.memory,
]
let memoryTotalBytes: number | null = null
for (const candidate of totalCandidates) {
const parsed = parseBytesLike(candidate)
if (parsed && Number.isFinite(parsed) && parsed > 0) {
memoryTotalBytes = parsed
break
}
const numeric = Number(candidate)
if (Number.isFinite(numeric) && numeric > 0) {
memoryTotalBytes = numeric
break
}
}
const usedCandidates = [
data.memoryUsedBytes,
data.memoryBytes,
data.memory_used,
data.memory,
]
let memoryUsedBytes: number | null = null
for (const candidate of usedCandidates) {
const parsed = parseBytesLike(candidate)
if (parsed !== undefined && Number.isFinite(parsed)) {
memoryUsedBytes = parsed
break
}
const numeric = Number(candidate)
if (Number.isFinite(numeric)) {
memoryUsedBytes = numeric
break
}
}
const memoryPercentRaw = Number(data.memoryUsedPercent ?? data.memory_percent ?? NaN)
let memoryPercent = Number.isFinite(memoryPercentRaw) ? clampPercent(memoryPercentRaw) : null
if (memoryTotalBytes && memoryUsedBytes === null && memoryPercent !== null) {
memoryUsedBytes = (memoryPercent / 100) * memoryTotalBytes
} else if (memoryTotalBytes && memoryUsedBytes !== null) {
memoryPercent = clampPercent((memoryUsedBytes / memoryTotalBytes) * 100)
}
let diskTotalBytes: number | null = null
let diskUsedBytes: number | null = null
let diskPercent: number | null = null
if (Array.isArray(disks) && disks.length > 0) {
let total = 0
let available = 0
disks.forEach((disk) => {
const totalParsed = parseBytesLike(disk?.totalBytes)
if (typeof totalParsed === "number" && Number.isFinite(totalParsed) && totalParsed > 0) {
total += totalParsed
}
const availableParsed = parseBytesLike(disk?.availableBytes)
if (typeof availableParsed === "number" && Number.isFinite(availableParsed) && availableParsed >= 0) {
available += availableParsed
}
})
if (total > 0) {
diskTotalBytes = total
const used = Math.max(0, total - available)
diskUsedBytes = used
diskPercent = clampPercent((used / total) * 100)
}
}
if (diskPercent === null) {
const diskMetric = Number(
data.diskUsage ?? data.disk ?? data.diskUsedPercent ?? data.storageUsedPercent ?? NaN
)
if (Number.isFinite(diskMetric)) {
diskPercent = clampPercent(diskMetric)
}
}
const gpuMetric = Number(
data.gpuUsagePercent ?? data.gpuUsage ?? data.gpu_percent ?? data.gpu ?? NaN
)
const gpuPercent = Number.isFinite(gpuMetric) ? clampPercent(gpuMetric) : null
return {
cpuPercent,
memoryUsedBytes,
memoryTotalBytes,
memoryPercent,
diskPercent,
diskUsedBytes,
diskTotalBytes,
gpuPercent,
}
}
function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; hardware?: MachineInventory["hardware"]; disks?: MachineInventory["disks"] }) {
const derived = useMemo(
() => deriveUsageMetrics({ metrics, hardware, disks }),
[metrics, hardware, disks]
)
const cards = [
{
key: "cpu",
label: "CPU",
percent: derived.cpuPercent,
primaryText: derived.cpuPercent !== null ? formatPercent(derived.cpuPercent) : "Sem dados",
secondaryText: derived.cpuPercent !== null ? "Uso instantâneo" : "Sem leituras recentes",
icon: <Cpu className="size-4 text-neutral-500" />,
color: "var(--chart-1)",
},
{
key: "memory",
label: "Memória",
percent: derived.memoryPercent,
primaryText:
derived.memoryUsedBytes !== null && derived.memoryTotalBytes !== null
? `${formatBytes(derived.memoryUsedBytes)} / ${formatBytes(derived.memoryTotalBytes)}`
: derived.memoryPercent !== null
? formatPercent(derived.memoryPercent)
: "Sem dados",
secondaryText: derived.memoryPercent !== null ? `${Math.round(derived.memoryPercent)}% em uso` : null,
icon: <MemoryStick className="size-4 text-neutral-500" />,
color: "var(--chart-2)",
},
{
key: "disk",
label: "Disco",
percent: derived.diskPercent,
primaryText:
derived.diskUsedBytes !== null && derived.diskTotalBytes !== null
? `${formatBytes(derived.diskUsedBytes)} / ${formatBytes(derived.diskTotalBytes)}`
: derived.diskPercent !== null
? formatPercent(derived.diskPercent)
: "Sem dados",
secondaryText: derived.diskPercent !== null ? `${Math.round(derived.diskPercent)}% utilizado` : null,
icon: <HardDrive className="size-4 text-neutral-500" />,
color: "var(--chart-3)",
},
] as Array<{ key: string; label: string; percent: number | null; primaryText: string; secondaryText?: string | null; icon: ReactNode; color: string }>
if (derived.gpuPercent !== null) {
cards.push({
key: "gpu",
label: "GPU",
percent: derived.gpuPercent,
primaryText: formatPercent(derived.gpuPercent),
secondaryText: null,
icon: <Monitor className="size-4 text-neutral-500" />,
color: "var(--chart-4)",
})
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{cards.map((card) => {
const percentValue = Number.isFinite(card.percent ?? NaN) ? Math.max(0, Math.min(100, card.percent ?? 0)) : 0
const percentLabel = card.percent !== null ? `${Math.round(card.percent)}%` : "—"
return (
<div key={card.key} className="flex items-center gap-4 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="relative h-24 w-24">
<ChartContainer
config={{ usage: { label: card.label, color: card.color } }}
className="h-24 w-24 aspect-square"
>
<RadialBarChart
data={[{ name: card.label, value: percentValue }]}
innerRadius="68%"
outerRadius="100%"
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" cornerRadius={10} fill="var(--color-usage)" background />
</RadialBarChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-[18%] flex items-center justify-center rounded-full bg-white/70 text-sm font-semibold text-neutral-900">
{percentLabel}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
{card.icon}
{card.label}
</div>
<div className="text-sm text-neutral-700">{card.primaryText}</div>
{card.secondaryText ? (
<div className="text-xs text-neutral-500">{card.secondaryText}</div>
) : null}
</div>
</div>
)
})}
</div>
)
}
function exportCsv(items: Array<Record<string, unknown>>, filename: string) {
if (!Array.isArray(items) || items.length === 0) return
const headersSet = new Set<string>()
items.forEach((it) => Object.keys(it ?? {}).forEach((k) => headersSet.add(k)))
const headers = Array.from(headersSet)
const csv = [headers.join(",")]
for (const it of items) {
const row = headers
.map((h) => {
const v = (it as Record<string, unknown>)[h]
if (v === undefined || v === null) return ""
const s = typeof v === "string" ? v : JSON.stringify(v)
return `"${s.replace(/"/g, '""')}"`
})
.join(",")
csv.push(row)
}
const blob = new Blob([csv.join("\n")], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}