feat(desktop): migra abas do Tauri para shadcn/Radix Tabs, adiciona status badge e botão 'Enviar inventário agora'\n\nfix(web): corrige tipo do DetailLine (classNameValue) para build no CI\n\nchore(prisma): padroniza fluxo local DEV com DATABASE_URL=file:./prisma/db.dev.sqlite (db push + seed)\n\nchore: atualiza pnpm-lock.yaml após dependências do desktop

This commit is contained in:
Esdras Renan 2025-10-10 11:56:48 -03:00
parent ce4b935e0c
commit e3d6fea412
13 changed files with 683 additions and 1118 deletions

View file

@ -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, ShieldCheck, ShieldAlert } from "lucide-react"
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
@ -42,6 +42,10 @@ type MachineSoftware = {
source?: string
}
// Forward declaration to ensure props are known before JSX usage
type DetailLineProps = { label: string; value?: string | number | null; classNameValue?: string }
declare function DetailLine(props: DetailLineProps): JSX.Element
type LinuxExtended = {
lsblk?: unknown
lspci?: string
@ -71,6 +75,16 @@ type WindowsExtended = {
}>
videoControllers?: Array<{ Name?: string; AdapterRAM?: number; DriverVersion?: string; PNPDeviceID?: string }>
disks?: Array<{ Model?: string; SerialNumber?: string; Size?: number; InterfaceType?: string; MediaType?: string }>
osInfo?: {
ProductName?: string
CurrentBuild?: string | number
CurrentBuildNumber?: string | number
DisplayVersion?: string
ReleaseId?: string
EditionID?: string
LicenseStatus?: number
IsActivated?: boolean
}
}
type MacExtended = {
@ -213,12 +227,12 @@ function getStatusVariant(status?: string | null) {
}
}
function osIcon(osName?: string | null) {
function OsIcon({ osName }: { osName?: string | null }) {
const name = (osName ?? "").toLowerCase()
if (name.includes("windows")) return "🪟"
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return ""
if (name.includes("linux")) return "🐧"
return "🖥️"
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return <Apple className="size-4 text-black" />
if (name.includes("linux")) return <Terminal className="size-4 text-black" />
// fallback para Windows/outros como monitor genérico
return <Monitor className="size-4 text-black" />
}
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
@ -375,11 +389,11 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
const isOnline = s === "online"
return (
<Badge className={cn("inline-flex h-9 items-center gap-3 rounded-full border border-slate-200 px-3 text-sm font-semibold", className)}>
<Badge className={cn("inline-flex h-9 items-center gap-5 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)} />
<span className={cn("absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]", ringClass)} />
) : null}
</span>
{label}
@ -407,6 +421,11 @@ type MachineDetailsProps = {
export function MachineDetails({ machine }: MachineDetailsProps) {
const { convexUserId } = useAuth()
// Company name lookup (by slug)
const companies = useQuery(
convexUserId && machine ? api.companies.list : "skip",
convexUserId && machine ? { tenantId: machine.tenantId, viewerId: convexUserId as any } : ("skip" as const)
) as Array<{ id: string; name: string; slug?: string }> | undefined
const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null
const hardware = metadata?.hardware ?? null
@ -463,6 +482,22 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
}
// collaborator (from inventory metadata, when provided by onboarding)
type Collaborator = { email?: string; name?: string }
const collaborator: Collaborator | null = (() => {
if (!metadata || typeof metadata !== "object") return null
const inv = metadata as Record<string, unknown>
const c = inv["collaborator"]
if (c && typeof c === "object") return c as Collaborator
return null
})()
const companyName = (() => {
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
const found = companies.find((c) => c.slug === machine.companySlug)
return found?.name ?? machine.companySlug
})()
const [renaming, setRenaming] = useState(false)
const [newName, setNewName] = useState<string>(machine?.hostname ?? "")
@ -489,48 +524,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
return jsonText.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (m) => `__HIGHLIGHT__${m}__END__`)
}, [jsonText, dialogQuery])
const exportInventoryJson = () => {
if (!machine) return
const payload = {
id: machine.id,
hostname: machine.hostname,
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metrics,
inventory: metadata,
postureAlerts: machine.postureAlerts ?? null,
lastPostureAt: machine.lastPostureAt ?? null,
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `inventario-${machine.hostname}.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const copyInventoryJson = async () => {
if (!machine) return
const payload = {
id: machine.id,
hostname: machine.hostname,
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metrics,
inventory: metadata,
postureAlerts: machine.postureAlerts ?? null,
lastPostureAt: machine.lastPostureAt ?? null,
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2))
toast.success("Inventário copiado para a área de transferência.")
} catch {
toast.error("Não foi possível copiar o inventário.")
}
}
// removed copy/export inventory JSON buttons as requested
return (
<Card className="border-slate-200">
@ -567,12 +561,32 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
{/* 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">{osIcon(machine.osName)}</span>
<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>
{windowsExt?.osInfo ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Build: {String((windowsExt.osInfo as any)?.CurrentBuildNumber ?? (windowsExt.osInfo as any)?.CurrentBuild ?? "—")}
</Badge>
) : null}
{windowsExt?.osInfo ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Ativado: {((windowsExt.osInfo as any)?.IsActivated === true) ? "Sim" : "Não"}
</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">
Colaborador: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
</Badge>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{machine.authEmail ? (
@ -581,11 +595,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
Copiar e-mail
</Button>
) : null}
<Button size="sm" onClick={exportInventoryJson} className="gap-2 border border-black bg-black text-white hover:bg-black/90">
Exportar inventário (JSON)
</Button>
{machine.registeredBy ? (
<Badge variant="outline">Registrada via {machine.registeredBy}</Badge>
<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}
</Badge>
) : null}
</div>
</section>
@ -1141,8 +1154,6 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
) : null}
<div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" onClick={copyInventoryJson}>Copiar JSON</Button>
<Button size="sm" onClick={exportInventoryJson}>Exportar JSON</Button>
{Array.isArray(software) && software.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(software, "softwares.csv")}>Softwares CSV</Button>
) : null}
@ -1329,7 +1340,7 @@ function MachineCard({ machine }: { machine: MachinesQueryItem }) {
)
}
function DetailLine({ label, value }: { label: string; value?: string | number | null }) {
function DetailLine({ label, value, classNameValue }: { label: string; value?: string | number | null; classNameValue?: string }) {
if (value === null || value === undefined) return null
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {
return null
@ -1337,7 +1348,7 @@ function DetailLine({ label, value }: { label: string; value?: string | number |
return (
<div className="flex items-center justify-between gap-4">
<span>{label}</span>
<span className="text-right font-medium text-foreground">{value}</span>
<span className={cn("text-right font-medium text-foreground", classNameValue)}>{value}</span>
</div>
)
}

View file

@ -0,0 +1,30 @@
"use client"
import Link from "next/link"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
export function MachineBreadcrumbs({ tenantId, machineId }: { tenantId: string; machineId: string }) {
const { convexUserId } = useAuth()
const list = useQuery(
convexUserId ? api.machines.listByTenant : "skip",
convexUserId ? { tenantId, includeMetadata: false } : ("skip" as const)
) as Array<{ id: Id<"machines">; hostname: string }> | undefined
const hostname = useMemo(() => list?.find((m) => m.id === (machineId as unknown as Id<"machines">))?.hostname ?? "Detalhe", [list, machineId])
return (
<nav className="mb-4 text-sm text-neutral-600">
<ol className="flex items-center gap-2">
<li>
<Link href="/admin/machines" className="underline-offset-4 hover:underline">Máquinas</Link>
</li>
<li className="text-neutral-400">/</li>
<li className="text-neutral-800">{hostname}</li>
</ol>
</nav>
)
}