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:
parent
ce4b935e0c
commit
e3d6fea412
13 changed files with 683 additions and 1118 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, 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
30
src/components/admin/machines/machine-breadcrumbs.client.tsx
Normal file
30
src/components/admin/machines/machine-breadcrumbs.client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue