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
|
|
@ -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 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
{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>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
) : 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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm font-semibold text-foreground">{machine.hostname}</p>
|
<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,10 +1132,11 @@ 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">
|
||||||
|
<div className="absolute right-2 top-2">
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 top-2 size-2 rounded-full border border-white",
|
"relative block size-2 rounded-full",
|
||||||
className.includes("emerald")
|
className.includes("emerald")
|
||||||
? "bg-emerald-500"
|
? "bg-emerald-500"
|
||||||
: className.includes("rose")
|
: className.includes("rose")
|
||||||
|
|
@ -1067,6 +1146,10 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
||||||
: "bg-slate-400"
|
: "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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue