feat(admin/machines): company search input with suggestions; rename machine dialog using Convex mutation; improve CPU name rendering and Defender booleans; add pulsating online indicator on cards and detail
This commit is contained in:
parent
124bb2a26f
commit
129407dbce
2 changed files with 151 additions and 34 deletions
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { useMutation } 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 } from "lucide-react"
|
||||
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
|
@ -26,6 +28,7 @@ import {
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
||||
type MachineMetrics = Record<string, unknown> | null
|
||||
|
||||
|
|
@ -190,6 +193,12 @@ function formatPercent(value?: number | null) {
|
|||
return `${normalized.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function fmtBool(value: unknown) {
|
||||
if (value === true) return "Sim"
|
||||
if (value === false) return "Não"
|
||||
return "—"
|
||||
}
|
||||
|
||||
function getStatusVariant(status?: string | null) {
|
||||
if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown }
|
||||
const normalized = status.toLowerCase()
|
||||
|
|
@ -204,7 +213,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
const [q, setQ] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [osFilter, setOsFilter] = useState<string>("all")
|
||||
const [companyFilter, setCompanyFilter] = useState<string>("all")
|
||||
const [companyQuery, setCompanyQuery] = useState<string>("")
|
||||
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
|
|
@ -228,7 +237,10 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
if (s !== statusFilter) return false
|
||||
}
|
||||
if (osFilter !== "all" && (m.osName ?? "").toLowerCase() !== osFilter.toLowerCase()) return false
|
||||
if (companyFilter !== "all" && (m.companySlug ?? "") !== companyFilter) return false
|
||||
if (companyQuery && companyQuery.trim().length > 0) {
|
||||
const slug = (m.companySlug ?? "").toLowerCase()
|
||||
if (!slug.includes(companyQuery.trim().toLowerCase())) return false
|
||||
}
|
||||
if (!text) return true
|
||||
const hay = [
|
||||
m.hostname,
|
||||
|
|
@ -276,22 +288,36 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={companyFilter} onValueChange={setCompanyFilter}>
|
||||
<SelectTrigger className="min-w-40">
|
||||
<SelectValue placeholder="Empresa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas empresas</SelectItem>
|
||||
{companyOptions.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={companyQuery}
|
||||
onChange={(e) => setCompanyQuery(e.target.value)}
|
||||
placeholder="Buscar empresa (slug)"
|
||||
className="min-w-[220px]"
|
||||
/>
|
||||
{companyQuery && companyOptions.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase())).slice(0,6).length > 0 ? (
|
||||
<div className="absolute z-10 mt-1 max-h-52 w-full overflow-auto rounded-md border bg-white p-1 shadow-sm">
|
||||
{companyOptions
|
||||
.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase()))
|
||||
.slice(0, 8)
|
||||
.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setCompanyQuery(c)}
|
||||
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-slate-100"
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<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"); setOsFilter("all"); setCompanyFilter("all"); setOnlyAlerts(false) }}>Limpar</Button>
|
||||
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setOsFilter("all"); setCompanyQuery(""); setOnlyAlerts(false) }}>Limpar</Button>
|
||||
</div>
|
||||
{machines.length === 0 ? (
|
||||
<EmptyState />
|
||||
|
|
@ -328,6 +354,7 @@ type MachineDetailsProps = {
|
|||
}
|
||||
|
||||
export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||
const { convexUserId } = useAuth()
|
||||
const metadata = machine?.inventory ?? null
|
||||
const metrics = machine?.metrics ?? null
|
||||
const hardware = metadata?.hardware ?? null
|
||||
|
|
@ -371,6 +398,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const renameMachine = useMutation(api.machines.rename as any)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [newName, setNewName] = useState<string>(machine?.hostname ?? "")
|
||||
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const [dialogQuery, setDialogQuery] = useState("")
|
||||
const jsonText = useMemo(() => {
|
||||
|
|
@ -451,7 +482,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<section className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">{machine.hostname}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">{machine.hostname}</p>
|
||||
<Button size="icon" variant="ghost" className="size-7" onClick={() => { setNewName(machine.hostname); setRenaming(true) }}>
|
||||
<Pencil className="size-4" />
|
||||
<span className="sr-only">Renomear máquina</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{machine.authEmail ?? "E-mail não definido"}
|
||||
</p>
|
||||
|
|
@ -463,6 +500,12 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</div>
|
||||
<MachineStatusBadge status={machine.status} />
|
||||
</div>
|
||||
{String(machine.status ?? "").toLowerCase() === "online" ? (
|
||||
<div className="relative h-2 w-2">
|
||||
<span className="absolute left-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-500" />
|
||||
<span className="absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400/30 animate-ping" />
|
||||
</div>
|
||||
) : 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">
|
||||
{machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""}
|
||||
|
|
@ -484,6 +527,41 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* Renomear máquina */}
|
||||
<Dialog open={renaming} onOpenChange={setRenaming}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Renomear máquina</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Novo hostname" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setRenaming(false)}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!machine || !convexUserId) return
|
||||
const name = (newName ?? "").trim()
|
||||
if (name.length < 2) {
|
||||
toast.error("Informe um nome válido")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await renameMachine({ machineId: machine.id as Id<"machines">, actorId: convexUserId as Id<"users">, hostname: name })
|
||||
toast.success("Máquina renomeada")
|
||||
setRenaming(false)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.error("Falha ao renomear máquina")
|
||||
}
|
||||
}}
|
||||
>
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Sincronização</h4>
|
||||
<div className="grid gap-2 text-sm text-muted-foreground">
|
||||
|
|
@ -700,8 +778,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<Cpu className="size-5 text-slate-500" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs text-muted-foreground">CPU</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">{String((winCpu as any)?.Name ?? "—")}</p>
|
||||
<p className="text-xs text-muted-foreground">CPU</p>
|
||||
<p className="break-words text-sm font-semibold text-foreground">{String((winCpu as any)?.Name ?? "—")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -739,7 +817,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
{Array.isArray(windowsExt.cpu) ? (
|
||||
(windowsExt.cpu as Array<Record<string, unknown>>).slice(0,1).map((c, i) => (
|
||||
<div key={`cpu-${i}`} className="mt-2 grid gap-1 text-sm text-muted-foreground">
|
||||
<DetailLine label="Modelo" value={String(c?.["Name"] ?? "—")} />
|
||||
<DetailLine label="Modelo" value={String(c?.["Name"] ?? "—")} classNameValue="break-words" />
|
||||
<DetailLine label="Fabricante" value={String(c?.["Manufacturer"] ?? "—")} />
|
||||
<DetailLine label="Socket" value={String(c?.["SocketDesignation"] ?? "—")} />
|
||||
<DetailLine label="Núcleos" value={String(c?.["NumberOfCores"] ?? "—")} />
|
||||
|
|
@ -891,8 +969,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">Defender</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
<DetailLine label="Antivirus" value={String(windowsExt.defender?.AntivirusEnabled ?? "—")} />
|
||||
<DetailLine label="Tempo real" value={String(windowsExt.defender?.RealTimeProtectionEnabled ?? "—")} />
|
||||
<DetailLine label="Antivírus" value={fmtBool((windowsExt.defender as any)?.AntivirusEnabled)} />
|
||||
<DetailLine label="Proteção em tempo real" value={fmtBool((windowsExt.defender as any)?.RealTimeProtectionEnabled)} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1054,19 +1132,24 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
|||
return (
|
||||
<Link href={`/admin/machines/${machine.id}`} className="group">
|
||||
<Card className="relative overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute right-2 top-2 size-2 rounded-full border border-white",
|
||||
className.includes("emerald")
|
||||
? "bg-emerald-500"
|
||||
: className.includes("rose")
|
||||
? "bg-rose-500"
|
||||
: className.includes("amber")
|
||||
? "bg-amber-500"
|
||||
: "bg-slate-400"
|
||||
)}
|
||||
/>
|
||||
<div className="absolute right-2 top-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"relative block size-2 rounded-full",
|
||||
className.includes("emerald")
|
||||
? "bg-emerald-500"
|
||||
: className.includes("rose")
|
||||
? "bg-rose-500"
|
||||
: className.includes("amber")
|
||||
? "bg-amber-500"
|
||||
: "bg-slate-400"
|
||||
)}
|
||||
/>
|
||||
{String(machine.status ?? "").toLowerCase() === "online" ? (
|
||||
<span className="absolute left-1/2 top-1/2 -z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400/30 animate-ping" />
|
||||
) : null}
|
||||
</div>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="line-clamp-1 flex items-center gap-2 text-base font-semibold">
|
||||
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue