Atualiza portal e admin com bloqueio de máquinas desativadas
This commit is contained in:
parent
e5085962e9
commit
630110bf3a
31 changed files with 1756 additions and 244 deletions
391
src/components/admin/clients/admin-clients-manager.tsx
Normal file
391
src/components/admin/clients/admin-clients-manager.tsx
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState, useTransition } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconFilter,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
|
||||
export type AdminClient = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: "MANAGER" | "COLLABORATOR"
|
||||
companyId: string | null
|
||||
companyName: string | null
|
||||
tenantId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
authUserId: string | null
|
||||
lastSeenAt: string | null
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<AdminClient["role"], string> = {
|
||||
MANAGER: "Gestor",
|
||||
COLLABORATOR: "Colaborador",
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
return format(date, "dd/MM/yy HH:mm", { locale: ptBR })
|
||||
}
|
||||
|
||||
function formatLastSeen(lastSeen: string | null) {
|
||||
if (!lastSeen) return "Nunca conectado"
|
||||
return formatDate(lastSeen)
|
||||
}
|
||||
|
||||
export function AdminClientsManager({ initialClients }: { initialClients: AdminClient[] }) {
|
||||
const [clients, setClients] = useState(initialClients)
|
||||
const [search, setSearch] = useState("")
|
||||
const [roleFilter, setRoleFilter] = useState<"all" | AdminClient["role"]>("all")
|
||||
const [companyFilter, setCompanyFilter] = useState<string>("all")
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const companies = useMemo(() => {
|
||||
const entries = new Map<string, string>()
|
||||
clients.forEach((client) => {
|
||||
if (client.companyId && client.companyName) {
|
||||
entries.set(client.companyId, client.companyName)
|
||||
}
|
||||
})
|
||||
return Array.from(entries.entries()).map(([id, name]) => ({ id, name }))
|
||||
}, [clients])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return clients.filter((client) => {
|
||||
if (roleFilter !== "all" && client.role !== roleFilter) return false
|
||||
if (companyFilter !== "all" && client.companyId !== companyFilter) return false
|
||||
if (!search.trim()) return true
|
||||
const term = search.trim().toLowerCase()
|
||||
return (
|
||||
client.name.toLowerCase().includes(term) ||
|
||||
client.email.toLowerCase().includes(term) ||
|
||||
(client.companyName ?? "").toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
}, [clients, roleFilter, companyFilter, search])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/clients", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
throw new Error(payload?.error ?? "Não foi possível excluir os clientes selecionados.")
|
||||
}
|
||||
const { deletedIds } = (await response.json().catch(() => ({ deletedIds: [] }))) as {
|
||||
deletedIds: string[]
|
||||
}
|
||||
if (deletedIds.length > 0) {
|
||||
setClients((prev) => prev.filter((client) => !deletedIds.includes(client.id)))
|
||||
setRowSelection({})
|
||||
}
|
||||
toast.success(
|
||||
deletedIds.length === 1
|
||||
? "Cliente removido com sucesso."
|
||||
: `${deletedIds.length} clientes removidos com sucesso.`,
|
||||
)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Não foi possível excluir os clientes selecionados."
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
},
|
||||
[startTransition],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<AdminClient>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Cliente",
|
||||
cell: ({ row }) => {
|
||||
const client = row.original
|
||||
const initials = client.name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((value) => value.charAt(0).toUpperCase())
|
||||
.join("")
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarFallback>{initials || client.email.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-neutral-900">{client.name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">{client.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Perfil",
|
||||
cell: ({ row }) => {
|
||||
const role = row.original.role
|
||||
const variant = role === "MANAGER" ? "default" : "secondary"
|
||||
return <Badge variant={variant}>{ROLE_LABEL[role]}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "companyName",
|
||||
header: "Empresa",
|
||||
cell: ({ row }) =>
|
||||
row.original.companyName ? (
|
||||
<Badge variant="outline" className="bg-slate-50 text-xs font-medium">
|
||||
{row.original.companyName}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-neutral-500">Sem empresa</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Cadastrado em",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-neutral-600">{formatDate(row.original.createdAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lastSeenAt",
|
||||
header: "Último acesso",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-neutral-600">{formatLastSeen(row.original.lastSeenAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => handleDelete([row.original.id])}
|
||||
>
|
||||
<IconTrash className="mr-2 size-4" /> Remover
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleDelete, isPending]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: { rowSelection, sorting },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
<IconUser className="size-4" />
|
||||
{clients.length} cliente{clients.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Buscar por nome, e-mail ou empresa"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="h-9 w-full md:w-72"
|
||||
/>
|
||||
<Button variant="outline" size="icon" className="md:hidden">
|
||||
<IconFilter className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={roleFilter} onValueChange={(value) => setRoleFilter(value as typeof roleFilter)}>
|
||||
<SelectTrigger className="h-9 w-40">
|
||||
<SelectValue placeholder="Perfil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os perfis</SelectItem>
|
||||
<SelectItem value="MANAGER">Gestores</SelectItem>
|
||||
<SelectItem value="COLLABORATOR">Colaboradores</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={companyFilter} onValueChange={(value) => setCompanyFilter(value)}>
|
||||
<SelectTrigger className="h-9 w-48">
|
||||
<SelectValue placeholder="Empresa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={selectedRows.length === 0 || isPending}
|
||||
onClick={() => handleDelete(selectedRows.map((row) => row.id))}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
Excluir selecionados
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-slate-200">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
{header.isPlaceholder ? null : header.column.columnDef.header instanceof Function
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center text-sm text-neutral-500">
|
||||
Nenhum cliente encontrado para os filtros selecionados.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="align-middle text-sm text-neutral-700">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between gap-3 text-sm text-neutral-600 md:flex-row">
|
||||
<div>
|
||||
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount() || 1}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<IconChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<IconChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition, useId } from "react"
|
||||
import Link from "next/link"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { useQuery } from "convex/react"
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconCheck,
|
||||
IconDeviceDesktop,
|
||||
IconPencil,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
|
|
@ -73,6 +75,11 @@ type MachineSummary = {
|
|||
hostname: string
|
||||
status: string | null
|
||||
lastHeartbeatAt: number | null
|
||||
isActive?: boolean | null
|
||||
authEmail?: string | null
|
||||
osName?: string | null
|
||||
osVersion?: string | null
|
||||
architecture?: string | null
|
||||
}
|
||||
|
||||
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
|
||||
|
|
@ -84,6 +91,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [machinesDialog, setMachinesDialog] = useState<{ companyId: string; name: string } | null>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const nameId = useId()
|
||||
|
|
@ -111,6 +119,10 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
if (!editingId) return []
|
||||
return machinesByCompanyId.get(editingId) ?? []
|
||||
}, [machinesByCompanyId, editingId])
|
||||
const machinesDialogList = useMemo(() => {
|
||||
if (!machinesDialog) return []
|
||||
return machinesByCompanyId.get(machinesDialog.companyId) ?? []
|
||||
}, [machinesByCompanyId, machinesDialog])
|
||||
|
||||
const resetForm = () => setForm({})
|
||||
|
||||
|
|
@ -575,7 +587,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{companyMachines.slice(0, 3).map((machine) => {
|
||||
const variant = getMachineStatusVariant(machine.status)
|
||||
const variant = getMachineStatusVariant(machine.isActive === false ? "deactivated" : machine.status)
|
||||
return (
|
||||
<Tooltip key={machine.id}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -661,15 +673,15 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<Table className="min-w-full table-fixed text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-100/80 dark:border-slate-800/60">
|
||||
<TableHead className="w-[30%] min-w-[220px] pl-6 text-slate-500 dark:text-slate-300">Empresa</TableHead>
|
||||
<TableHead className="w-[22%] min-w-[180px] pl-4 text-slate-500 dark:text-slate-300">Provisionamento</TableHead>
|
||||
<TableHead className="w-[18%] min-w-[160px] pl-12 text-slate-500 dark:text-slate-300">Cliente avulso</TableHead>
|
||||
<TableHead className="w-[20%] min-w-[170px] pl-12 text-slate-500 dark:text-slate-300">Uso e alertas</TableHead>
|
||||
<TableHead className="w-[10%] min-w-[90px] pr-6 text-right text-slate-500 dark:text-slate-300">Ações</TableHead>
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted/60 backdrop-blur supports-[backdrop-filter]:bg-muted/40">
|
||||
<TableRow className="border-b border-slate-200 dark:border-slate-800/60 [&_th]:h-10 [&_th]:text-xs [&_th]:font-medium [&_th]:uppercase [&_th]:tracking-wide [&_th]:text-muted-foreground [&_th:first-child]:rounded-tl-lg [&_th:last-child]:rounded-tr-lg">
|
||||
<TableHead className="w-[30%] min-w-[220px] pl-6">Empresa</TableHead>
|
||||
<TableHead className="w-[22%] min-w-[180px] pl-4">Provisionamento</TableHead>
|
||||
<TableHead className="w-[18%] min-w-[160px] pl-12">Cliente avulso</TableHead>
|
||||
<TableHead className="w-[20%] min-w-[170px] pl-12">Uso e alertas</TableHead>
|
||||
<TableHead className="w-[10%] min-w-[90px] pr-6 text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -683,6 +695,8 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
|
||||
: null
|
||||
const formattedPhone = formatPhoneDisplay(company.phone)
|
||||
const companyMachines = machinesByCompanyId.get(company.id) ?? []
|
||||
const machineCount = companyMachines.length
|
||||
return (
|
||||
<TableRow
|
||||
key={company.id}
|
||||
|
|
@ -710,6 +724,12 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
{company.contractedHoursPerMonth}h/mês
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-slate-200 bg-white text-[11px] font-medium dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
{machineCount} máquina{machineCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="text-[12px] font-medium text-slate-500 dark:text-slate-400">
|
||||
|
|
@ -803,6 +823,16 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell className="pr-6 text-right align-top">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
onClick={() => setMachinesDialog({ companyId: company.id, name: company.name })}
|
||||
>
|
||||
<IconDeviceDesktop className="size-4" /> Ver máquinas
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="ml-auto">
|
||||
|
|
@ -829,6 +859,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
|
@ -845,6 +876,54 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<Dialog open={!!machinesDialog} onOpenChange={(open) => { if (!open) setMachinesDialog(null) }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Máquinas — {machinesDialog?.name ?? ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{machinesDialogList.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nenhuma máquina vinculada a esta empresa.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{machinesDialogList.map((machine) => {
|
||||
const statusKey = machine.isActive === false ? "deactivated" : machine.status
|
||||
const statusVariant = getMachineStatusVariant(statusKey)
|
||||
return (
|
||||
<li key={machine.id} className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-slate-50/60 px-4 py-3 text-sm text-neutral-700">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-neutral-900">{machine.hostname}</p>
|
||||
<p className="text-xs text-neutral-500">{machine.authEmail ?? "Sem e-mail definido"}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn("h-7 px-3 text-xs font-medium", statusVariant.className)}>
|
||||
{statusVariant.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
<span>{machine.osName ?? "SO desconhecido"}</span>
|
||||
{machine.osVersion ? <span className="text-neutral-400">•</span> : null}
|
||||
{machine.osVersion ? <span>{machine.osVersion}</span> : null}
|
||||
{machine.architecture ? (
|
||||
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-neutral-600 shadow-sm">
|
||||
{machine.architecture.toUpperCase()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="text-xs">
|
||||
<Link href={`/admin/machines/${machine.id}`}>Ver detalhes</Link>
|
||||
</Button>
|
||||
<span className="text-xs text-neutral-500">
|
||||
Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
|
||||
|
|
@ -892,6 +971,7 @@ const MACHINE_STATUS_VARIANTS: Record<string, { label: string; className: string
|
|||
stale: { label: "Sem sinal", className: "border-slate-300 bg-slate-200/60 text-slate-700" },
|
||||
maintenance: { label: "Manutenção", className: "border-amber-200 bg-amber-500/10 text-amber-600" },
|
||||
blocked: { label: "Bloqueada", className: "border-orange-200 bg-orange-500/10 text-orange-600" },
|
||||
deactivated: { label: "Desativada", className: "border-slate-300 bg-slate-100 text-slate-600" },
|
||||
unknown: { label: "Desconhecida", className: "border-slate-200 bg-slate-100 text-slate-600" },
|
||||
}
|
||||
|
||||
|
|
@ -902,7 +982,7 @@ function getMachineStatusVariant(status?: string | null) {
|
|||
|
||||
function summarizeStatus(machines: MachineSummary[]): Record<string, number> {
|
||||
return machines.reduce<Record<string, number>>((acc, machine) => {
|
||||
const normalized = (machine.status ?? "unknown").toLowerCase()
|
||||
const normalized = (machine.isActive === false ? "deactivated" : machine.status ?? "unknown").toLowerCase()
|
||||
acc[normalized] = (acc[normalized] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { toast } from "sonner"
|
||||
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal } from "lucide-react"
|
||||
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal, Power, PlayCircle, Download } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
|
@ -24,7 +25,9 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ChartContainer } from "@/components/ui/chart"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -567,6 +570,7 @@ export type MachinesQueryItem = {
|
|||
assignedUserName: string | null
|
||||
assignedUserRole: string | null
|
||||
status: string | null
|
||||
isActive: boolean
|
||||
lastHeartbeatAt: number | null
|
||||
heartbeatAgeMs: number | null
|
||||
registeredBy: string | null
|
||||
|
|
@ -611,6 +615,7 @@ const statusLabels: Record<string, string> = {
|
|||
stale: "Sem sinal",
|
||||
maintenance: "Manutenção",
|
||||
blocked: "Bloqueada",
|
||||
deactivated: "Desativada",
|
||||
unknown: "Desconhecida",
|
||||
}
|
||||
|
||||
|
|
@ -620,6 +625,7 @@ const statusClasses: Record<string, string> = {
|
|||
stale: "border-slate-400/30 bg-slate-200 text-slate-700",
|
||||
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
|
||||
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600",
|
||||
deactivated: "border-slate-400/40 bg-slate-100 text-slate-600",
|
||||
unknown: "border-slate-300 bg-slate-200 text-slate-700",
|
||||
}
|
||||
|
||||
|
|
@ -707,7 +713,8 @@ function getStatusVariant(status?: string | null) {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveMachineStatus(machine: { status?: string | null; lastHeartbeatAt?: number | null }): string {
|
||||
function resolveMachineStatus(machine: { status?: string | null; lastHeartbeatAt?: number | null; isActive?: boolean | null }): string {
|
||||
if (machine.isActive === false) return "deactivated"
|
||||
const manualStatus = (machine.status ?? "").toLowerCase()
|
||||
if (["maintenance", "blocked"].includes(manualStatus)) {
|
||||
return manualStatus
|
||||
|
|
@ -871,6 +878,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
|
|||
? "bg-amber-500"
|
||||
: s === "blocked"
|
||||
? "bg-orange-500"
|
||||
: s === "deactivated"
|
||||
? "bg-slate-500"
|
||||
: "bg-slate-400"
|
||||
const ringClass =
|
||||
s === "online"
|
||||
|
|
@ -881,6 +890,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
|
|||
? "bg-amber-400/30"
|
||||
: s === "blocked"
|
||||
? "bg-orange-400/30"
|
||||
: s === "deactivated"
|
||||
? "bg-slate-400/40"
|
||||
: "bg-slate-300/30"
|
||||
|
||||
const isOnline = s === "online"
|
||||
|
|
@ -961,6 +972,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
? windowsBaseboardRaw
|
||||
: null
|
||||
const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined
|
||||
const isActive = machine?.isActive ?? true
|
||||
const windowsMemoryModules = useMemo(() => {
|
||||
if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw
|
||||
if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") return [windowsMemoryModulesRaw]
|
||||
|
|
@ -1111,6 +1123,61 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const summaryChips = useMemo(() => {
|
||||
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
|
||||
const osName = machine?.osName ?? "Sistema desconhecido"
|
||||
const osVersion = machine?.osVersion ?? windowsVersionLabel ?? ""
|
||||
chips.push({
|
||||
key: "os",
|
||||
label: "Sistema",
|
||||
value: [osName, osVersion].filter(Boolean).join(" ").trim(),
|
||||
icon: <OsIcon osName={machine?.osName} />,
|
||||
})
|
||||
if (machine?.architecture) {
|
||||
chips.push({
|
||||
key: "arch",
|
||||
label: "Arquitetura",
|
||||
value: machine.architecture.toUpperCase(),
|
||||
icon: <Cpu className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
if (windowsBuildLabel) {
|
||||
chips.push({
|
||||
key: "build",
|
||||
label: "Build",
|
||||
value: windowsBuildLabel,
|
||||
icon: <ServerCog className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) {
|
||||
chips.push({
|
||||
key: "activation",
|
||||
label: "Licença",
|
||||
value: windowsActivationStatus ? "Ativada" : "Não ativada",
|
||||
icon: windowsActivationStatus ? <ShieldCheck className="size-4 text-emerald-500" /> : <ShieldAlert className="size-4 text-amber-500" />,
|
||||
tone: windowsActivationStatus ? undefined : "warning",
|
||||
})
|
||||
}
|
||||
if (primaryGpu?.name) {
|
||||
chips.push({
|
||||
key: "gpu",
|
||||
label: "GPU principal",
|
||||
value: `${primaryGpu.name}${typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}`,
|
||||
icon: <MemoryStick className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
if (collaborator?.email) {
|
||||
const collaboratorValue = collaborator.name ? `${collaborator.name} · ${collaborator.email}` : collaborator.email
|
||||
chips.push({
|
||||
key: "collaborator",
|
||||
label: personaLabel,
|
||||
value: collaboratorValue,
|
||||
icon: <ShieldCheck className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
return chips
|
||||
}, [machine?.osName, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel])
|
||||
|
||||
const companyName = (() => {
|
||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||
const found = companies.find((c) => c.slug === machine.companySlug)
|
||||
|
|
@ -1131,6 +1198,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
|
||||
)
|
||||
const [savingAccess, setSavingAccess] = useState(false)
|
||||
const [togglingActive, setTogglingActive] = useState(false)
|
||||
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
|
||||
const jsonText = useMemo(() => {
|
||||
const payload = {
|
||||
|
|
@ -1145,6 +1213,20 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
return JSON.stringify(payload, null, 2)
|
||||
}, [machine, metrics, metadata])
|
||||
const handleDownloadInventory = useCallback(() => {
|
||||
if (!machine) return
|
||||
const safeHostname = machine.hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase()
|
||||
const fileName = `${safeHostname || "machine"}_${machine.id}.json`
|
||||
const blob = new Blob([jsonText], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [jsonText, machine])
|
||||
|
||||
const filteredJsonHtml = useMemo(() => {
|
||||
if (!dialogQuery.trim()) return jsonText
|
||||
|
|
@ -1200,6 +1282,29 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!machine) return
|
||||
setTogglingActive(true)
|
||||
try {
|
||||
const response = await fetch("/api/admin/machines/toggle-active", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineId: machine.id, active: !isActive }),
|
||||
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(!isActive ? "Máquina reativada" : "Máquina desativada")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível atualizar o status da máquina.")
|
||||
} finally {
|
||||
setTogglingActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
|
|
@ -1224,55 +1329,26 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<p className="text-xs text-muted-foreground">
|
||||
{machine.authEmail ?? "E-mail não definido"}
|
||||
</p>
|
||||
{machine.companySlug ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Empresa vinculada: <span className="font-medium text-foreground">{companyName ?? machine.companySlug}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm">
|
||||
{companyName ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-600 shadow-sm">
|
||||
{companyName}
|
||||
</div>
|
||||
) : null}
|
||||
<MachineStatusBadge status={effectiveStatus} />
|
||||
{!isActive ? (
|
||||
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 text-xs font-semibold uppercase text-rose-700">
|
||||
Máquina desativada
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<MachineStatusBadge status={effectiveStatus} />
|
||||
</div>
|
||||
{/* ping integrado na badge de status */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
<span className="mr-2 inline-flex items-center"><OsIcon osName={machine.osName} /></span>
|
||||
{machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
{machine.architecture?.toUpperCase() ?? "Arquitetura indefinida"}
|
||||
</Badge>
|
||||
{windowsOsInfo ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Build: {windowsBuildLabel ?? "—"}
|
||||
</Badge>
|
||||
) : null}
|
||||
{windowsOsInfo ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Ativado: {
|
||||
windowsActivationStatus == null
|
||||
? "—"
|
||||
: windowsActivationStatus
|
||||
? "Sim"
|
||||
: "Não"
|
||||
}
|
||||
</Badge>
|
||||
) : null}
|
||||
{primaryGpu?.name ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
GPU: {primaryGpu.name}
|
||||
{typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}
|
||||
</Badge>
|
||||
) : null}
|
||||
{companyName ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Empresa: {companyName}
|
||||
</Badge>
|
||||
) : null}
|
||||
{collaborator?.email ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
{personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
|
||||
</Badge>
|
||||
) : null}
|
||||
<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="flex flex-wrap gap-2">
|
||||
{machine.authEmail ? (
|
||||
|
|
@ -1285,6 +1361,19 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<ShieldCheck className="size-4" />
|
||||
Ajustar acesso
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActive ? "outline" : "default"}
|
||||
className={cn(
|
||||
"gap-2 border-dashed",
|
||||
!isActive && "bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||
)}
|
||||
onClick={handleToggleActive}
|
||||
disabled={togglingActive}
|
||||
>
|
||||
{isActive ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
||||
{isActive ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||
</Button>
|
||||
{machine.registeredBy ? (
|
||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||
Registrada via {machine.registeredBy}
|
||||
|
|
@ -1405,12 +1494,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{metrics && typeof metrics === "object" ? (
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||
<MetricsGrid metrics={metrics} />
|
||||
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hardware || network || (labels && labels.length > 0) ? (
|
||||
<section className="space-y-3">
|
||||
|
|
@ -2149,7 +2236,23 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<DialogTitle>Inventário completo — {machine.hostname}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Input placeholder="Buscar no JSON" value={dialogQuery} onChange={(e) => setDialogQuery(e.target.value)} />
|
||||
<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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadInventory}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<Download className="size-4" /> Baixar JSON
|
||||
</Button>
|
||||
</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">')
|
||||
|
|
@ -2225,7 +2328,7 @@ function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQuery
|
|||
|
||||
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
|
||||
const effectiveStatus = resolveMachineStatus(machine)
|
||||
const { className } = getStatusVariant(effectiveStatus)
|
||||
const isActive = machine.isActive
|
||||
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
|
||||
type AgentMetrics = {
|
||||
memoryUsedBytes?: number
|
||||
|
|
@ -2264,19 +2367,23 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
|||
|
||||
return (
|
||||
<Link href={`/admin/machines/${machine.id}`} className="group">
|
||||
<Card className="relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
|
||||
<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",
|
||||
className.includes("emerald")
|
||||
effectiveStatus === "online"
|
||||
? "bg-emerald-500"
|
||||
: className.includes("rose")
|
||||
: effectiveStatus === "offline"
|
||||
? "bg-rose-500"
|
||||
: className.includes("amber")
|
||||
: effectiveStatus === "maintenance"
|
||||
? "bg-amber-500"
|
||||
: "bg-slate-400"
|
||||
: effectiveStatus === "blocked"
|
||||
? "bg-orange-500"
|
||||
: effectiveStatus === "deactivated"
|
||||
? "bg-slate-500"
|
||||
: "bg-slate-400"
|
||||
)}
|
||||
/>
|
||||
{effectiveStatus === "online" ? (
|
||||
|
|
@ -2288,6 +2395,11 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
|||
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
|
||||
{!isActive ? (
|
||||
<Badge variant="outline" className="mt-2 w-fit border-rose-200 bg-rose-50 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
|
||||
Desativada
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex grow flex-col gap-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -2351,52 +2463,243 @@ function DetailLine({ label, value, classNameValue }: DetailLineProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
|
||||
function InfoChip({ label, value, icon, tone = "default" }: { label: string; value: string; icon?: ReactNode; tone?: "default" | "warning" | "muted" }) {
|
||||
const toneClasses =
|
||||
tone === "warning"
|
||||
? "border-amber-200 bg-amber-50 text-amber-700"
|
||||
: tone === "muted"
|
||||
? "border-slate-200 bg-slate-50 text-neutral-600"
|
||||
: "border-slate-200 bg-white text-neutral-800"
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 rounded-xl border px-3 py-2 shadow-sm", toneClasses)}>
|
||||
{icon ? <span className="text-neutral-500">{icon}</span> : null}
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-xs uppercase text-neutral-500">{label}</p>
|
||||
<p className="truncate text-sm font-semibold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function clampPercent(raw: number): number {
|
||||
if (!Number.isFinite(raw)) return 0
|
||||
const normalized = raw > 1 && raw <= 100 ? raw : raw <= 1 ? raw * 100 : raw
|
||||
return Math.max(0, Math.min(100, normalized))
|
||||
}
|
||||
|
||||
function deriveUsageMetrics({
|
||||
metrics,
|
||||
hardware,
|
||||
disks,
|
||||
}: {
|
||||
metrics: MachineMetrics
|
||||
hardware?: MachineInventory["hardware"]
|
||||
disks?: MachineInventory["disks"]
|
||||
}) {
|
||||
const data = (metrics ?? {}) as Record<string, unknown>
|
||||
// Compat: aceitar chaves do agente desktop (cpuUsagePercent, memoryUsedBytes, memoryTotalBytes)
|
||||
const cpu = (() => {
|
||||
const v = Number(
|
||||
data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? data.cpuUsagePercent ?? NaN
|
||||
)
|
||||
return v
|
||||
})()
|
||||
const memory = (() => {
|
||||
// valor absoluto em bytes, se disponível
|
||||
const memBytes = Number(
|
||||
data.memoryBytes ?? data.memory ?? data.memory_used ?? data.memoryUsedBytes ?? NaN
|
||||
)
|
||||
if (Number.isFinite(memBytes)) return memBytes
|
||||
// tentar derivar a partir de percentuais do agente
|
||||
const usedPct = Number(data.memoryUsedPercent ?? NaN)
|
||||
const totalBytes = Number(data.memoryTotalBytes ?? NaN)
|
||||
if (Number.isFinite(usedPct) && Number.isFinite(totalBytes)) {
|
||||
return Math.max(0, Math.min(1, usedPct > 1 ? usedPct / 100 : usedPct)) * totalBytes
|
||||
|
||||
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
|
||||
}
|
||||
return NaN
|
||||
})()
|
||||
const disk = Number(data.diskUsage ?? data.disk ?? NaN)
|
||||
const gpuUsage = Number(
|
||||
data.gpuUsage ?? data.gpu ?? data.gpuUsagePercent ?? data.gpu_percent ?? NaN
|
||||
const numeric = Number(candidate)
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
memoryTotalBytes = numeric
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const usedCandidates = [
|
||||
data.memoryUsedBytes,
|
||||
data.memoryBytes,
|
||||
data.memory_used,
|
||||
data.memory,
|
||||
]
|
||||
let memoryUsedBytes: number | null = null
|
||||
for (const candidate of usedCandidates) {
|
||||
const parsed = parseBytesLike(candidate)
|
||||
if (parsed !== undefined && Number.isFinite(parsed)) {
|
||||
memoryUsedBytes = parsed
|
||||
break
|
||||
}
|
||||
const numeric = Number(candidate)
|
||||
if (Number.isFinite(numeric)) {
|
||||
memoryUsedBytes = numeric
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const memoryPercentRaw = Number(data.memoryUsedPercent ?? data.memory_percent ?? NaN)
|
||||
let memoryPercent = Number.isFinite(memoryPercentRaw) ? clampPercent(memoryPercentRaw) : null
|
||||
if (memoryTotalBytes && memoryUsedBytes === null && memoryPercent !== null) {
|
||||
memoryUsedBytes = (memoryPercent / 100) * memoryTotalBytes
|
||||
} else if (memoryTotalBytes && memoryUsedBytes !== null) {
|
||||
memoryPercent = clampPercent((memoryUsedBytes / memoryTotalBytes) * 100)
|
||||
}
|
||||
|
||||
let diskTotalBytes: number | null = null
|
||||
let diskUsedBytes: number | null = null
|
||||
let diskPercent: number | null = null
|
||||
if (Array.isArray(disks) && disks.length > 0) {
|
||||
let total = 0
|
||||
let available = 0
|
||||
disks.forEach((disk) => {
|
||||
const totalParsed = parseBytesLike(disk?.totalBytes)
|
||||
if (typeof totalParsed === "number" && Number.isFinite(totalParsed) && totalParsed > 0) {
|
||||
total += totalParsed
|
||||
}
|
||||
const availableParsed = parseBytesLike(disk?.availableBytes)
|
||||
if (typeof availableParsed === "number" && Number.isFinite(availableParsed) && availableParsed >= 0) {
|
||||
available += availableParsed
|
||||
}
|
||||
})
|
||||
if (total > 0) {
|
||||
diskTotalBytes = total
|
||||
const used = Math.max(0, total - available)
|
||||
diskUsedBytes = used
|
||||
diskPercent = clampPercent((used / total) * 100)
|
||||
}
|
||||
}
|
||||
if (diskPercent === null) {
|
||||
const diskMetric = Number(
|
||||
data.diskUsage ?? data.disk ?? data.diskUsedPercent ?? data.storageUsedPercent ?? NaN
|
||||
)
|
||||
if (Number.isFinite(diskMetric)) {
|
||||
diskPercent = clampPercent(diskMetric)
|
||||
}
|
||||
}
|
||||
|
||||
const gpuMetric = Number(
|
||||
data.gpuUsagePercent ?? data.gpuUsage ?? data.gpu_percent ?? data.gpu ?? NaN
|
||||
)
|
||||
const gpuPercent = Number.isFinite(gpuMetric) ? clampPercent(gpuMetric) : null
|
||||
|
||||
return {
|
||||
cpuPercent,
|
||||
memoryUsedBytes,
|
||||
memoryTotalBytes,
|
||||
memoryPercent,
|
||||
diskPercent,
|
||||
diskUsedBytes,
|
||||
diskTotalBytes,
|
||||
gpuPercent,
|
||||
}
|
||||
}
|
||||
|
||||
function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; hardware?: MachineInventory["hardware"]; disks?: MachineInventory["disks"] }) {
|
||||
const derived = useMemo(
|
||||
() => deriveUsageMetrics({ metrics, hardware, disks }),
|
||||
[metrics, hardware, disks]
|
||||
)
|
||||
|
||||
const cards: Array<{ label: string; value: string }> = [
|
||||
{ label: "CPU", value: formatPercent(cpu) },
|
||||
{ label: "Memória", value: formatBytes(memory) },
|
||||
{ label: "Disco", value: Number.isNaN(disk) ? "—" : formatPercent(disk) },
|
||||
]
|
||||
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 (!Number.isNaN(gpuUsage)) {
|
||||
cards.push({ label: "GPU", value: formatPercent(gpuUsage) })
|
||||
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-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-md border border-slate-200 bg-slate-50/60 p-3 text-sm text-muted-foreground">
|
||||
<p className="text-xs uppercase text-slate-500">{card.label}</p>
|
||||
<p className="text-sm font-semibold text-foreground">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
<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-20 w-20">
|
||||
<ChartContainer
|
||||
config={{ usage: { label: card.label, color: card.color } }}
|
||||
className="h-20 w-20 aspect-square"
|
||||
>
|
||||
<RadialBarChart
|
||||
data={[{ name: card.label, value: percentValue }]}
|
||||
innerRadius="55%"
|
||||
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-0 flex items-center justify-center text-sm font-semibold text-neutral-800">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue