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:
Esdras Renan 2025-10-10 10:26:35 -03:00
parent 124bb2a26f
commit 129407dbce
2 changed files with 151 additions and 34 deletions

View file

@ -650,3 +650,37 @@ export const linkAuthAccount = mutation({
return { ok: true } return { ok: true }
}, },
}) })
export const rename = mutation({
args: {
machineId: v.id("machines"),
actorId: v.id("users"),
tenantId: v.optional(v.string()),
hostname: v.string(),
},
handler: async (ctx, { machineId, actorId, tenantId, hostname }) => {
// Reutiliza requireStaff através de tickets.ts helpers
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
// Verifica permissão no tenant da máquina
const viewer = await ctx.db.get(actorId)
if (!viewer || viewer.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
}
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
}
const nextName = hostname.trim()
if (nextName.length < 2) {
throw new ConvexError("Informe um nome válido para a máquina")
}
await ctx.db.patch(machineId, { hostname: nextName, updatedAt: Date.now() })
return { ok: true }
},
})

View file

@ -2,12 +2,14 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useQuery } from "convex/react" import { useQuery } from "convex/react"
import { useMutation } from "convex/react"
import { format, formatDistanceToNowStrict } from "date-fns" import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { toast } from "sonner" 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 { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -26,6 +28,7 @@ import {
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import Link from "next/link" import Link from "next/link"
import { useAuth } from "@/lib/auth-client"
type MachineMetrics = Record<string, unknown> | null type MachineMetrics = Record<string, unknown> | null
@ -190,6 +193,12 @@ function formatPercent(value?: number | null) {
return `${normalized.toFixed(0)}%` 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) { function getStatusVariant(status?: string | null) {
if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown } if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown }
const normalized = status.toLowerCase() const normalized = status.toLowerCase()
@ -204,7 +213,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all") const [statusFilter, setStatusFilter] = useState<string>("all")
const [osFilter, setOsFilter] = 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 [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
const osOptions = useMemo(() => { const osOptions = useMemo(() => {
@ -228,7 +237,10 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
if (s !== statusFilter) return false if (s !== statusFilter) return false
} }
if (osFilter !== "all" && (m.osName ?? "").toLowerCase() !== osFilter.toLowerCase()) 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 if (!text) return true
const hay = [ const hay = [
m.hostname, m.hostname,
@ -276,22 +288,36 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={companyFilter} onValueChange={setCompanyFilter}> <div className="relative">
<SelectTrigger className="min-w-40"> <Input
<SelectValue placeholder="Empresa" /> value={companyQuery}
</SelectTrigger> onChange={(e) => setCompanyQuery(e.target.value)}
<SelectContent> placeholder="Buscar empresa (slug)"
<SelectItem value="all">Todas empresas</SelectItem> className="min-w-[220px]"
{companyOptions.map((c) => ( />
<SelectItem key={c} value={c}>{c}</SelectItem> {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">
</SelectContent> {companyOptions
</Select> .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"> <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))} /> <Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span> <span>Somente com alertas</span>
</label> </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> </div>
{machines.length === 0 ? ( {machines.length === 0 ? (
<EmptyState /> <EmptyState />
@ -328,6 +354,7 @@ type MachineDetailsProps = {
} }
export function MachineDetails({ machine }: MachineDetailsProps) { export function MachineDetails({ machine }: MachineDetailsProps) {
const { convexUserId } = useAuth()
const metadata = machine?.inventory ?? null const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null const metrics = machine?.metrics ?? null
const hardware = metadata?.hardware ?? 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 [openDialog, setOpenDialog] = useState(false)
const [dialogQuery, setDialogQuery] = useState("") const [dialogQuery, setDialogQuery] = useState("")
const jsonText = useMemo(() => { const jsonText = useMemo(() => {
@ -451,7 +482,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="space-y-1"> <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"> <p className="text-xs text-muted-foreground">
{machine.authEmail ?? "E-mail não definido"} {machine.authEmail ?? "E-mail não definido"}
</p> </p>
@ -463,6 +500,12 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div> </div>
<MachineStatusBadge status={machine.status} /> <MachineStatusBadge status={machine.status} />
</div> </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"> <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"> <Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
{machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""} {machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""}
@ -484,6 +527,41 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div> </div>
</section> </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"> <section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4> <h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground"> <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"> <CardContent className="flex items-center gap-3 py-3">
<Cpu className="size-5 text-slate-500" /> <Cpu className="size-5 text-slate-500" />
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-xs text-muted-foreground">CPU</p> <p className="text-xs text-muted-foreground">CPU</p>
<p className="truncate text-sm font-semibold text-foreground">{String((winCpu as any)?.Name ?? "—")}</p> <p className="break-words text-sm font-semibold text-foreground">{String((winCpu as any)?.Name ?? "—")}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -739,7 +817,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
{Array.isArray(windowsExt.cpu) ? ( {Array.isArray(windowsExt.cpu) ? (
(windowsExt.cpu as Array<Record<string, unknown>>).slice(0,1).map((c, i) => ( (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"> <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="Fabricante" value={String(c?.["Manufacturer"] ?? "—")} />
<DetailLine label="Socket" value={String(c?.["SocketDesignation"] ?? "—")} /> <DetailLine label="Socket" value={String(c?.["SocketDesignation"] ?? "—")} />
<DetailLine label="Núcleos" value={String(c?.["NumberOfCores"] ?? "—")} /> <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"> <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> <p className="text-xs font-semibold uppercase text-slate-500">Defender</p>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm"> <div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<DetailLine label="Antivirus" value={String(windowsExt.defender?.AntivirusEnabled ?? "—")} /> <DetailLine label="Antivírus" value={fmtBool((windowsExt.defender as any)?.AntivirusEnabled)} />
<DetailLine label="Tempo real" value={String(windowsExt.defender?.RealTimeProtectionEnabled ?? "—")} /> <DetailLine label="Proteção em tempo real" value={fmtBool((windowsExt.defender as any)?.RealTimeProtectionEnabled)} />
</div> </div>
</div> </div>
) : null} ) : null}
@ -1054,19 +1132,24 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
return ( return (
<Link href={`/admin/machines/${machine.id}`} className="group"> <Link href={`/admin/machines/${machine.id}`} className="group">
<Card className="relative overflow-hidden border-slate-200 transition-colors hover:border-slate-300"> <Card className="relative overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
<span <div className="absolute right-2 top-2">
aria-hidden <span
className={cn( aria-hidden
"absolute right-2 top-2 size-2 rounded-full border border-white", className={cn(
className.includes("emerald") "relative block size-2 rounded-full",
? "bg-emerald-500" className.includes("emerald")
: className.includes("rose") ? "bg-emerald-500"
? "bg-rose-500" : className.includes("rose")
: className.includes("amber") ? "bg-rose-500"
? "bg-amber-500" : className.includes("amber")
: "bg-slate-400" ? "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"> <CardHeader className="pb-2">
<CardTitle className="line-clamp-1 flex items-center gap-2 text-base font-semibold"> <CardTitle className="line-clamp-1 flex items-center gap-2 text-base font-semibold">
<Monitor className="size-4 text-slate-500" /> {machine.hostname} <Monitor className="size-4 text-slate-500" /> {machine.hostname}