6150 lines
281 KiB
TypeScript
6150 lines
281 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import type { ReactNode } from "react"
|
|
import { useMutation, 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,
|
|
CheckSquare,
|
|
RotateCcw,
|
|
AlertTriangle,
|
|
Key,
|
|
Eye,
|
|
EyeOff,
|
|
MonitorSmartphone,
|
|
Globe,
|
|
Apple,
|
|
Terminal,
|
|
Power,
|
|
PlayCircle,
|
|
Download,
|
|
Plus,
|
|
Smartphone,
|
|
Tablet,
|
|
} from "lucide-react"
|
|
|
|
import { api } from "@/convex/_generated/api"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } 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 { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
|
import { DEVICE_STATUS_LABELS, getDeviceStatusIndicator, resolveDeviceStatus } from "@/lib/device-status"
|
|
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"
|
|
import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager"
|
|
import { DatePicker } from "@/components/ui/date-picker"
|
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
|
|
|
type DeviceMetrics = Record<string, unknown> | null
|
|
|
|
type DeviceLabel = {
|
|
id?: number | string
|
|
name?: string
|
|
}
|
|
|
|
type DeviceSoftware = {
|
|
name?: string
|
|
version?: string
|
|
source?: string
|
|
}
|
|
|
|
type NormalizedSoftwareEntry = {
|
|
name: string
|
|
version?: string
|
|
publisher?: string
|
|
installDate?: Date | null
|
|
source?: string
|
|
}
|
|
|
|
type DeviceExportTemplate = {
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
columns: DeviceInventoryColumnConfig[]
|
|
companyId: string | null
|
|
isDefault: boolean
|
|
isActive?: boolean
|
|
}
|
|
|
|
const BASE_DEVICE_COLUMN_KEYS = DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => meta.key)
|
|
const DEFAULT_DEVICE_COLUMN_CONFIG: DeviceInventoryColumnConfig[] = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map(
|
|
(meta) => ({ key: meta.key })
|
|
)
|
|
|
|
function orderColumnConfig(
|
|
config: DeviceInventoryColumnConfig[],
|
|
customOrder: string[]
|
|
): DeviceInventoryColumnConfig[] {
|
|
const order = new Map<string, number>()
|
|
BASE_DEVICE_COLUMN_KEYS.forEach((key, index) => order.set(key, index))
|
|
customOrder.forEach((key, idx) => {
|
|
if (!order.has(key)) {
|
|
order.set(key, BASE_DEVICE_COLUMN_KEYS.length + idx)
|
|
}
|
|
})
|
|
const seen = new Set<string>()
|
|
return config
|
|
.filter(({ key }) => {
|
|
if (!key || seen.has(key)) return false
|
|
seen.add(key)
|
|
return order.has(key)
|
|
})
|
|
.sort((a, b) => (order.get(a.key)! - order.get(b.key)!))
|
|
}
|
|
|
|
function areColumnConfigsEqual(a: DeviceInventoryColumnConfig[], b: DeviceInventoryColumnConfig[]): boolean {
|
|
if (a.length !== b.length) return false
|
|
return a.every((col, idx) => col.key === b[idx]?.key && (col.label ?? "") === (b[idx]?.label ?? ""))
|
|
}
|
|
|
|
function formatDeviceCustomFieldDisplay(entry?: { value?: unknown; displayValue?: string }): string {
|
|
if (!entry) return "—"
|
|
if (typeof entry.displayValue === "string" && entry.displayValue.trim().length > 0) {
|
|
return entry.displayValue
|
|
}
|
|
const raw = entry.value
|
|
if (raw === null || raw === undefined) return "—"
|
|
if (Array.isArray(raw)) {
|
|
const values = raw
|
|
.map((item) => (item === null || item === undefined ? "" : String(item).trim()))
|
|
.filter((item) => item.length > 0)
|
|
return values.length > 0 ? values.join(", ") : "—"
|
|
}
|
|
if (typeof raw === "boolean") {
|
|
return raw ? "Sim" : "Não"
|
|
}
|
|
if (typeof raw === "number") {
|
|
return Number.isFinite(raw) ? String(raw) : "—"
|
|
}
|
|
const asString = String(raw).trim()
|
|
return asString.length > 0 ? asString : "—"
|
|
}
|
|
|
|
type DeviceAlertEntry = {
|
|
id: string
|
|
kind: string
|
|
message: string
|
|
severity: string
|
|
createdAt: number
|
|
}
|
|
type DeviceTicketSummary = {
|
|
id: string
|
|
reference: number
|
|
subject: string
|
|
status: TicketStatus
|
|
priority: TicketPriority
|
|
updatedAt: number
|
|
createdAt: number
|
|
device: { id: string | null; hostname: string | null } | null
|
|
assignee: { name: string | null; email: string | null } | null
|
|
}
|
|
|
|
type DeviceOpenTicketsSummary = {
|
|
totalOpen: number
|
|
hasMore: boolean
|
|
tickets: DeviceTicketSummary[]
|
|
}
|
|
|
|
|
|
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?: DeviceSoftware[]
|
|
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 DeviceInventory = {
|
|
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?: DeviceSoftware[]
|
|
labels?: DeviceLabel[]
|
|
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 }
|
|
}
|
|
|
|
export type DeviceRemoteAccessEntry = {
|
|
id: string | null
|
|
clientId: string
|
|
provider: string | null
|
|
identifier: string | null
|
|
username: string | null
|
|
password: string | null
|
|
url: string | null
|
|
notes: string | null
|
|
lastVerifiedAt: number | null
|
|
metadata: Record<string, unknown> | null
|
|
}
|
|
|
|
export type DeviceRemoteAccess = {
|
|
provider: string | null
|
|
identifier: string | null
|
|
username: string | null
|
|
password: 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 normalizeDeviceRemoteAccessEntry(raw: unknown): DeviceRemoteAccessEntry | 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,
|
|
username: null,
|
|
password: null,
|
|
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 username = readString(record, "username", "user", "login", "email", "account") ?? null
|
|
const password = readString(record, "password", "pass", "secret", "pin") ?? null
|
|
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,
|
|
username,
|
|
password,
|
|
url,
|
|
notes,
|
|
lastVerifiedAt,
|
|
metadata: record,
|
|
}
|
|
}
|
|
|
|
export function normalizeDeviceRemoteAccess(raw: unknown): DeviceRemoteAccess | null {
|
|
const entry = normalizeDeviceRemoteAccessEntry(raw)
|
|
if (!entry) return null
|
|
const { provider, identifier, username, password, url, notes, lastVerifiedAt, metadata } = entry
|
|
return { provider, identifier, username, password, url, notes, lastVerifiedAt, metadata }
|
|
}
|
|
|
|
export function normalizeDeviceRemoteAccessList(raw: unknown): DeviceRemoteAccessEntry[] {
|
|
if (!raw) return []
|
|
const source = Array.isArray(raw) ? raw : [raw]
|
|
const seen = new Set<string>()
|
|
const entries: DeviceRemoteAccessEntry[] = []
|
|
for (const item of source) {
|
|
const entry = normalizeDeviceRemoteAccessEntry(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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
export function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) {
|
|
if (!entry) return false
|
|
const provider = (entry.provider ?? entry.metadata?.provider ?? "").toString().toLowerCase()
|
|
if (provider.includes("rustdesk")) return true
|
|
const url = (entry.url ?? entry.metadata?.url ?? "").toString().toLowerCase()
|
|
return url.includes("rustdesk")
|
|
}
|
|
|
|
export function buildRustDeskUri(entry: DeviceRemoteAccessEntry) {
|
|
const identifier = (entry.identifier ?? "").replace(/\s+/g, "")
|
|
if (!identifier) return null
|
|
const params = new URLSearchParams()
|
|
if (entry.password) {
|
|
params.set("password", entry.password)
|
|
}
|
|
const query = params.toString()
|
|
return `rustdesk://${encodeURIComponent(identifier)}${query ? `?${query}` : ""}`
|
|
}
|
|
|
|
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 DevicesQueryItem = {
|
|
id: string
|
|
tenantId: string
|
|
hostname: string
|
|
displayName: string | null
|
|
deviceType: string | null
|
|
devicePlatform: string | null
|
|
managementMode: string | null
|
|
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: DeviceMetrics
|
|
inventory: DeviceInventory | null
|
|
postureAlerts?: Array<Record<string, unknown>> | null
|
|
lastPostureAt?: number | null
|
|
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
|
remoteAccessEntries: DeviceRemoteAccessEntry[]
|
|
customFields?: Array<{ fieldId?: string; fieldKey: string; label: string; type?: string; value: unknown; displayValue?: string }>
|
|
}
|
|
|
|
export function normalizeDeviceItem(raw: Record<string, unknown>): DevicesQueryItem {
|
|
const { remoteAccess, ...rest } = raw as Record<string, unknown> & { remoteAccess?: unknown }
|
|
return {
|
|
...(rest as DevicesQueryItem),
|
|
remoteAccessEntries: normalizeDeviceRemoteAccessList(remoteAccess),
|
|
}
|
|
}
|
|
|
|
function useDevicesQuery(tenantId: string): { devices: DevicesQueryItem[]; isLoading: boolean } {
|
|
const result = useQuery(api.devices.listByTenant, {
|
|
tenantId,
|
|
includeMetadata: true,
|
|
}) as Array<Record<string, unknown>> | undefined
|
|
const devices = useMemo(() => (result ?? []).map((item) => normalizeDeviceItem(item)), [result])
|
|
return {
|
|
devices,
|
|
isLoading: result === undefined,
|
|
}
|
|
}
|
|
|
|
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
|
desktop: "Desktop",
|
|
mobile: "Celular",
|
|
tablet: "Tablet",
|
|
}
|
|
|
|
const DEVICE_TYPE_FILTER_OPTIONS = [
|
|
{ value: "all", label: "Todos os tipos" },
|
|
{ value: "desktop", label: DEVICE_TYPE_LABELS.desktop },
|
|
{ value: "mobile", label: DEVICE_TYPE_LABELS.mobile },
|
|
{ value: "tablet", label: DEVICE_TYPE_LABELS.tablet },
|
|
]
|
|
|
|
function formatDeviceTypeLabel(value?: string | null): string {
|
|
if (!value) return "Desconhecido"
|
|
const normalized = value.toLowerCase()
|
|
return DEVICE_TYPE_LABELS[normalized] ?? value.charAt(0).toUpperCase() + value.slice(1)
|
|
}
|
|
|
|
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: DeviceMetrics): 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: DEVICE_STATUS_LABELS.unknown, className: statusClasses.unknown }
|
|
const normalized = status.toLowerCase()
|
|
return {
|
|
label: DEVICE_STATUS_LABELS[normalized] ?? status,
|
|
className: statusClasses[normalized] ?? statusClasses.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 AdminDevicesOverview({
|
|
tenantId,
|
|
initialCompanyFilterSlug = "all",
|
|
autoOpenCreateDevice = false,
|
|
}: {
|
|
tenantId: string
|
|
initialCompanyFilterSlug?: string
|
|
autoOpenCreateDevice?: boolean
|
|
}) {
|
|
const { devices, isLoading } = useDevicesQuery(tenantId)
|
|
const [q, setQ] = useState("")
|
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
|
const [deviceTypeFilter, setDeviceTypeFilter] = 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 [columnConfig, setColumnConfig] = useState<DeviceInventoryColumnConfig[]>(DEFAULT_DEVICE_COLUMN_CONFIG)
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
|
const [hasManualColumns, setHasManualColumns] = useState(false)
|
|
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
|
|
const [templateName, setTemplateName] = useState("")
|
|
const [templateForCompany, setTemplateForCompany] = useState(false)
|
|
const [templateAsDefault, setTemplateAsDefault] = useState(false)
|
|
const [isSavingTemplate, setIsSavingTemplate] = useState(false)
|
|
const [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false)
|
|
const [createDeviceLoading, setCreateDeviceLoading] = useState(false)
|
|
const [newDeviceName, setNewDeviceName] = useState("")
|
|
const [newDeviceIdentifier, setNewDeviceIdentifier] = useState("")
|
|
const [newDeviceType, setNewDeviceType] = useState("mobile")
|
|
const [newDevicePlatform, setNewDevicePlatform] = useState("")
|
|
const [newDeviceCompanySlug, setNewDeviceCompanySlug] = useState<string | null>(
|
|
initialCompanyFilterSlug !== "all" ? initialCompanyFilterSlug : null
|
|
)
|
|
const [newDeviceSerials, setNewDeviceSerials] = useState("")
|
|
const [newDeviceNotes, setNewDeviceNotes] = useState("")
|
|
const [newDeviceActive, setNewDeviceActive] = useState(true)
|
|
const { convexUserId } = useAuth()
|
|
const companies = useQuery(
|
|
api.companies.list,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as Array<{ id: string; name: string; slug?: string }> | undefined
|
|
const companyNameBySlug = useMemo(() => {
|
|
const map = new Map<string, string>()
|
|
devices.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
|
|
}, [devices, companies])
|
|
|
|
const selectedCompany = useMemo(() => {
|
|
if (companyFilterSlug === "all") return null
|
|
return (companies ?? []).find((company) => (company.slug ?? company.id) === companyFilterSlug) ?? null
|
|
}, [companies, companyFilterSlug])
|
|
|
|
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>()
|
|
devices.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, devices])
|
|
|
|
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
|
|
return companyOptions.map((company) => ({
|
|
value: company.slug,
|
|
label: company.name,
|
|
}))
|
|
}, [companyOptions])
|
|
|
|
const deviceFields = useQuery(
|
|
api.deviceFields.listForTenant,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as Array<{ id: string; key: string; label: string }> | undefined
|
|
|
|
const exportTemplates = useQuery(
|
|
api.deviceExportTemplates.listForTenant,
|
|
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
) as DeviceExportTemplate[] | undefined
|
|
|
|
const customFieldOptions = useMemo(() => {
|
|
const map = new Map<string, { key: string; label: string }>()
|
|
;(deviceFields ?? []).forEach((field) => {
|
|
map.set(field.key, { key: field.key, label: field.label })
|
|
})
|
|
devices.forEach((device) => {
|
|
;(device.customFields ?? []).forEach((field) => {
|
|
if (!map.has(field.fieldKey)) {
|
|
map.set(field.fieldKey, { key: field.fieldKey, label: field.label })
|
|
}
|
|
})
|
|
})
|
|
return Array.from(map.values()).sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
|
}, [deviceFields, devices])
|
|
|
|
const customColumnOrder = useMemo(() => customFieldOptions.map((field) => `custom:${field.key}`), [customFieldOptions])
|
|
|
|
useEffect(() => {
|
|
setColumnConfig((prev) => orderColumnConfig(prev, customColumnOrder))
|
|
}, [customColumnOrder])
|
|
|
|
const customColumnOptions = useMemo(
|
|
() => customFieldOptions.map((field) => ({ key: `custom:${field.key}`, label: field.label })),
|
|
[customFieldOptions]
|
|
)
|
|
|
|
const baseColumnOptions = useMemo(
|
|
() => DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ key: meta.key, label: meta.label })),
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!exportTemplates || exportTemplates.length === 0) return
|
|
if (hasManualColumns || selectedTemplateId) return
|
|
const companyTemplate = selectedCompany
|
|
? exportTemplates.find((tpl) => tpl.companyId === selectedCompany.id && tpl.isDefault)
|
|
: null
|
|
const fallbackTemplate = exportTemplates.find((tpl) => tpl.isDefault && !tpl.companyId)
|
|
const templateToApply = companyTemplate ?? fallbackTemplate
|
|
if (!templateToApply) return
|
|
const normalized = orderColumnConfig(
|
|
(templateToApply.columns ?? []).map((col) => ({ key: col.key, label: col.label ?? undefined })),
|
|
customColumnOrder
|
|
)
|
|
if (normalized.length === 0 || areColumnConfigsEqual(normalized, columnConfig)) return
|
|
setColumnConfig(normalized)
|
|
setSelectedTemplateId(templateToApply.id)
|
|
setHasManualColumns(false)
|
|
}, [exportTemplates, selectedCompany, customColumnOrder, hasManualColumns, selectedTemplateId, columnConfig])
|
|
|
|
const createTemplate = useMutation(api.deviceExportTemplates.create)
|
|
const saveDeviceProfileMutation = useMutation(api.devices.saveDeviceProfile)
|
|
|
|
useEffect(() => {
|
|
if (!selectedCompany && templateForCompany) {
|
|
setTemplateForCompany(false)
|
|
}
|
|
}, [selectedCompany, templateForCompany])
|
|
|
|
const templateOptions = useMemo(() => {
|
|
return (exportTemplates ?? []).slice().sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
|
}, [exportTemplates])
|
|
|
|
const selectedTemplate = useMemo(
|
|
() => templateOptions.find((tpl) => tpl.id === selectedTemplateId) ?? null,
|
|
[templateOptions, selectedTemplateId]
|
|
)
|
|
|
|
const handleTemplateSelection = useCallback(
|
|
(value: string) => {
|
|
if (value === "custom") {
|
|
setSelectedTemplateId(null)
|
|
setHasManualColumns(true)
|
|
return
|
|
}
|
|
const template = templateOptions.find((tpl) => tpl.id === value)
|
|
if (!template) {
|
|
toast.error("Template não encontrado.")
|
|
return
|
|
}
|
|
const normalized = orderColumnConfig(
|
|
(template.columns ?? []).map((col) => ({ key: col.key, label: col.label ?? undefined })),
|
|
customColumnOrder
|
|
)
|
|
if (normalized.length === 0) {
|
|
toast.error("Template sem colunas válidas.")
|
|
return
|
|
}
|
|
setColumnConfig(normalized)
|
|
setSelectedTemplateId(template.id)
|
|
setHasManualColumns(false)
|
|
},
|
|
[templateOptions, customColumnOrder]
|
|
)
|
|
|
|
const handleToggleColumn = useCallback(
|
|
(key: string, checked: boolean, label?: string) => {
|
|
setColumnConfig((prev) => {
|
|
const filtered = prev.filter((col) => col.key !== key)
|
|
if (checked) {
|
|
return orderColumnConfig([...filtered, { key, label }], customColumnOrder)
|
|
}
|
|
return filtered
|
|
})
|
|
setSelectedTemplateId(null)
|
|
setHasManualColumns(true)
|
|
},
|
|
[customColumnOrder]
|
|
)
|
|
|
|
const handleResetColumns = useCallback(() => {
|
|
setColumnConfig(orderColumnConfig([...DEFAULT_DEVICE_COLUMN_CONFIG], customColumnOrder))
|
|
setSelectedTemplateId(null)
|
|
setHasManualColumns(false)
|
|
}, [customColumnOrder])
|
|
|
|
const handleSelectAllColumns = useCallback(() => {
|
|
const allColumns: DeviceInventoryColumnConfig[] = [
|
|
...baseColumnOptions.map((col) => ({ key: col.key })),
|
|
...customColumnOptions.map((col) => ({ key: col.key, label: col.label })),
|
|
]
|
|
setColumnConfig(orderColumnConfig(allColumns, customColumnOrder))
|
|
setSelectedTemplateId(null)
|
|
setHasManualColumns(true)
|
|
}, [baseColumnOptions, customColumnOptions, customColumnOrder])
|
|
|
|
const handleOpenTemplateDialog = useCallback(() => {
|
|
setTemplateName("")
|
|
setTemplateForCompany(Boolean(selectedCompany))
|
|
setTemplateAsDefault(false)
|
|
setIsTemplateDialogOpen(true)
|
|
}, [selectedCompany])
|
|
|
|
const handleSaveTemplate = useCallback(async () => {
|
|
if (!convexUserId) {
|
|
toast.error("Sincronize a sessão para salvar templates.")
|
|
return
|
|
}
|
|
const normalized = orderColumnConfig(columnConfig, customColumnOrder)
|
|
if (normalized.length === 0) {
|
|
toast.error("Selecione ao menos uma coluna para salvar o template.")
|
|
return
|
|
}
|
|
const name = templateName.trim()
|
|
if (name.length < 3) {
|
|
toast.error("Informe um nome para o template (mínimo 3 caracteres).")
|
|
return
|
|
}
|
|
try {
|
|
setIsSavingTemplate(true)
|
|
const response = await createTemplate({
|
|
tenantId,
|
|
actorId: convexUserId as Id<"users">,
|
|
name,
|
|
columns: normalized,
|
|
companyId: templateForCompany && selectedCompany ? (selectedCompany.id as Id<"companies">) : undefined,
|
|
isDefault: templateAsDefault,
|
|
isActive: true,
|
|
})
|
|
setIsTemplateDialogOpen(false)
|
|
setTemplateName("")
|
|
setTemplateForCompany(false)
|
|
setTemplateAsDefault(false)
|
|
const newTemplateId = typeof response === "string" ? response : response ? String(response) : null
|
|
if (newTemplateId) {
|
|
setSelectedTemplateId(newTemplateId)
|
|
setHasManualColumns(false)
|
|
}
|
|
toast.success("Template salvo com sucesso.")
|
|
} catch (error) {
|
|
console.error("Failed to save template", error)
|
|
toast.error("Não foi possível salvar o template.")
|
|
} finally {
|
|
setIsSavingTemplate(false)
|
|
}
|
|
}, [columnConfig, createTemplate, customColumnOrder, convexUserId, selectedCompany, templateAsDefault, templateForCompany, templateName, tenantId])
|
|
|
|
const handleOpenCreateDevice = useCallback(() => {
|
|
const initialCompany = selectedCompany
|
|
? selectedCompany.slug ?? selectedCompany.id
|
|
: companyFilterSlug !== "all"
|
|
? companyFilterSlug
|
|
: null
|
|
setNewDeviceName("")
|
|
setNewDeviceIdentifier("")
|
|
setNewDeviceType("mobile")
|
|
setNewDevicePlatform("")
|
|
setNewDeviceSerials("")
|
|
setNewDeviceNotes("")
|
|
setNewDeviceActive(true)
|
|
setNewDeviceCompanySlug(initialCompany)
|
|
setIsCreateDeviceOpen(true)
|
|
}, [selectedCompany, companyFilterSlug])
|
|
|
|
useEffect(() => {
|
|
if (autoOpenCreateDevice) {
|
|
handleOpenCreateDevice()
|
|
}
|
|
}, [autoOpenCreateDevice, handleOpenCreateDevice])
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return
|
|
const handler = () => {
|
|
handleOpenCreateDevice()
|
|
}
|
|
window.addEventListener("quick-open-device", handler)
|
|
return () => window.removeEventListener("quick-open-device", handler)
|
|
}, [handleOpenCreateDevice])
|
|
|
|
const handleCreateDevice = useCallback(async () => {
|
|
if (!convexUserId) {
|
|
toast.error("Sincronize a sessão antes de criar dispositivos.")
|
|
return
|
|
}
|
|
const name = newDeviceName.trim()
|
|
if (name.length < 3) {
|
|
toast.error("Informe um nome com ao menos 3 caracteres.")
|
|
return
|
|
}
|
|
const identifier = (newDeviceIdentifier.trim() || name).trim()
|
|
const platform = newDevicePlatform.trim()
|
|
const serials = newDeviceSerials
|
|
.split(/\r?\n|,|;/)
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
const targetCompany = newDeviceCompanySlug
|
|
? (companies ?? []).find((company) => (company.slug ?? company.id) === newDeviceCompanySlug) ?? null
|
|
: null
|
|
try {
|
|
setCreateDeviceLoading(true)
|
|
await saveDeviceProfileMutation({
|
|
tenantId,
|
|
actorId: convexUserId as Id<"users">,
|
|
displayName: name,
|
|
hostname: identifier,
|
|
deviceType: newDeviceType,
|
|
devicePlatform: platform || undefined,
|
|
osName: platform || undefined,
|
|
serialNumbers: serials.length > 0 ? serials : undefined,
|
|
companyId: targetCompany ? (targetCompany.id as Id<"companies">) : undefined,
|
|
companySlug: targetCompany?.slug ?? undefined,
|
|
status: "unknown",
|
|
isActive: newDeviceActive,
|
|
profile: newDeviceNotes.trim() ? { notes: newDeviceNotes.trim() } : undefined,
|
|
})
|
|
toast.success("Dispositivo criado com sucesso.")
|
|
setIsCreateDeviceOpen(false)
|
|
setNewDeviceName("")
|
|
setNewDeviceIdentifier("")
|
|
setNewDevicePlatform("")
|
|
setNewDeviceSerials("")
|
|
setNewDeviceNotes("")
|
|
setNewDeviceActive(true)
|
|
if (targetCompany?.slug) {
|
|
setCompanyFilterSlug(targetCompany.slug)
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to create device", error)
|
|
toast.error("Não foi possível criar o dispositivo.")
|
|
} finally {
|
|
setCreateDeviceLoading(false)
|
|
}
|
|
}, [companies, convexUserId, newDeviceActive, newDeviceCompanySlug, newDeviceIdentifier, newDeviceName, newDeviceNotes, newDevicePlatform, newDeviceSerials, newDeviceType, saveDeviceProfileMutation, tenantId])
|
|
|
|
const filteredDevices = useMemo(() => {
|
|
const text = q.trim().toLowerCase()
|
|
return devices.filter((m) => {
|
|
if (onlyAlerts && !(Array.isArray(m.postureAlerts) && m.postureAlerts.length > 0)) return false
|
|
if (statusFilter !== "all") {
|
|
const s = resolveDeviceStatus(m).toLowerCase()
|
|
if (s !== statusFilter) return false
|
|
}
|
|
if (deviceTypeFilter !== "all") {
|
|
const type = (m.deviceType ?? "desktop").toLowerCase()
|
|
if (type !== deviceTypeFilter) 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)
|
|
})
|
|
}, [devices, q, statusFilter, companyFilterSlug, onlyAlerts, deviceTypeFilter])
|
|
const handleOpenExportDialog = useCallback(() => {
|
|
if (filteredDevices.length === 0) {
|
|
toast.info("Não há dispositivos para exportar com os filtros atuais.")
|
|
return
|
|
}
|
|
setExportSelection(filteredDevices.map((m) => m.id))
|
|
setExportProgress(0)
|
|
setExportError(null)
|
|
setIsExporting(false)
|
|
setIsExportDialogOpen(true)
|
|
}, [filteredDevices])
|
|
|
|
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(filteredDevices.map((m) => m.id))
|
|
setExportSelection((prev) => {
|
|
const next = prev.filter((id) => allowed.has(id))
|
|
return next.length === prev.length ? prev : next
|
|
})
|
|
}, [filteredDevices, isExportDialogOpen])
|
|
|
|
const handleToggleDeviceSelection = useCallback((deviceId: string, checked: boolean) => {
|
|
setExportSelection((prev) => {
|
|
if (checked) {
|
|
if (prev.includes(deviceId)) return prev
|
|
return [...prev, deviceId]
|
|
}
|
|
return prev.filter((id) => id !== deviceId)
|
|
})
|
|
}, [])
|
|
|
|
const handleSelectAllDevices = useCallback((checked: boolean) => {
|
|
if (checked) {
|
|
setExportSelection(filteredDevices.map((m) => m.id))
|
|
} else {
|
|
setExportSelection([])
|
|
}
|
|
}, [filteredDevices])
|
|
|
|
const handleConfirmExport = useCallback(async () => {
|
|
const orderedSelection = filteredDevices.map((m) => m.id).filter((id) => exportSelection.includes(id))
|
|
if (orderedSelection.length === 0) {
|
|
toast.info("Selecione ao menos um dispositivo para exportar.")
|
|
return
|
|
}
|
|
const normalizedColumns = orderColumnConfig(columnConfig, customColumnOrder)
|
|
if (normalizedColumns.length === 0) {
|
|
toast.info("Selecione ao menos uma coluna 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))
|
|
params.set("columns", JSON.stringify(normalizedColumns))
|
|
if (selectedTemplateId) {
|
|
params.set("templateId", selectedTemplateId)
|
|
}
|
|
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] ?? `devices-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} dispositivo${orderedSelection.length === 1 ? "" : "s"}.`)
|
|
setIsExporting(false)
|
|
setIsExportDialogOpen(false)
|
|
} catch (error) {
|
|
console.error("Failed to export devices inventory", error)
|
|
setIsExporting(false)
|
|
setExportProgress(0)
|
|
setExportError("Não foi possível gerar o arquivo. Tente novamente.")
|
|
}
|
|
}, [companyFilterSlug, exportSelection, filteredDevices, columnConfig, customColumnOrder, selectedTemplateId])
|
|
|
|
const exportableCount = filteredDevices.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 className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<CardTitle>Dispositivos registrados</CardTitle>
|
|
<CardDescription>Sincronizadas via agente local instalado nas máquinas. Atualiza em tempo quase real.</CardDescription>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button size="sm" onClick={handleOpenCreateDevice}>
|
|
<Plus className="size-4" />
|
|
Novo dispositivo
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="overflow-hidden">
|
|
<div className="mb-3 flex flex-col gap-3">
|
|
<div className="flex flex-wrap gap-3">
|
|
<label className="flex min-w-[260px] flex-1 flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
Buscar hostname, e-mail, MAC, serial...
|
|
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Digite para filtrar" />
|
|
</label>
|
|
<label className="flex w-auto min-w-[170px] max-w-[220px] flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
Todos status
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="justify-between text-sm">
|
|
<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>
|
|
</label>
|
|
<label className="flex w-auto min-w-[180px] max-w-[240px] flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
Todos os tipos
|
|
<Select value={deviceTypeFilter} onValueChange={setDeviceTypeFilter}>
|
|
<SelectTrigger className="justify-between text-sm">
|
|
<SelectValue placeholder="Tipo de dispositivo" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DEVICE_TYPE_FILTER_OPTIONS.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<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)
|
|
setIsCompanyPopoverOpen(false)
|
|
}}
|
|
>
|
|
Limpar
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="gap-2" onClick={handleOpenExportDialog}>
|
|
<Download className="size-4" />
|
|
Exportar XLSX
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{isLoading ? (
|
|
<LoadingState />
|
|
) : devices.length === 0 ? (
|
|
<EmptyState />
|
|
) : (
|
|
<DevicesGrid devices={filteredDevices} 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 os dispositivos antes de gerar o XLSX.</DialogDescription>
|
|
</DialogHeader>
|
|
{filteredDevices.length === 0 ? (
|
|
<div className="rounded-md border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-muted-foreground">
|
|
Nenhum dispositivo 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 {filteredDevices.length} selecionadas
|
|
</span>
|
|
<label className="inline-flex items-center gap-2 font-medium text-slate-600">
|
|
<Checkbox
|
|
checked={selectAllState}
|
|
onCheckedChange={(value) => handleSelectAllDevices(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">
|
|
{filteredDevices.map((device) => {
|
|
const statusKey = resolveDeviceStatus(device)
|
|
const statusLabel = DEVICE_STATUS_LABELS[statusKey] ?? statusKey
|
|
const isChecked = exportSelection.includes(device.id)
|
|
const osParts = [device.osName ?? "", device.osVersion ?? ""].filter(Boolean)
|
|
const osLabel = osParts.join(" ")
|
|
return (
|
|
<li key={device.id}>
|
|
<label className="flex cursor-pointer items-start gap-3 px-3 py-3 hover:bg-slate-50">
|
|
<Checkbox
|
|
checked={isChecked}
|
|
onCheckedChange={(value) => handleToggleDeviceSelection(device.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">
|
|
{device.displayName ?? device.hostname ?? device.authEmail ?? "Dispositivo"}
|
|
</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>{device.companyName ?? "Sem empresa"}</span>
|
|
{osLabel ? (
|
|
<>
|
|
<span className="text-slate-300">•</span>
|
|
<span>{osLabel}</span>
|
|
</>
|
|
) : null}
|
|
{device.architecture ? (
|
|
<>
|
|
<span className="text-slate-300">•</span>
|
|
<span>{device.architecture}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{filteredDevices.length > 0 ? (
|
|
<div className="space-y-4 rounded-md border border-slate-200 bg-white p-4">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold text-slate-900">Template de exportação</p>
|
|
<p className="text-xs text-slate-500">Salve combinações de colunas para reutilizar mais tarde.</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={selectedTemplateId ?? "custom"} onValueChange={handleTemplateSelection}>
|
|
<SelectTrigger className="w-60">
|
|
<SelectValue placeholder="Selecionar template" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="custom">Personalizado</SelectItem>
|
|
{templateOptions.map((tpl) => (
|
|
<SelectItem key={tpl.id} value={tpl.id}>
|
|
{tpl.name}
|
|
{tpl.isDefault ? " • padrão" : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="button" variant="outline" size="sm" onClick={handleOpenTemplateDialog} disabled={isExporting}>
|
|
Salvar template
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{selectedTemplate ? (
|
|
<p className="text-xs text-slate-500">
|
|
Aplicando <span className="font-semibold text-slate-700">{selectedTemplate.name}</span>
|
|
{selectedTemplate.companyId ? " (empresa)" : ""}
|
|
{selectedTemplate.isDefault ? " • padrão" : ""}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="text-sm font-semibold text-slate-900">Colunas</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button type="button" variant="ghost" size="sm" onClick={handleSelectAllColumns} disabled={isExporting} className="gap-2">
|
|
<CheckSquare className="size-4" />
|
|
Selecionar todas
|
|
</Button>
|
|
<Separator orientation="vertical" className="h-4" />
|
|
<Button type="button" variant="ghost" size="sm" onClick={handleResetColumns} disabled={isExporting} className="gap-2">
|
|
<RotateCcw className="size-4" />
|
|
Restaurar padrão
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{baseColumnOptions.map((column) => {
|
|
const checked = columnConfig.some((col) => col.key === column.key)
|
|
return (
|
|
<label key={column.key} className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) =>
|
|
handleToggleColumn(column.key, value === true || value === "indeterminate")
|
|
}
|
|
disabled={isExporting}
|
|
/>
|
|
<span>{column.label}</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
{customColumnOptions.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-semibold uppercase text-slate-500">Campos personalizados</p>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{customColumnOptions.map((column) => {
|
|
const checked = columnConfig.some((col) => col.key === column.key)
|
|
return (
|
|
<label key={column.key} className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) =>
|
|
handleToggleColumn(column.key, value === true || value === "indeterminate", column.label)
|
|
}
|
|
disabled={isExporting}
|
|
/>
|
|
<span>{column.label}</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<p className="text-xs text-slate-500">
|
|
{columnConfig.length} coluna{columnConfig.length === 1 ? "" : "s"} selecionada{columnConfig.length === 1 ? "" : "s"}.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{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>
|
|
<Dialog open={isTemplateDialogOpen} onOpenChange={(open) => setIsTemplateDialogOpen(open)}>
|
|
<DialogContent className="max-w-md space-y-4">
|
|
<DialogHeader>
|
|
<DialogTitle>Salvar template</DialogTitle>
|
|
<DialogDescription>Guarde esta seleção de colunas para reutilizar em futuras exportações.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label htmlFor="template-name" className="text-sm font-medium text-slate-700">
|
|
Nome do template
|
|
</label>
|
|
<Input
|
|
id="template-name"
|
|
autoFocus
|
|
value={templateName}
|
|
onChange={(event) => setTemplateName(event.target.value)}
|
|
placeholder="Inventário padrão"
|
|
disabled={isSavingTemplate}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-slate-700">Escopo</p>
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={templateForCompany && Boolean(selectedCompany)}
|
|
onCheckedChange={(value) => setTemplateForCompany(value === true || value === "indeterminate")}
|
|
disabled={!selectedCompany || isSavingTemplate}
|
|
/>
|
|
<span>
|
|
Associar à empresa
|
|
{selectedCompany ? ` ${selectedCompany.name}` : " (selecione uma empresa na filtragem)"}
|
|
</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={templateAsDefault}
|
|
onCheckedChange={(value) => setTemplateAsDefault(value === true || value === "indeterminate")}
|
|
disabled={isSavingTemplate}
|
|
/>
|
|
<span>Definir como padrão</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-slate-500">
|
|
{columnConfig.length} coluna{columnConfig.length === 1 ? "" : "s"} será{columnConfig.length === 1 ? "" : "o"} salva{columnConfig.length === 1 ? "" : "s"} neste template.
|
|
</p>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-2">
|
|
<Button type="button" variant="outline" onClick={() => setIsTemplateDialogOpen(false)} disabled={isSavingTemplate}>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSaveTemplate}
|
|
disabled={
|
|
isSavingTemplate || columnConfig.length === 0 || templateName.trim().length < 3 || !convexUserId
|
|
}
|
|
className="gap-2"
|
|
>
|
|
{isSavingTemplate ? (
|
|
<>
|
|
<Spinner className="size-4" />
|
|
Salvando...
|
|
</>
|
|
) : (
|
|
"Salvar template"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Dialog open={isCreateDeviceOpen} onOpenChange={(open) => (!createDeviceLoading ? setIsCreateDeviceOpen(open) : null)}>
|
|
<DialogContent className="max-w-lg space-y-4">
|
|
<DialogHeader>
|
|
<DialogTitle>Novo dispositivo</DialogTitle>
|
|
<DialogDescription>Cadastre equipamentos sem agente instalado, como celulares e tablets.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<label htmlFor="device-name" className="text-sm font-medium text-slate-700">
|
|
Nome do dispositivo
|
|
</label>
|
|
<Input
|
|
id="device-name"
|
|
autoFocus
|
|
value={newDeviceName}
|
|
onChange={(event) => setNewDeviceName(event.target.value)}
|
|
placeholder="iPhone da Ana"
|
|
disabled={createDeviceLoading}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label htmlFor="device-identifier" className="text-sm font-medium text-slate-700">
|
|
Identificador (hostname)
|
|
</label>
|
|
<Input
|
|
id="device-identifier"
|
|
value={newDeviceIdentifier}
|
|
onChange={(event) => setNewDeviceIdentifier(event.target.value)}
|
|
placeholder="ana-iphone"
|
|
disabled={createDeviceLoading}
|
|
/>
|
|
<p className="text-xs text-slate-500">Caso vazio, usaremos o nome como identificador.</p>
|
|
</div>
|
|
<div className="grid gap-2 sm:grid-cols-2 sm:gap-4">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-slate-700">Tipo</label>
|
|
<Select value={newDeviceType} onValueChange={setNewDeviceType} disabled={createDeviceLoading}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="mobile">Celular</SelectItem>
|
|
<SelectItem value="desktop">Desktop</SelectItem>
|
|
<SelectItem value="tablet">Tablet</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label htmlFor="device-platform" className="text-sm font-medium text-slate-700">
|
|
Plataforma
|
|
</label>
|
|
<Input
|
|
id="device-platform"
|
|
value={newDevicePlatform}
|
|
onChange={(event) => setNewDevicePlatform(event.target.value)}
|
|
placeholder="iOS 18, Android 15, Windows 11"
|
|
disabled={createDeviceLoading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium text-slate-700">Empresa</label>
|
|
<SearchableCombobox
|
|
value={newDeviceCompanySlug}
|
|
onValueChange={setNewDeviceCompanySlug}
|
|
options={companyComboboxOptions}
|
|
placeholder="Sem empresa"
|
|
searchPlaceholder="Buscar empresa..."
|
|
emptyText="Nenhuma empresa encontrada."
|
|
allowClear
|
|
clearLabel="Sem empresa"
|
|
disabled={createDeviceLoading}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label htmlFor="device-serials" className="text-sm font-medium text-slate-700">
|
|
Seriais / IMEI
|
|
</label>
|
|
<Textarea
|
|
id="device-serials"
|
|
value={newDeviceSerials}
|
|
onChange={(event) => setNewDeviceSerials(event.target.value)}
|
|
placeholder="Separe múltiplos valores com quebra de linha"
|
|
disabled={createDeviceLoading}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label htmlFor="device-notes" className="text-sm font-medium text-slate-700">
|
|
Observações
|
|
</label>
|
|
<Textarea
|
|
id="device-notes"
|
|
value={newDeviceNotes}
|
|
onChange={(event) => setNewDeviceNotes(event.target.value)}
|
|
placeholder="Informações adicionais para a equipe"
|
|
disabled={createDeviceLoading}
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={newDeviceActive}
|
|
onCheckedChange={(value) => setNewDeviceActive(value === true || value === "indeterminate")}
|
|
disabled={createDeviceLoading}
|
|
/>
|
|
<span>Ativo</span>
|
|
</label>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => !createDeviceLoading && setIsCreateDeviceOpen(false)}
|
|
disabled={createDeviceLoading}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleCreateDevice}
|
|
disabled={createDeviceLoading || newDeviceName.trim().length < 3}
|
|
className="gap-2"
|
|
>
|
|
{createDeviceLoading ? (
|
|
<>
|
|
<Spinner className="size-4" />
|
|
Salvando...
|
|
</>
|
|
) : (
|
|
"Cadastrar dispositivo"
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DeviceStatusBadge({ status }: { status?: string | null }) {
|
|
const { label, className } = getStatusVariant(status)
|
|
const { dotClass, ringClass, isPinging } = getDeviceStatusIndicator(status)
|
|
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", dotClass)} />
|
|
{isPinging ? (
|
|
<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">Nenhum dispositivo registrado ainda</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Execute o agente local ou o webhook do Fleet para registrar os dispositivos 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 dispositivos...</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Sincronizando o inventário em tempo real. Isso leva apenas alguns instantes.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type DeviceDetailsProps = {
|
|
device: DevicesQueryItem | null
|
|
}
|
|
|
|
export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|
const router = useRouter()
|
|
const { role: viewerRole } = useAuth()
|
|
const normalizedViewerRole = (viewerRole ?? "").toLowerCase()
|
|
const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent"
|
|
const canManageFieldCatalog = normalizedViewerRole === "admin"
|
|
const effectiveStatus = device ? resolveDeviceStatus(device) : "unknown"
|
|
const [isActiveLocal, setIsActiveLocal] = useState<boolean>(device?.isActive ?? true)
|
|
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
|
|
const isManualMobile =
|
|
(device?.managementMode ?? "").toLowerCase() === "manual" &&
|
|
(device?.deviceType ?? "").toLowerCase() === "mobile"
|
|
const alertsHistory = useQuery(
|
|
api.devices.listAlerts,
|
|
device ? { machineId: device.id as Id<"machines">, limit: 50 } : "skip"
|
|
) as DeviceAlertEntry[] | undefined
|
|
const deviceAlertsHistory = alertsHistory ?? []
|
|
const openTickets = useQuery(
|
|
api.devices.listOpenTickets,
|
|
device ? { machineId: device.id as Id<"machines">, limit: 6 } : "skip"
|
|
) as DeviceOpenTicketsSummary | undefined
|
|
const deviceTickets = openTickets?.tickets ?? []
|
|
const totalOpenTickets = openTickets?.totalOpen ?? deviceTickets.length
|
|
const displayLimit = 3
|
|
const displayedDeviceTickets = deviceTickets.slice(0, displayLimit)
|
|
const hasAdditionalOpenTickets = totalOpenTickets > displayedDeviceTickets.length
|
|
const deviceTicketsHref = device ? `/admin/devices/${device.id}/tickets` : null
|
|
const metadata = device?.inventory ?? null
|
|
const metrics = device?.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 ?? device?.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 = device?.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 ?? ""
|
|
}, [device?.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 = device?.lastHeartbeatAt ? new Date(device.lastHeartbeatAt) : null
|
|
const tokenExpiry = device?.token?.expiresAt ? new Date(device.token.expiresAt) : null
|
|
const tokenLastUsed = device?.token?.lastUsedAt ? new Date(device.token.lastUsedAt) : null
|
|
|
|
const copyEmail = async () => {
|
|
if (!device?.authEmail) return
|
|
try {
|
|
await navigator.clipboard.writeText(device.authEmail)
|
|
toast.success("E-mail do dispositivo copiado.")
|
|
} catch {
|
|
toast.error("Não foi possível copiar o e-mail do dispositivo.")
|
|
}
|
|
}
|
|
|
|
// collaborator (from device assignment or metadata)
|
|
type Collaborator = { email?: string; name?: string; role?: string }
|
|
const collaborator: Collaborator | null = useMemo(() => {
|
|
if (device?.assignedUserEmail) {
|
|
return {
|
|
email: device.assignedUserEmail ?? undefined,
|
|
name: device.assignedUserName ?? undefined,
|
|
role: device.persona ?? device.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
|
|
}, [device?.assignedUserEmail, device?.assignedUserName, device?.persona, device?.assignedUserRole, metadata])
|
|
|
|
const primaryLinkedUser: Collaborator | null = useMemo(() => {
|
|
const firstLinked = device?.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 ?? device?.persona ?? undefined,
|
|
}
|
|
}
|
|
if (collaborator?.email) {
|
|
return collaborator
|
|
}
|
|
if (device?.authEmail) {
|
|
return {
|
|
email: device.authEmail ?? undefined,
|
|
name: undefined,
|
|
role: device?.persona ?? undefined,
|
|
}
|
|
}
|
|
return null
|
|
}, [collaborator, device?.authEmail, device?.linkedUsers, device?.persona])
|
|
|
|
const personaRole = (primaryLinkedUser?.role ?? collaborator?.role ?? device?.persona ?? "").toLowerCase()
|
|
const personaLabel = personaRole === "manager" ? "Gestor" : "Colaborador"
|
|
|
|
const remoteAccessEntries = useMemo(() => device?.remoteAccessEntries ?? [], [device?.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 = device?.osVersion ?? windowsVersionLabel ?? ""
|
|
const osVersion = formatOsVersionDisplay(osNameDisplay, osVersionRaw)
|
|
chips.push({
|
|
key: "os",
|
|
label: "Sistema",
|
|
value: [osName, osVersion].filter(Boolean).join(" ").trim(),
|
|
icon: <OsIcon osName={device?.osName} />,
|
|
})
|
|
if (device?.architecture) {
|
|
chips.push({
|
|
key: "arch",
|
|
label: "Arquitetura",
|
|
value: device.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,
|
|
device?.osVersion,
|
|
device?.architecture,
|
|
windowsVersionLabel,
|
|
windowsBuildLabel,
|
|
windowsActivationStatus,
|
|
primaryLinkedUser?.email,
|
|
primaryLinkedUser?.name,
|
|
personaLabel,
|
|
device?.osName,
|
|
remoteAccessEntries,
|
|
])
|
|
|
|
const companyName = device?.companyName ?? device?.companySlug ?? null
|
|
|
|
const [renaming, setRenaming] = useState(false)
|
|
const [newName, setNewName] = useState<string>(device?.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>("")
|
|
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 [remoteAccessUsernameInput, setRemoteAccessUsernameInput] = useState("")
|
|
const [remoteAccessPasswordInput, setRemoteAccessPasswordInput] = useState("")
|
|
const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("")
|
|
const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("")
|
|
const [remoteAccessSaving, setRemoteAccessSaving] = useState(false)
|
|
const [visibleRemoteSecrets, setVisibleRemoteSecrets] = useState<Record<string, boolean>>({})
|
|
const editingRemoteAccess = useMemo(
|
|
() => remoteAccessEntries.find((entry) => entry.clientId === editingRemoteAccessClientId) ?? null,
|
|
[editingRemoteAccessClientId, remoteAccessEntries]
|
|
)
|
|
const [togglingActive, setTogglingActive] = useState(false)
|
|
const [isResettingAgent, setIsResettingAgent] = useState(false)
|
|
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
|
|
const jsonText = useMemo(() => {
|
|
const payload = {
|
|
id: device?.id,
|
|
hostname: device?.hostname,
|
|
status: device?.status,
|
|
lastHeartbeatAt: device?.lastHeartbeatAt,
|
|
metrics,
|
|
inventory: metadata,
|
|
postureAlerts: device?.postureAlerts ?? null,
|
|
lastPostureAt: device?.lastPostureAt ?? null,
|
|
}
|
|
return JSON.stringify(payload, null, 2)
|
|
}, [device, metrics, metadata])
|
|
const handleDownloadInventoryJson = useCallback(() => {
|
|
if (!device) return
|
|
const baseName = device.displayName ?? device.hostname ?? "dispositivo"
|
|
const safeHostname = baseName.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase()
|
|
const fileName = `${safeHostname || "device"}_${device.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, device])
|
|
|
|
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("")
|
|
}, [device?.id])
|
|
|
|
useEffect(() => {
|
|
setAccessName(primaryLinkedUser?.name ?? "")
|
|
setAccessRole(personaRole === "manager" ? "manager" : "collaborator")
|
|
}, [device?.id, primaryLinkedUser?.name, personaRole])
|
|
|
|
useEffect(() => {
|
|
setIsActiveLocal(device?.isActive ?? true)
|
|
}, [device?.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 ?? "")
|
|
setRemoteAccessUsernameInput(editingRemoteAccess?.username ?? "")
|
|
setRemoteAccessPasswordInput(editingRemoteAccess?.password ?? "")
|
|
setRemoteAccessUrlInput(editingRemoteAccess?.url ?? "")
|
|
setRemoteAccessNotesInput(editingRemoteAccess?.notes ?? "")
|
|
}, [remoteAccessDialog, editingRemoteAccess])
|
|
|
|
useEffect(() => {
|
|
if (remoteAccessDialog) return
|
|
if (!editingRemoteAccessClientId) {
|
|
setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value)
|
|
setRemoteAccessCustomProvider("")
|
|
setRemoteAccessIdentifierInput("")
|
|
setRemoteAccessUsernameInput("")
|
|
setRemoteAccessPasswordInput("")
|
|
setRemoteAccessUrlInput("")
|
|
setRemoteAccessNotesInput("")
|
|
}
|
|
}, [editingRemoteAccessClientId, remoteAccessDialog])
|
|
|
|
useEffect(() => {
|
|
setShowAllWindowsSoftware(false)
|
|
setVisibleRemoteSecrets({})
|
|
}, [device?.id])
|
|
|
|
const displayedWindowsSoftware = useMemo(
|
|
() => (showAllWindowsSoftware ? normalizedWindowsSoftware : normalizedWindowsSoftware.slice(0, 12)),
|
|
[showAllWindowsSoftware, normalizedWindowsSoftware]
|
|
)
|
|
|
|
const handleSaveAccess = async () => {
|
|
if (!device) return
|
|
if (!accessEmail.trim()) {
|
|
toast.error("Informe o e-mail do colaborador ou gestor.")
|
|
return
|
|
}
|
|
setSavingAccess(true)
|
|
try {
|
|
const response = await fetch("/api/admin/devices/access", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
machineId: device.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 o acesso do dispositivo.")
|
|
} finally {
|
|
setSavingAccess(false)
|
|
}
|
|
}
|
|
|
|
const handleSaveRemoteAccess = useCallback(async () => {
|
|
if (!device) return
|
|
if (!canManageRemoteAccess) {
|
|
toast.error("Você não tem permissão para ajustar o acesso remoto deste dispositivo.")
|
|
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
|
|
}
|
|
|
|
const username = remoteAccessUsernameInput.trim()
|
|
const password = remoteAccessPasswordInput.trim()
|
|
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: device.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/devices/remote-access", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(cleanupPayload),
|
|
}).catch(() => null)
|
|
}
|
|
|
|
const response = await fetch("/api/admin/devices/remote-access", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
machineId: device.id,
|
|
provider: providerName,
|
|
identifier,
|
|
username,
|
|
password,
|
|
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)
|
|
}
|
|
}, [
|
|
device,
|
|
canManageRemoteAccess,
|
|
remoteAccessProviderOption,
|
|
remoteAccessCustomProvider,
|
|
remoteAccessIdentifierInput,
|
|
remoteAccessUsernameInput,
|
|
remoteAccessPasswordInput,
|
|
remoteAccessUrlInput,
|
|
remoteAccessNotesInput,
|
|
editingRemoteAccess,
|
|
])
|
|
|
|
const handleRemoveRemoteAccess = useCallback(async (entry: DeviceRemoteAccessEntry) => {
|
|
if (!device) return
|
|
if (!canManageRemoteAccess) {
|
|
toast.error("Você não tem permissão para ajustar o acesso remoto deste dispositivo.")
|
|
return
|
|
}
|
|
toast.dismiss("remote-access")
|
|
toast.loading("Removendo acesso remoto...", { id: "remote-access" })
|
|
setRemoteAccessSaving(true)
|
|
try {
|
|
const requestPayload: Record<string, unknown> = {
|
|
machineId: device.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/devices/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)
|
|
}
|
|
}, [device, canManageRemoteAccess])
|
|
|
|
const handleToggleActive = async () => {
|
|
if (!device) return
|
|
const nextActive = !isActiveLocal
|
|
setIsActiveLocal(nextActive)
|
|
setTogglingActive(true)
|
|
try {
|
|
const response = await fetch("/api/admin/devices/toggle-active", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ machineId: device.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 ? "Dispositivo reativada" : "Dispositivo desativada")
|
|
} catch (error) {
|
|
console.error(error)
|
|
setIsActiveLocal(!nextActive)
|
|
toast.error("Não foi possível atualizar o status do dispositivo.")
|
|
} finally {
|
|
setTogglingActive(false)
|
|
}
|
|
}
|
|
|
|
const handleResetAgent = useCallback(async () => {
|
|
if (!device) return
|
|
toast.dismiss("device-reset")
|
|
toast.loading("Resetando agente...", { id: "device-reset" })
|
|
setIsResettingAgent(true)
|
|
try {
|
|
const response = await fetch("/api/admin/devices/reset-agent", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ machineId: device.id }),
|
|
})
|
|
const payload = (await response.json().catch(() => null)) as { error?: string; revoked?: number } | null
|
|
if (!response.ok) {
|
|
const message = payload?.error ?? "Falha ao resetar agente."
|
|
throw new Error(message)
|
|
}
|
|
const revokedLabel = typeof payload?.revoked === "number" && payload.revoked > 0 ? ` (${payload.revoked} token(s) revogados)` : ""
|
|
toast.success(`Agente resetado${revokedLabel}. Reprovisione o agente no dispositivo.`, { id: "device-reset" })
|
|
router.refresh()
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Falha ao resetar agente."
|
|
toast.error(message, { id: "device-reset" })
|
|
} finally {
|
|
setIsResettingAgent(false)
|
|
}
|
|
}, [device, router])
|
|
|
|
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.")
|
|
}
|
|
}, [])
|
|
|
|
const handleCopyRemoteCredential = useCallback(async (value: string | null | undefined, label: string) => {
|
|
if (!value) return
|
|
try {
|
|
await navigator.clipboard.writeText(value)
|
|
toast.success(`${label} copiado.`)
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error(`Não foi possível copiar ${label.toLowerCase()}.`)
|
|
}
|
|
}, [])
|
|
|
|
const toggleRemoteSecret = useCallback((clientId: string) => {
|
|
setVisibleRemoteSecrets((prev) => ({ ...prev, [clientId]: !prev[clientId] }))
|
|
}, [])
|
|
|
|
const handleRustDeskConnect = useCallback((entry: DeviceRemoteAccessEntry) => {
|
|
if (!entry) return
|
|
const link = buildRustDeskUri(entry)
|
|
if (!link) {
|
|
toast.error("Não foi possível montar o link do RustDesk (ID ou senha ausentes).")
|
|
return
|
|
}
|
|
if (typeof window === "undefined") {
|
|
toast.error("A conexão direta só funciona no navegador.")
|
|
return
|
|
}
|
|
try {
|
|
window.location.href = link
|
|
toast.success("Abrindo o RustDesk...")
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível acionar o RustDesk neste dispositivo.")
|
|
}
|
|
}, [])
|
|
|
|
// Exportação individual (colunas personalizadas)
|
|
const [isSingleExportOpen, setIsSingleExportOpen] = useState(false)
|
|
const [singleExporting, setSingleExporting] = useState(false)
|
|
const [singleExportError, setSingleExportError] = useState<string | null>(null)
|
|
const [singleColumns, setSingleColumns] = useState<DeviceInventoryColumnConfig[]>([...DEFAULT_DEVICE_COLUMN_CONFIG])
|
|
const { convexUserId } = useAuth()
|
|
const deviceFieldDefs = useQuery(
|
|
api.deviceFields.listForTenant,
|
|
convexUserId && device
|
|
? { tenantId: device.tenantId, viewerId: convexUserId as Id<"users">, scope: (device.deviceType ?? "all") as string }
|
|
: "skip"
|
|
) as Array<{ id: string; key: string; label: string; type?: string; options?: Array<{ value: string; label: string }>; required?: boolean }> | undefined
|
|
|
|
const baseColumnOptionsSingle = useMemo(
|
|
() => DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ key: meta.key, label: meta.label })),
|
|
[]
|
|
)
|
|
const customColumnOptionsSingle = useMemo(() => {
|
|
const map = new Map<string, { key: string; label: string }>()
|
|
;(deviceFieldDefs ?? []).forEach((field) => map.set(field.key, { key: field.key, label: field.label }))
|
|
;(device?.customFields ?? []).forEach((field) => {
|
|
if (!map.has(field.fieldKey)) map.set(field.fieldKey, { key: field.fieldKey, label: field.label })
|
|
})
|
|
return Array.from(map.values())
|
|
.map((f) => ({ key: `custom:${f.key}`, label: f.label }))
|
|
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
|
}, [deviceFieldDefs, device?.customFields])
|
|
|
|
const singleCustomOrder = useMemo(() => customColumnOptionsSingle.map((c) => c.key), [customColumnOptionsSingle])
|
|
|
|
useEffect(() => {
|
|
setSingleColumns((prev) => orderColumnConfig(prev, singleCustomOrder))
|
|
}, [singleCustomOrder])
|
|
|
|
const toggleSingleColumn = useCallback((key: string, checked: boolean, label?: string) => {
|
|
setSingleColumns((prev) => {
|
|
const filtered = prev.filter((col) => col.key !== key)
|
|
if (checked) return orderColumnConfig([...filtered, { key, label }], singleCustomOrder)
|
|
return filtered
|
|
})
|
|
}, [singleCustomOrder])
|
|
|
|
const resetSingleColumns = useCallback(() => {
|
|
setSingleColumns(orderColumnConfig([...DEFAULT_DEVICE_COLUMN_CONFIG], singleCustomOrder))
|
|
}, [singleCustomOrder])
|
|
|
|
const selectAllSingleColumns = useCallback(() => {
|
|
const all: DeviceInventoryColumnConfig[] = [
|
|
...baseColumnOptionsSingle.map((c) => ({ key: c.key })),
|
|
...customColumnOptionsSingle.map((c) => ({ key: c.key, label: c.label })),
|
|
]
|
|
setSingleColumns(orderColumnConfig(all, singleCustomOrder))
|
|
}, [baseColumnOptionsSingle, customColumnOptionsSingle, singleCustomOrder])
|
|
|
|
const handleExportSingle = useCallback(async () => {
|
|
if (!device) return
|
|
const normalized = orderColumnConfig(singleColumns, singleCustomOrder)
|
|
if (normalized.length === 0) {
|
|
toast.info("Selecione ao menos uma coluna para exportar.")
|
|
return
|
|
}
|
|
setSingleExporting(true)
|
|
setSingleExportError(null)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
params.append("machineId", device.id)
|
|
params.set("columns", JSON.stringify(normalized))
|
|
const url = `/api/reports/machines-inventory.xlsx?${params.toString()}`
|
|
const response = await fetch(url)
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
const disposition = response.headers.get("Content-Disposition")
|
|
const filenameMatch = disposition?.match(/filename="?([^";]+)"?/i)
|
|
const filename = filenameMatch?.[1] ?? `machine-inventory-${device.hostname}.xlsx`
|
|
const blob = await response.blob()
|
|
const downloadUrl = window.URL.createObjectURL(blob)
|
|
const a = document.createElement("a")
|
|
a.href = downloadUrl
|
|
a.download = filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
a.remove()
|
|
window.URL.revokeObjectURL(downloadUrl)
|
|
setIsSingleExportOpen(false)
|
|
} catch (err) {
|
|
console.error("Falha na exportação individual", err)
|
|
setSingleExportError("Não foi possível gerar a planilha. Tente novamente.")
|
|
} finally {
|
|
setSingleExporting(false)
|
|
}
|
|
}, [device, singleColumns, singleCustomOrder])
|
|
|
|
// Editor de campos personalizados
|
|
const [customFieldsEditorOpen, setCustomFieldsEditorOpen] = useState(false)
|
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
|
const saveCustomFields = useMutation(api.devices.saveDeviceCustomFields)
|
|
const createDeviceField = useMutation(api.deviceFields.create)
|
|
|
|
useEffect(() => {
|
|
const current: Record<string, unknown> = {}
|
|
;(device?.customFields ?? []).forEach((f) => {
|
|
current[String((f as { fieldId?: string }).fieldId ?? f.fieldKey)] = f.value ?? f.displayValue ?? null
|
|
})
|
|
setCustomFieldValues(current)
|
|
}, [device?.customFields])
|
|
|
|
const editableFields = useMemo(
|
|
() => (deviceFieldDefs ?? []).map((f) => ({ id: f.id, key: f.key, label: f.label, type: f.type ?? "text", options: f.options ?? [] })),
|
|
[deviceFieldDefs]
|
|
)
|
|
|
|
const displayCustomFields = useMemo(() => {
|
|
const definitions = deviceFieldDefs ?? []
|
|
const values = device?.customFields ?? []
|
|
const result: Array<{ key: string; label: string; value: string }> = []
|
|
const valueMap = new Map<string, (typeof values)[number]>()
|
|
|
|
values.forEach((field) => {
|
|
if (field.fieldId) {
|
|
valueMap.set(String(field.fieldId), field)
|
|
}
|
|
if (field.fieldKey) {
|
|
valueMap.set(field.fieldKey, field)
|
|
}
|
|
})
|
|
|
|
const used = new Set<string>()
|
|
definitions.forEach((definition) => {
|
|
const idKey = String(definition.id)
|
|
const valueEntry = valueMap.get(idKey) ?? valueMap.get(definition.key)
|
|
used.add(idKey)
|
|
result.push({
|
|
key: idKey,
|
|
label: definition.label,
|
|
value: formatDeviceCustomFieldDisplay(valueEntry),
|
|
})
|
|
})
|
|
|
|
values.forEach((field) => {
|
|
const idKey = field.fieldId ? String(field.fieldId) : undefined
|
|
const keyKey = field.fieldKey ?? field.label
|
|
const compositeKey = idKey ?? keyKey
|
|
if (!compositeKey || used.has(compositeKey)) return
|
|
used.add(compositeKey)
|
|
result.push({
|
|
key: compositeKey,
|
|
label: field.label,
|
|
value: formatDeviceCustomFieldDisplay(field),
|
|
})
|
|
})
|
|
|
|
return result
|
|
}, [deviceFieldDefs, device?.customFields])
|
|
|
|
const handleSaveCustomFields = useCallback(async () => {
|
|
if (!device || !convexUserId) return
|
|
try {
|
|
const fields = editableFields
|
|
.map((def) => {
|
|
const value = customFieldValues[def.id] ?? customFieldValues[def.key]
|
|
return { fieldId: def.id as Id<"deviceFields">, value }
|
|
})
|
|
.filter((entry) => entry.value !== undefined) as Array<{ fieldId: Id<"deviceFields">; value: unknown }>
|
|
await saveCustomFields({ tenantId: device.tenantId, actorId: convexUserId as Id<"users">, machineId: device.id as Id<"machines">, fields })
|
|
toast.success("Campos salvos com sucesso.")
|
|
try {
|
|
router.refresh()
|
|
} catch {
|
|
// ignore refresh errors (e.g., when not in a routed context)
|
|
}
|
|
setCustomFieldsEditorOpen(false)
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível salvar os campos.")
|
|
}
|
|
}, [device, convexUserId, editableFields, customFieldValues, saveCustomFields, router])
|
|
|
|
const [newFieldOpen, setNewFieldOpen] = useState(false)
|
|
const [newFieldLabel, setNewFieldLabel] = useState("")
|
|
const [newFieldType, setNewFieldType] = useState<string>("text")
|
|
const [newFieldOptions, setNewFieldOptions] = useState<Array<{ label: string; value: string }>>([])
|
|
|
|
const handleCreateNewField = useCallback(async () => {
|
|
if (!device || !convexUserId) return
|
|
const label = newFieldLabel.trim()
|
|
if (label.length < 2) {
|
|
toast.error("Informe o rótulo do campo")
|
|
return
|
|
}
|
|
try {
|
|
await createDeviceField({
|
|
tenantId: device.tenantId,
|
|
actorId: convexUserId as Id<"users">,
|
|
label,
|
|
type: newFieldType,
|
|
required: false,
|
|
options: (newFieldType === "select" || newFieldType === "multiselect") ? newFieldOptions : undefined,
|
|
scope: (device.deviceType ?? "all") as string,
|
|
companyId: device.companyId ? (device.companyId as Id<"companies">) : undefined,
|
|
})
|
|
toast.success("Campo criado")
|
|
setNewFieldLabel("")
|
|
setNewFieldOptions([])
|
|
setNewFieldOpen(false)
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível criar o campo")
|
|
}
|
|
}, [device, convexUserId, newFieldLabel, newFieldType, newFieldOptions, createDeviceField])
|
|
|
|
return (
|
|
<Card className="border-slate-200">
|
|
<CardHeader className="gap-1">
|
|
<CardTitle>Detalhes</CardTitle>
|
|
<CardDescription>Resumo do dispositivo selecionado</CardDescription>
|
|
{device ? (
|
|
<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 ? <DeviceStatusBadge status={effectiveStatus} /> : null}
|
|
{!isActiveLocal ? (
|
|
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
|
|
Dispositivo desativada
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</CardAction>
|
|
) : null}
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{!device ? (
|
|
<p className="text-sm text-muted-foreground">Selecione um dispositivo 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">
|
|
{device.displayName ?? device.hostname ?? "Dispositivo"}
|
|
</h1>
|
|
{isManualMobile ? (
|
|
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-neutral-600">
|
|
Identificação interna
|
|
</span>
|
|
) : null}
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="size-7"
|
|
onClick={() => {
|
|
setNewName(device.displayName ?? device.hostname ?? "")
|
|
setRenaming(true)
|
|
}}
|
|
>
|
|
<Pencil className="size-4" />
|
|
<span className="sr-only">Renomear dispositivo</span>
|
|
</Button>
|
|
</div>
|
|
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>{device.authEmail ?? "E-mail não definido"}</span>
|
|
{device.authEmail ? (
|
|
<button
|
|
type="button"
|
|
onClick={copyEmail}
|
|
className="inline-flex items-center rounded-md p-1 text-neutral-500 transition hover:bg-[#00d6eb]/15 hover:text-[#0a4760] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40 focus-visible:ring-offset-2"
|
|
title="Copiar e-mail do dispositivo"
|
|
aria-label="Copiar e-mail do dispositivo"
|
|
>
|
|
<ClipboardCopy className="size-3.5" />
|
|
</button>
|
|
) : null}
|
|
</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-slate-200 bg-white/80 px-4 py-4 shadow-sm">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controles do dispositivo</p>
|
|
{device.registeredBy ? (
|
|
<span className="text-xs font-medium text-slate-500">
|
|
Registrada via <span className="text-slate-800">{device.registeredBy}</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
|
|
<ShieldCheck className="size-4" />
|
|
Ajustar acesso
|
|
</Button>
|
|
{!isManualMobile ? (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:bg-amber-100/60 hover:text-amber-900"
|
|
onClick={handleResetAgent}
|
|
disabled={isResettingAgent}
|
|
>
|
|
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
|
|
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
|
|
</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>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Campos personalizados (posicionado logo após métricas) */}
|
|
<div className="space-y-3 border-t border-slate-100 pt-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
|
|
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
|
{displayCustomFields.length}
|
|
</Badge>
|
|
</div>
|
|
{displayCustomFields.length === 0 ? (
|
|
<p className="text-xs text-neutral-500">Nenhum campo personalizado definido para este dispositivo.</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="outline" className="gap-2" onClick={() => setCustomFieldsEditorOpen(true)}>
|
|
<Pencil className="size-4" />
|
|
Editar
|
|
</Button>
|
|
{canManageFieldCatalog && device ? (
|
|
<DeviceCustomFieldManager
|
|
tenantId={device.tenantId}
|
|
defaultScope={device.deviceType ?? "all"}
|
|
defaultCompanyId={device.companyId ?? null}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{displayCustomFields.length > 0 ? (
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{displayCustomFields.map((field) => (
|
|
<div key={field.key} className="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{field.label}</p>
|
|
<p className="mt-1 text-neutral-800">{field.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por este dispositivo</h4>
|
|
{totalOpenTickets === 0 ? (
|
|
<p className="text-xs text-[color:var(--accent-foreground)]/80">
|
|
Nenhum chamado em aberto registrado diretamente por este dispositivo.
|
|
</p>
|
|
) : 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>
|
|
) : (
|
|
<p className="text-xs text-[color:var(--accent-foreground)]/80">
|
|
Últimos chamados vinculados a este dispositivo.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center">
|
|
<div className="flex h-10 min-w-[56px] items-center justify-center rounded-xl border border-[color:var(--accent)] bg-white px-3 text-[color:var(--accent-foreground)] shadow-sm">
|
|
<span className="text-lg font-semibold leading-none tabular-nums">{totalOpenTickets}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{totalOpenTickets > 0 ? (
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{displayedDeviceTickets.map((ticket) => {
|
|
const priorityMeta = getTicketPriorityMeta(ticket.priority)
|
|
return (
|
|
<Link
|
|
key={ticket.id}
|
|
href={`/tickets/${ticket.id}`}
|
|
className="group flex h-full flex-col justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white p-3 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="space-y-1">
|
|
<p className="line-clamp-2 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 flex-wrap items-center justify-between 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>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
{deviceTicketsHref ? (
|
|
<div className="mt-4">
|
|
<Link
|
|
href={deviceTicketsHref}
|
|
className="text-xs font-semibold text-[color:var(--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>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-3 border-t border-slate-100 pt-5">
|
|
<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 lastVerifiedDate =
|
|
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
|
|
? new Date(entry.lastVerifiedAt)
|
|
: null
|
|
const isRustDesk = isRustDeskAccess(entry)
|
|
const secretVisible = Boolean(visibleRemoteSecrets[entry.clientId])
|
|
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 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)}
|
|
title="Copiar ID"
|
|
aria-label="Copiar ID"
|
|
>
|
|
<ClipboardCopy className="size-3.5" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
{entry.username || entry.password ? (
|
|
<div className="flex flex-col gap-1">
|
|
{entry.username ? (
|
|
<div className="inline-flex flex-wrap items-center gap-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Usuário</span>
|
|
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
|
|
{entry.username}
|
|
</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
|
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
|
|
title="Copiar usuário"
|
|
aria-label="Copiar usuário"
|
|
>
|
|
<ClipboardCopy className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
{entry.password ? (
|
|
<div className="inline-flex flex-wrap items-center gap-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Senha</span>
|
|
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
|
|
{secretVisible ? entry.password : "••••••••"}
|
|
</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
|
onClick={() => toggleRemoteSecret(entry.clientId)}
|
|
>
|
|
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
|
{secretVisible ? "Ocultar" : "Mostrar"}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
|
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
|
|
title="Copiar senha"
|
|
aria-label="Copiar senha"
|
|
>
|
|
<ClipboardCopy className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{entry.url && !isRustDesk ? (
|
|
<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}
|
|
{isRustDesk && (entry.identifier || entry.password) ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-1 inline-flex items-center gap-2 border-slate-300 bg-white text-slate-800 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50 hover:text-slate-900 focus-visible:border-slate-400 focus-visible:ring-slate-200"
|
|
onClick={() => handleRustDeskConnect(entry)}
|
|
>
|
|
<MonitorSmartphone className="size-4 text-[#4b5563]" /> Conectar via RustDesk
|
|
</Button>
|
|
) : 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>
|
|
</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-3 border-t border-slate-100 pt-6">
|
|
<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(device.linkedUsers) && device.linkedUsers.length > 0 ? (
|
|
<ul className="divide-y divide-slate-200 overflow-hidden rounded-md border border-slate-200 bg-slate-50/60">
|
|
{device.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/devices/links?machineId=${device.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 && (!device.linkedUsers || device.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/devices/links', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ machineId: device.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 dispositivo */}
|
|
<Dialog open={renaming} onOpenChange={setRenaming}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Renomear dispositivo</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 (!device) return
|
|
const name = (newName ?? "").trim()
|
|
if (name.length < 2) {
|
|
toast.error("Informe um nome válido")
|
|
return
|
|
}
|
|
try {
|
|
const res = await fetch("/api/admin/devices/rename", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ machineId: device.id, hostname: name }),
|
|
})
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
toast.success("Dispositivo renomeada")
|
|
setRenaming(false)
|
|
} catch (err) {
|
|
console.error(err)
|
|
toast.error("Falha ao renomear dispositivo")
|
|
}
|
|
}}
|
|
>
|
|
Salvar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={accessDialog} onOpenChange={setAccessDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Ajustar acesso do dispositivo</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 este dispositivo.
|
|
</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">Usuário (opcional)</label>
|
|
<Input
|
|
value={remoteAccessUsernameInput}
|
|
onChange={(event) => setRemoteAccessUsernameInput(event.target.value)}
|
|
placeholder="Ex: suporte@cliente.com"
|
|
autoComplete="username"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium">Senha / PIN (opcional)</label>
|
|
<Input
|
|
type="password"
|
|
value={remoteAccessPasswordInput}
|
|
onChange={(event) => setRemoteAccessPasswordInput(event.target.value)}
|
|
placeholder="Senha permanente do RustDesk ou PIN"
|
|
autoComplete="current-password"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Esse valor ficará disponível para os administradores do painel. Limpe o campo para remover a senha salva.
|
|
</p>
|
|
</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>
|
|
|
|
{!isManualMobile ? (
|
|
<section className="space-y-3 border-t border-slate-100 pt-6">
|
|
<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(device.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(device.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">{device.token?.usageCount ?? 0} trocas</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{!isManualMobile ? (
|
|
<section className="space-y-3 border-t border-slate-100 pt-6">
|
|
<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>
|
|
) : null}
|
|
|
|
{!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? (
|
|
<section className="space-y-4 border-t border-slate-100 pt-6">
|
|
<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(", ")
|
|
: device?.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-3 border-t border-slate-100 pt-6">
|
|
<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-4 border-t border-slate-100 pt-6">
|
|
<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 ?? device?.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 ?? device?.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 no dispositivo).
|
|
</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(device?.postureAlerts) && device?.postureAlerts?.length ? (
|
|
<section className="space-y-2">
|
|
<h4 className="text-sm font-semibold">Alertas de postura</h4>
|
|
<div className="space-y-2">
|
|
{device?.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: {device?.lastPostureAt ? formatRelativeTime(new Date(device.lastPostureAt)) : "—"}
|
|
</p>
|
|
</section>
|
|
) : null}
|
|
|
|
{!isManualMobile ? (
|
|
<section className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-semibold">Histórico de alertas</h4>
|
|
{deviceAlertsHistory.length > 0 ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
Últimos {deviceAlertsHistory.length} {deviceAlertsHistory.length === 1 ? "evento" : "eventos"}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{deviceAlertsHistory.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">
|
|
{deviceAlertsHistory.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 este dispositivo.</p>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
<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}
|
|
|
|
{device ? (
|
|
<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 o dispositivo revoga o token atual e remove os dados de inventário sincronizados.
|
|
</p>
|
|
</div>
|
|
<Button variant="destructive" size="sm" onClick={() => setDeleteDialog(true)}>Excluir dispositivo</Button>
|
|
</section>
|
|
) : null}
|
|
|
|
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
{device ? (
|
|
<Button size="sm" variant="outline" className="inline-flex items-center gap-2" onClick={() => setIsSingleExportOpen(true)}>
|
|
<Download className="size-4" /> Exportar planilha
|
|
</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 — {device.displayName ?? device.hostname ?? "Dispositivo"}</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>
|
|
{device ? (
|
|
<Button type="button" variant="outline" size="sm" asChild className="inline-flex items-center gap-2">
|
|
<a href={`/api/admin/devices/${device.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>
|
|
|
|
{/* Exportação individual: seleção de colunas */}
|
|
<Dialog open={isSingleExportOpen} onOpenChange={(open) => (!singleExporting ? setIsSingleExportOpen(open) : null)}>
|
|
<DialogContent className="max-w-2xl space-y-4">
|
|
<DialogHeader>
|
|
<DialogTitle>Exportar planilha — {device.displayName ?? device.hostname ?? "Dispositivo"}</DialogTitle>
|
|
<DialogDescription>Escolha as colunas a incluir na exportação.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-slate-900">Colunas</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button type="button" variant="ghost" size="sm" onClick={selectAllSingleColumns} disabled={singleExporting} className="gap-2">
|
|
<CheckSquare className="size-4" /> Selecionar todas
|
|
</Button>
|
|
<Separator orientation="vertical" className="h-4" />
|
|
<Button type="button" variant="ghost" size="sm" onClick={resetSingleColumns} disabled={singleExporting} className="gap-2">
|
|
<RotateCcw className="size-4" /> Restaurar padrão
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{baseColumnOptionsSingle.map((col) => {
|
|
const checked = singleColumns.some((c) => c.key === col.key)
|
|
return (
|
|
<label key={col.key} className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) => toggleSingleColumn(col.key, value === true || value === "indeterminate")}
|
|
disabled={singleExporting}
|
|
/>
|
|
<span>{col.label}</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
{customColumnOptionsSingle.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-semibold uppercase text-slate-500">Campos personalizados</p>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{customColumnOptionsSingle.map((col) => {
|
|
const checked = singleColumns.some((c) => c.key === col.key)
|
|
return (
|
|
<label key={col.key} className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) => toggleSingleColumn(col.key, value === true || value === "indeterminate", col.label)}
|
|
disabled={singleExporting}
|
|
/>
|
|
<span>{col.label}</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<p className="text-xs text-slate-500">{singleColumns.length} coluna{singleColumns.length === 1 ? "" : "s"} selecionada{singleColumns.length === 1 ? "" : "s"}.</p>
|
|
</div>
|
|
{singleExportError ? <p className="text-sm text-destructive">{singleExportError}</p> : null}
|
|
<DialogFooter className="gap-2 sm:gap-2">
|
|
<Button type="button" variant="outline" onClick={() => setIsSingleExportOpen(false)} disabled={singleExporting}>
|
|
Cancelar
|
|
</Button>
|
|
<Button type="button" onClick={handleExportSingle} disabled={singleExporting} className="gap-2">
|
|
{singleExporting ? (
|
|
<>
|
|
<Spinner className="mr-2 size-4" /> Exportando...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="size-4" /> Exportar
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Editor de campos personalizados */}
|
|
<Dialog open={customFieldsEditorOpen} onOpenChange={(open) => setCustomFieldsEditorOpen(open)}>
|
|
<DialogContent className="max-w-2xl space-y-4">
|
|
<DialogHeader>
|
|
<DialogTitle>Campos personalizados — {device.displayName ?? device.hostname ?? "Dispositivo"}</DialogTitle>
|
|
<DialogDescription>Adicione e ajuste informações complementares deste dispositivo.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-slate-900">Valores</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="outline" onClick={() => setNewFieldOpen((v) => !v)}>
|
|
{newFieldOpen ? "Cancelar" : "Novo campo"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{newFieldOpen ? (
|
|
<div className="space-y-3 rounded-lg border border-slate-200 p-3">
|
|
<div className="grid gap-2 sm:grid-cols-[1fr_160px] sm:gap-3">
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium text-slate-700">Rótulo</label>
|
|
<Input value={newFieldLabel} onChange={(e) => setNewFieldLabel(e.target.value)} placeholder="Ex.: Patrimônio" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium text-slate-700">Tipo</label>
|
|
<Select value={newFieldType} onValueChange={setNewFieldType}>
|
|
<SelectTrigger><SelectValue placeholder="Selecione" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">Texto</SelectItem>
|
|
<SelectItem value="number">Número</SelectItem>
|
|
<SelectItem value="date">Data</SelectItem>
|
|
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
|
|
<SelectItem value="select">Seleção</SelectItem>
|
|
<SelectItem value="multiselect">Seleção múltipla</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
{(newFieldType === "select" || newFieldType === "multiselect") ? (
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-medium text-slate-700">Opções</label>
|
|
<div className="space-y-2">
|
|
{newFieldOptions.map((opt, idx) => (
|
|
<div key={idx} className="grid gap-2 sm:grid-cols-2">
|
|
<Input
|
|
placeholder="Rótulo"
|
|
value={opt.label}
|
|
onChange={(e) => setNewFieldOptions((prev) => prev.map((o, i) => i === idx ? { ...o, label: e.target.value } : o))}
|
|
/>
|
|
<Input
|
|
placeholder="Valor"
|
|
value={opt.value}
|
|
onChange={(e) => setNewFieldOptions((prev) => prev.map((o, i) => i === idx ? { ...o, value: e.target.value } : o))}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button type="button" size="sm" variant="outline" onClick={() => setNewFieldOptions((prev) => [...prev, { label: "", value: "" }])}>Adicionar opção</Button>
|
|
{newFieldOptions.length > 0 ? (
|
|
<Button type="button" size="sm" variant="ghost" onClick={() => setNewFieldOptions((prev) => prev.slice(0, -1))}>Remover última</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" size="sm" onClick={handleCreateNewField}>Criar</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{editableFields.length === 0 ? (
|
|
<div className="rounded-md border border-dashed border-slate-200 p-4 text-sm text-muted-foreground">Nenhum campo disponível para este tipo de dispositivo.</div>
|
|
) : (
|
|
<div className="grid gap-3">
|
|
{editableFields.map((field) => {
|
|
const value = customFieldValues[field.id] ?? customFieldValues[field.key] ?? null
|
|
const setValue = (v: unknown) => setCustomFieldValues((prev) => ({ ...prev, [field.id]: v }))
|
|
return (
|
|
<div key={field.id} className="grid gap-1">
|
|
<label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{field.label}</label>
|
|
{field.type === "text" ? (
|
|
<Input value={(value as string) ?? ""} onChange={(e) => setValue(e.target.value)} />
|
|
) : field.type === "number" ? (
|
|
<Input type="number" value={value == null ? "" : String(value)} onChange={(e) => setValue(e.target.value === "" ? null : Number(e.target.value))} />
|
|
) : field.type === "date" ? (
|
|
<DatePicker
|
|
value={value ? String(value).slice(0, 10) : null}
|
|
onChange={(next) => setValue(next ?? null)}
|
|
placeholder="Selecionar data"
|
|
/>
|
|
) : field.type === "boolean" ? (
|
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox checked={Boolean(value)} onCheckedChange={(v) => setValue(Boolean(v))} />
|
|
<span>Ativo</span>
|
|
</label>
|
|
) : field.type === "select" ? (
|
|
<Select value={value ? String(value) : undefined} onValueChange={(v) => setValue(v)}>
|
|
<SelectTrigger><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
|
<SelectContent>
|
|
{field.options?.map((opt: { value: string; label: string }) => (
|
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : field.type === "multiselect" ? (
|
|
<div className="grid gap-1 sm:grid-cols-2">
|
|
{field.options?.map((opt: { value: string; label: string }) => {
|
|
const arr = Array.isArray(value) ? (value as unknown[]) : []
|
|
const checked = arr.some((v) => String(v) === opt.value)
|
|
return (
|
|
<label key={opt.value} className="flex items-center gap-2 text-sm text-slate-700">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(c) => {
|
|
const base = Array.isArray(value) ? [...(value as unknown[])] : []
|
|
if (c) {
|
|
if (!base.some((v) => String(v) === opt.value)) base.push(opt.value)
|
|
} else {
|
|
const idx = base.findIndex((v) => String(v) === opt.value)
|
|
if (idx >= 0) base.splice(idx, 1)
|
|
}
|
|
setValue(base)
|
|
}}
|
|
/>
|
|
<span>{opt.label}</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={() => setCustomFieldsEditorOpen(false)}>Cancelar</Button>
|
|
<Button type="button" onClick={handleSaveCustomFields}>Salvar</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={deleteDialog} onOpenChange={(open) => { if (!open) setDeleting(false); setDeleteDialog(open) }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Excluir dispositivo</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3 text-sm text-muted-foreground">
|
|
<p>Tem certeza que deseja excluir <span className="font-semibold text-foreground">{device?.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 (!device) return
|
|
setDeleting(true)
|
|
try {
|
|
const res = await fetch("/api/admin/devices/delete", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ machineId: device.id }),
|
|
credentials: "include",
|
|
})
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
toast.success("Dispositivo excluída")
|
|
setDeleteDialog(false)
|
|
router.push("/admin/devices")
|
|
} catch (err) {
|
|
console.error(err)
|
|
toast.error("Falha ao excluir dispositivo")
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}}
|
|
>
|
|
{deleting ? "Excluindo..." : "Excluir dispositivo"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function DevicesGrid({ devices, companyNameBySlug }: { devices: DevicesQueryItem[]; companyNameBySlug: Map<string, string> }) {
|
|
if (!devices || devices.length === 0) return <EmptyState />
|
|
return (
|
|
<div className="grid grid-cols-1 gap-3 [@supports(display:grid)]:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
|
{devices.map((m) => (
|
|
<DeviceCard
|
|
key={m.id}
|
|
device={m}
|
|
companyName={m.companyName ?? (m.companySlug ? companyNameBySlug.get(m.companySlug) ?? m.companySlug : null)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DeviceCard({ device, companyName }: { device: DevicesQueryItem; companyName?: string | null }) {
|
|
const router = useRouter()
|
|
const effectiveStatus = resolveDeviceStatus(device)
|
|
const isActive = device.isActive
|
|
const lastHeartbeat = device.lastHeartbeatAt ? new Date(device.lastHeartbeatAt) : null
|
|
const deviceType = (device.deviceType ?? "desktop").toLowerCase()
|
|
const DeviceIcon = deviceType === "mobile" ? Smartphone : deviceType === "tablet" ? Tablet : Monitor
|
|
const deviceTypeLabel = formatDeviceTypeLabel(device.deviceType)
|
|
const deviceLabel = device.displayName ?? device.hostname ?? "Dispositivo"
|
|
type AgentMetrics = {
|
|
memoryUsedBytes?: number
|
|
memoryTotalBytes?: number
|
|
memoryUsedPercent?: number
|
|
cpuUsagePercent?: number
|
|
}
|
|
const mm = (device.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 (device.assignedUserEmail) {
|
|
return {
|
|
email: device.assignedUserEmail ?? undefined,
|
|
name: device.assignedUserName ?? undefined,
|
|
role: device.persona ?? device.assignedUserRole ?? undefined,
|
|
}
|
|
}
|
|
const inv = device.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 ?? device.companySlug ?? null
|
|
|
|
return (
|
|
<Link href={`/admin/devices/${device.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">
|
|
<DeviceIcon className="size-4 text-slate-500" /> {deviceLabel}
|
|
</CardTitle>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
|
<span>{deviceTypeLabel}</span>
|
|
{device.devicePlatform ? (
|
|
<>
|
|
<span className="text-slate-300">•</span>
|
|
<span>{device.devicePlatform}</span>
|
|
</>
|
|
) : null}
|
|
{device.authEmail ? (
|
|
<>
|
|
<span className="text-slate-300">•</span>
|
|
<span>{device.authEmail}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{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>
|
|
<button
|
|
type="button"
|
|
className="underline underline-offset-4 hover:text-slate-800"
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
router.push("/admin/users")
|
|
}}
|
|
>
|
|
Gerenciar usuários
|
|
</button>
|
|
</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">
|
|
Desativado
|
|
</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 = device.osName ?? "SO"
|
|
const ver = formatOsVersionDisplay(device.osName, device.osVersion)
|
|
return [name, ver].filter(Boolean).join(" ").trim()
|
|
})()}
|
|
</Badge>
|
|
{device.architecture ? (
|
|
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
|
|
{device.architecture.toUpperCase()}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
{collaborator?.email ? (
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{persona}: {collaborator.name ? `${collaborator.name} · ` : ""}
|
|
{collaborator.email}
|
|
</p>
|
|
) : null}
|
|
<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">
|
|
{deviceTypeLabel}
|
|
</Badge>
|
|
{companyLabel ? (
|
|
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
|
|
{companyLabel}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
<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(device.inventory?.disks) ? `${device.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: DeviceMetrics
|
|
hardware?: DeviceInventory["hardware"]
|
|
disks?: DeviceInventory["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: DeviceMetrics; hardware?: DeviceInventory["hardware"]; disks?: DeviceInventory["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)
|
|
}
|