ui(machines): integrate pulsating dot inside status badge with spacing; add breadcrumbs; Defender badges; Linux lsblk table; search by company name via Convex; refine card heights
This commit is contained in:
parent
f89424c168
commit
ea46514da5
8 changed files with 432 additions and 38 deletions
|
|
@ -5,7 +5,7 @@ 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 } from "lucide-react"
|
||||
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
|
@ -27,6 +27,7 @@ import { Separator } from "@/components/ui/separator"
|
|||
import { cn } from "@/lib/utils"
|
||||
import Link from "next/link"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
||||
type MachineMetrics = Record<string, unknown> | null
|
||||
|
||||
|
|
@ -219,6 +220,16 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
const [osFilter, setOsFilter] = useState<string>("all")
|
||||
const [companyQuery, setCompanyQuery] = useState<string>("")
|
||||
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
||||
const { convexUserId } = useAuth()
|
||||
const companies = useQuery(
|
||||
convexUserId ? api.companies.list : "skip",
|
||||
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : ("skip" as const)
|
||||
) as Array<{ id: string; name: string; slug?: string }> | undefined
|
||||
const companyNameBySlug = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
;(companies ?? []).forEach((c) => c.slug && map.set(c.slug, c.name))
|
||||
return map
|
||||
}, [companies])
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
|
|
@ -226,11 +237,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
return Array.from(set).sort()
|
||||
}, [machines])
|
||||
|
||||
const companyOptions = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
machines.forEach((m) => m.companySlug && set.add(m.companySlug))
|
||||
return Array.from(set).sort()
|
||||
}, [machines])
|
||||
const companyNameOptions = useMemo(() => (companies ?? []).map((c) => c.name).sort((a,b)=>a.localeCompare(b,"pt-BR")), [companies])
|
||||
|
||||
const filteredMachines = useMemo(() => {
|
||||
const text = q.trim().toLowerCase()
|
||||
|
|
@ -242,8 +249,8 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
}
|
||||
if (osFilter !== "all" && (m.osName ?? "").toLowerCase() !== osFilter.toLowerCase()) return false
|
||||
if (companyQuery && companyQuery.trim().length > 0) {
|
||||
const slug = (m.companySlug ?? "").toLowerCase()
|
||||
if (!slug.includes(companyQuery.trim().toLowerCase())) return false
|
||||
const name = companyNameBySlug.get(m.companySlug ?? "")?.toLowerCase() ?? ""
|
||||
if (!name.includes(companyQuery.trim().toLowerCase())) return false
|
||||
}
|
||||
if (!text) return true
|
||||
const hay = [
|
||||
|
|
@ -296,12 +303,12 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
<Input
|
||||
value={companyQuery}
|
||||
onChange={(e) => setCompanyQuery(e.target.value)}
|
||||
placeholder="Buscar empresa (slug)"
|
||||
placeholder="Buscar empresa"
|
||||
className="min-w-[220px]"
|
||||
/>
|
||||
{companyQuery && companyOptions.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase())).slice(0,6).length > 0 ? (
|
||||
{companyQuery && companyNameOptions.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
|
||||
{companyNameOptions
|
||||
.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase()))
|
||||
.slice(0, 8)
|
||||
.map((c) => (
|
||||
|
|
@ -336,7 +343,40 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
|||
|
||||
function MachineStatusBadge({ status }: { status?: string | null }) {
|
||||
const { label, className } = getStatusVariant(status)
|
||||
return <Badge className={cn("border", className)}>{label}</Badge>
|
||||
const s = String(status ?? "").toLowerCase()
|
||||
const colorClass =
|
||||
s === "online"
|
||||
? "bg-emerald-500"
|
||||
: s === "offline"
|
||||
? "bg-rose-500"
|
||||
: s === "maintenance"
|
||||
? "bg-amber-500"
|
||||
: s === "blocked"
|
||||
? "bg-orange-500"
|
||||
: "bg-slate-400"
|
||||
const ringClass =
|
||||
s === "online"
|
||||
? "bg-emerald-400/30"
|
||||
: s === "offline"
|
||||
? "bg-rose-400/30"
|
||||
: s === "maintenance"
|
||||
? "bg-amber-400/30"
|
||||
: s === "blocked"
|
||||
? "bg-orange-400/30"
|
||||
: "bg-slate-300/30"
|
||||
|
||||
const isOnline = s === "online"
|
||||
return (
|
||||
<Badge className={cn("inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold", className)}>
|
||||
<span className="relative inline-flex items-center">
|
||||
<span className={cn("size-2 rounded-full", colorClass)} />
|
||||
{isOnline ? (
|
||||
<span className={cn("absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping", ringClass)} />
|
||||
) : null}
|
||||
</span>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
|
|
@ -499,7 +539,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">{machine.hostname}</p>
|
||||
<h1 className="break-words text-2xl font-semibold text-neutral-900">{machine.hostname}</h1>
|
||||
<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>
|
||||
|
|
@ -755,6 +795,39 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
{/* Linux */}
|
||||
{linuxExt ? (
|
||||
<div className="space-y-3">
|
||||
{Array.isArray((linuxExt as any).lsblk) && (linuxExt as any).lsblk.length > 0 ? (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">Montagens (lsblk)</p>
|
||||
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-200 bg-slate-100/80">
|
||||
<TableHead className="text-xs text-slate-500">Nome</TableHead>
|
||||
<TableHead className="text-xs text-slate-500">Ponto de montagem</TableHead>
|
||||
<TableHead className="text-xs text-slate-500">FS</TableHead>
|
||||
<TableHead className="text-xs text-slate-500">Tamanho</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{((linuxExt as any).lsblk as Array<Record<string, unknown>>).slice(0, 18).map((entry, idx) => {
|
||||
const name = typeof entry["name"] === "string" ? (entry["name"] as string) : "—"
|
||||
const mp = typeof entry["mountPoint"] === "string" ? (entry["mountPoint"] as string) : typeof entry["mountpoint"] === "string" ? (entry["mountpoint"] as string) : "—"
|
||||
const fs = typeof entry["fs"] === "string" ? (entry["fs"] as string) : typeof entry["fstype"] === "string" ? (entry["fstype"] as string) : "—"
|
||||
const sizeRaw = typeof entry["sizeBytes"] === "number" ? (entry["sizeBytes"] as number) : typeof entry["size"] === "number" ? (entry["size"] as number) : undefined
|
||||
return (
|
||||
<TableRow key={`lsblk-${idx}`} className="border-slate-100">
|
||||
<TableCell className="text-sm text-foreground">{name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{mp || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{fs || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{typeof sizeRaw === "number" ? formatBytes(sizeRaw) : "—"}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{Array.isArray(linuxExt.smart) && linuxExt.smart.length > 0 ? (
|
||||
<div className="rounded-md border border-slate-200 bg-emerald-50/40 p-3 dark:bg-emerald-900/10">
|
||||
<p className="text-xs font-semibold uppercase text-slate-500">SMART</p>
|
||||
|
|
@ -995,9 +1068,25 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
{windowsExt.defender ? (
|
||||
<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="Antivírus" value={fmtBool(readBool(windowsExt.defender, "AntivirusEnabled"))} />
|
||||
<DetailLine label="Proteção em tempo real" value={fmtBool(readBool(windowsExt.defender, "RealTimeProtectionEnabled"))} />
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
{readBool(windowsExt.defender, "AntivirusEnabled") === true ? (
|
||||
<Badge className="gap-1 border-emerald-500/20 bg-emerald-500/15 text-emerald-700">
|
||||
<ShieldCheck className="size-3" /> Antivírus: Ativo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1 border-rose-500/20 bg-rose-500/15 text-rose-700">
|
||||
<ShieldAlert className="size-3" /> Antivírus: Inativo
|
||||
</Badge>
|
||||
)}
|
||||
{readBool(windowsExt.defender, "RealTimeProtectionEnabled") === true ? (
|
||||
<Badge className="gap-1 border-emerald-500/20 bg-emerald-500/15 text-emerald-700">
|
||||
<ShieldCheck className="size-3" /> Proteção em tempo real: Ativa
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1 border-rose-500/20 bg-rose-500/15 text-rose-700">
|
||||
<ShieldAlert className="size-3" /> Proteção em tempo real: Inativa
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1165,7 +1254,7 @@ 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">
|
||||
<Card className="relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
|
||||
<div className="absolute right-2 top-2">
|
||||
<span
|
||||
aria-hidden
|
||||
|
|
@ -1190,7 +1279,7 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
|||
</CardTitle>
|
||||
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<CardContent className="flex grow flex-col gap-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
|
||||
{machine.osName ?? "SO"} {machine.osVersion ?? ""}
|
||||
|
|
@ -1218,7 +1307,7 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HardDrive className="size-3.5 text-slate-500" />
|
||||
{Array.isArray(machine.inventory?.disks) ? `${machine.inventory?.disks?.length ?? 0} discos` : "—"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue