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

@ -10,6 +10,7 @@
"tauri": "tauri"
},
"dependencies": {
"@radix-ui/react-tabs": "^1.1.13",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2",

View file

@ -490,6 +490,22 @@ fn collect_windows_extended() -> serde_json::Value {
let defender = ps("Get-MpComputerStatus | Select-Object AMRunningMode,AntivirusEnabled,RealTimeProtectionEnabled,AntispywareEnabled").unwrap_or_else(|| json!({}));
let hotfix = ps("Get-HotFix | Select-Object HotFixID,InstalledOn").unwrap_or_else(|| json!([]));
// Informações de build/edição e ativação
let os_info = ps(r#"
$cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion';
$ls = (Get-CimInstance -Query "SELECT LicenseStatus FROM SoftwareLicensingProduct WHERE PartialProductKey IS NOT NULL" | Select-Object -First 1).LicenseStatus;
[PSCustomObject]@{
ProductName = $cv.ProductName
CurrentBuild = $cv.CurrentBuild
CurrentBuildNumber = $cv.CurrentBuildNumber
DisplayVersion = $cv.DisplayVersion
ReleaseId = $cv.ReleaseId
EditionID = $cv.EditionID
LicenseStatus = $ls
IsActivated = ($ls -eq 1)
}
"#).unwrap_or_else(|| json!({}));
// Hardware detalhado (CPU/Board/BIOS/Memória/Vídeo/Discos)
let cpu = ps("Get-CimInstance Win32_Processor | Select-Object Name,Manufacturer,SocketDesignation,NumberOfCores,NumberOfLogicalProcessors,L2CacheSize,L3CacheSize,MaxClockSpeed").unwrap_or_else(|| json!({}));
let baseboard = ps("Get-CimInstance Win32_BaseBoard | Select-Object Product,Manufacturer,SerialNumber,Version").unwrap_or_else(|| json!({}));
@ -504,6 +520,7 @@ fn collect_windows_extended() -> serde_json::Value {
"services": services,
"defender": defender,
"hotfix": hotfix,
"osInfo": os_info,
"cpu": cpu,
"baseboard": baseboard,
"bios": bios,

View file

@ -0,0 +1,36 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "../../lib/utils"
export function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />
}
export function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn("inline-flex h-9 w-fit items-center justify-center rounded-lg bg-slate-100 p-[3px] text-slate-600", className)}
{...props}
/>
)
}
export function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-slate-800 transition-[color,box-shadow] data-[state=active]:bg-white data-[state=active]:shadow-sm",
className
)}
{...props}
/>
)
}
export function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />
}

View file

@ -12,3 +12,50 @@ body {
@apply bg-slate-50 text-slate-900;
}
.badge-status {
@apply inline-flex h-8 items-center gap-3 rounded-full border border-slate-200 px-3 text-sm font-semibold;
}
.card {
@apply w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm;
}
.btn {
@apply inline-flex items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition;
}
.btn-primary {
@apply border-black bg-black text-white hover:bg-black/90;
}
.btn-outline {
@apply border-slate-300 bg-white text-slate-800 hover:bg-slate-50;
}
.input {
@apply w-full rounded-lg border border-slate-300 px-3 py-2 text-sm;
}
.label {
@apply text-sm font-medium;
}
.tabs {
@apply mt-4 flex flex-col gap-3;
}
.tab-list {
@apply flex flex-wrap gap-2 border-b border-slate-200 pb-2;
}
.tab-btn {
@apply rounded-lg border border-transparent bg-transparent px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-100;
}
.tab-btn.active {
@apply border-slate-300 bg-slate-100 text-slate-900;
}
.stat-card {
@apply flex items-center gap-3 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-2;
}

View file

@ -0,0 +1,4 @@
export function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ")
}

View file

@ -1,758 +0,0 @@
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
import { openUrl } from "@tauri-apps/plugin-opener"
type MachineOs = {
name: string
version?: string | null
architecture?: string | null
}
type MachineMetrics = {
collectedAt: string
cpuLogicalCores: number
cpuPhysicalCores?: number | null
cpuUsagePercent: number
loadAverageOne?: number | null
loadAverageFive?: number | null
loadAverageFifteen?: number | null
memoryTotalBytes: number
memoryUsedBytes: number
memoryUsedPercent: number
uptimeSeconds: number
}
type MachineInventory = {
cpuBrand?: string | null
hostIdentifier?: string | null
}
type MachineProfile = {
hostname: string
os: MachineOs
macAddresses: string[]
serialNumbers: string[]
inventory: MachineInventory
metrics: MachineMetrics
}
type MachineRegisterResponse = {
machineId: string
tenantId?: string | null
companyId?: string | null
companySlug?: string | null
machineToken: string
machineEmail?: string | null
expiresAt?: number | null
}
type AgentConfig = {
machineId: string
tenantId?: string | null
companySlug?: string | null
machineEmail?: string | null
apiBaseUrl: string
appUrl: string
createdAt: number
lastSyncedAt?: number | null
expiresAt?: number | null
heartbeatIntervalSec?: number | null
}
declare global {
interface ImportMetaEnv {
readonly VITE_APP_URL?: string
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
}
const STORE_FILENAME = "machine-agent.json"
// Defaults: em produção, apontamos para o domínio público; em dev, localhost
const DEFAULT_APP_URL =
import.meta.env.MODE === "production"
? "https://tickets.esdrasrenan.com.br"
: "http://localhost:3000"
function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
const trimmed = (value ?? fallback).trim()
if (!trimmed.startsWith("http")) {
return fallback
}
return trimmed.replace(/\/+$/, "")
}
const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL)
const apiBaseUrl = normalizeUrl(
import.meta.env.VITE_API_BASE_URL,
appUrl
)
const alertElement = document.getElementById("alert-container") as HTMLDivElement | null
const contentElement = document.getElementById("content") as HTMLDivElement | null
const statusElement = document.getElementById("status-text") as HTMLParagraphElement | null
function setAlert(message: string | null, variant: "info" | "error" | "success" = "info") {
if (!alertElement) return
if (!message) {
alertElement.textContent = ""
alertElement.className = "alert"
return
}
alertElement.textContent = message
const extra = variant === "info" ? "" : ` ${variant}`
alertElement.className = `alert visible${extra}`
}
function setStatus(message: string) {
if (statusElement) {
statusElement.textContent = message
}
}
let storeInstance: Store | null = null
async function ensureStoreLoaded(): Promise<Store> {
if (!storeInstance) {
try {
storeInstance = await Store.load(STORE_FILENAME)
} catch (error) {
console.error("[agent] Falha ao carregar store", error)
throw error
}
}
return storeInstance
}
async function loadConfig(): Promise<AgentConfig | null> {
try {
const store = await ensureStoreLoaded()
const record = await store.get<AgentConfig>("config")
if (!record) return null
return record
} catch (error) {
console.error("[agent] Falha ao recuperar configuração", error)
return null
}
}
async function saveConfig(config: AgentConfig) {
const store = await ensureStoreLoaded()
await store.set("config", config)
await store.save()
}
async function clearConfig() {
const store = await ensureStoreLoaded()
await store.delete("config")
await store.save()
await store.delete("token");
await store.save()
}
async function getMachineToken(): Promise<string | null> {
const store = await ensureStoreLoaded()
const token = await store.get<string>("token")
return token ?? null
}
async function setMachineToken(token: string) {
const store = await ensureStoreLoaded()
await store.set("token", token)
await store.save()
}
async function collectMachineProfile(): Promise<MachineProfile> {
return await invoke<MachineProfile>("collect_machine_profile")
}
async function collectMachineInventory(): Promise<Record<string, unknown>> {
return await invoke<Record<string, unknown>>("collect_machine_inventory")
}
async function startHeartbeat(config: AgentConfig) {
const token = await getMachineToken()
if (!token) throw new Error("Token da máquina ausente no cofre seguro")
await invoke("start_machine_agent", {
baseUrl: config.apiBaseUrl,
token,
status: "online",
intervalSeconds: Math.max(60, Number(config.heartbeatIntervalSec ?? 300)),
})
}
async function stopHeartbeat() {
await invoke("stop_machine_agent")
}
function renderTabsShell() {
if (!contentElement) return
contentElement.innerHTML = `
<div class="tabs">
<div class="tab-list">
<button class="active" data-tab="overview">Resumo</button>
<button data-tab="inventory">Inventário</button>
<button data-tab="diagnostics">Diagnóstico</button>
<button data-tab="settings">Configurações</button>
</div>
<section id="tab-overview" class="tab-panel active"></section>
<section id="tab-inventory" class="tab-panel"></section>
<section id="tab-diagnostics" class="tab-panel"></section>
<section id="tab-settings" class="tab-panel"></section>
</div>
`
const buttons = contentElement.querySelectorAll<HTMLButtonElement>(".tab-list button")
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("active"))
btn.classList.add("active")
const tab = btn.getAttribute("data-tab")
const panels = contentElement.querySelectorAll<HTMLElement>(".tab-panel")
panels.forEach((p) => p.classList.remove("active"))
const panel = contentElement.querySelector<HTMLElement>(`#tab-${tab}`)
if (panel) panel.classList.add("active")
})
})
}
function renderOverviewPanel(profile: MachineProfile) {
const panel = document.getElementById("tab-overview")
if (!panel) return
const summary = renderMachineSummary(profile) ?? ""
panel.innerHTML = `
<p>Máquina provisionada e com heartbeat ativo.</p>
${summary}
`
}
function renderInventoryPanel(inv: Record<string, unknown>, profile: MachineProfile) {
const panel = document.getElementById("tab-inventory")
if (!panel) return
const disks = Array.isArray((inv as any).disks) ? ((inv as any).disks as any[]) : []
const network = (inv as any).network
const mem = profile.metrics
const totalDisk = disks.reduce((acc, d) => acc + Number(d?.totalBytes ?? 0), 0)
const disksCount = disks.length
let networkHtml = ""
if (Array.isArray(network)) {
networkHtml = `
<div class="machine-summary">
<div><strong>Rede (interfaces)</strong></div>
${(network as any[])
.map((iface) => `<div>${iface.name ?? "iface"} · ${iface.mac ?? "—"} · ${iface.ip ?? "—"}</div>`)
.join("")}
</div>
`
}
let disksHtml = ""
if (disks.length > 0) {
disksHtml = `
<div class="machine-summary">
<div><strong>Discos e partições</strong></div>
${disks
.map(
(d) =>
`<div>${d.name ?? "disco"} · ${d.mountPoint ?? "—"} · ${d.fs ?? "?"} · ${formatBytes(Number(d.totalBytes))} (${formatBytes(Number(d.availableBytes))} livre)</div>`
)
.join("")}
</div>
`
}
const software: any[] = Array.isArray((inv as any).software) ? ((inv as any).software as any[]) : []
const services: any[] = Array.isArray((inv as any).services) ? ((inv as any).services as any[]) : []
const softwareHtml = software.length
? `<div class="machine-summary"><div><strong>Softwares (amostra)</strong></div>${software
.slice(0, 8)
.map((s, i) => `<div>${s.name ?? s.DisplayName ?? `Software ${i + 1}`} ${s.version ?? s.DisplayVersion ?? ""}</div>`)
.join("")}${software.length > 8 ? `<div class="text-xs">+${software.length - 8} itens</div>` : ""}</div>`
: ""
const servicesHtml = services.length
? `<div class="machine-summary"><div><strong>Serviços (amostra)</strong></div>${services
.slice(0, 8)
.map((svc) => `<div>${svc.name ?? svc.Name ?? "serviço"} · ${svc.status ?? svc.Status ?? "?"}</div>`)
.join("")}${services.length > 8 ? `<div class="text-xs">+${services.length - 8} itens</div>` : ""}</div>`
: ""
panel.innerHTML = `
<div class="cards-grid">
<div class="card-item"><span class="card-icon">🧠</span><div><div class="text-xs">CPU</div><div class="text-sm"><strong>${formatPercent(mem.cpuUsagePercent)}</strong></div></div></div>
<div class="card-item"><span class="card-icon">🧩</span><div><div class="text-xs">Memória</div><div class="text-sm"><strong>${formatBytes(mem.memoryUsedBytes)}</strong> / ${formatBytes(mem.memoryTotalBytes)}</div></div></div>
<div class="card-item"><span class="card-icon">🖥</span><div><div class="text-xs">Sistema</div><div class="text-sm"><strong>${profile.os.name}</strong></div></div></div>
<div class="card-item"><span class="card-icon">💾</span><div><div class="text-xs">Discos</div><div class="text-sm"><strong>${disksCount}</strong> · ${formatBytes(totalDisk)}</div></div></div>
</div>
${networkHtml}
${disksHtml}
${softwareHtml}
${servicesHtml}
<div class="actions"><button id="refresh-inventory" class="secondary">Atualizar inventário</button></div>
`
const refresh = document.getElementById("refresh-inventory")
refresh?.addEventListener("click", async () => {
setStatus("Atualizando inventário…")
try {
const [p, i] = await Promise.all([collectMachineProfile(), collectMachineInventory()])
renderOverviewPanel(p)
renderInventoryPanel(i, p)
renderDiagnosticsPanel(p)
setStatus("Inventário atualizado.")
} catch {
setAlert("Falha ao atualizar inventário.", "error")
}
})
}
function renderDiagnosticsPanel(profile: MachineProfile) {
const panel = document.getElementById("tab-diagnostics")
if (!panel) return
const m = profile.metrics
panel.innerHTML = `
<div class="machine-summary">
<div><strong>CPU</strong> ${formatPercent(m.cpuUsagePercent)}</div>
<div><strong>Memória</strong> ${formatBytes(m.memoryUsedBytes)} / ${formatBytes(m.memoryTotalBytes)} (${formatPercent(m.memoryUsedPercent)})</div>
<div><strong>Uptime</strong> ${m.uptimeSeconds}s</div>
<div><strong>Load</strong> ${[m.loadAverageOne, m.loadAverageFive, m.loadAverageFifteen]
.map((v) => (v ? v.toFixed(2) : "—"))
.join(" / ")}</div>
</div>
<div class="machine-summary">
<div><strong>Histórico (curto)</strong></div>
<canvas id="diag-cpu" width="520" height="100" style="background:rgba(148,163,184,0.15); border-radius:8px;"></canvas>
<canvas id="diag-mem" width="520" height="100" style="background:rgba(148,163,184,0.15); border-radius:8px;"></canvas>
<p class="text-xs">Amostras locais a cada ~3s, mantemos ~60 pontos.</p>
</div>
`
const cpuCanvas = panel.querySelector<HTMLCanvasElement>("#diag-cpu")
const memCanvas = panel.querySelector<HTMLCanvasElement>("#diag-mem")
const cpuData: number[] = []
const memData: number[] = []
function draw(canvas: HTMLCanvasElement, series: number[], maxValue: number, color: string) {
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const w = canvas.width
const h = canvas.height
const n = Math.max(1, series.length)
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * (w - 6) + 3
const v = Math.max(0, Math.min(maxValue, series[i] ?? 0))
const y = h - 4 - (v / maxValue) * (h - 8)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
let stop = false
async function pump() {
if (stop) return
try {
const p = await collectMachineProfile()
cpuData.push(Math.max(0, Math.min(100, p.metrics.cpuUsagePercent)))
const memPct = (p.metrics.memoryUsedPercent)
memData.push(Math.max(0, Math.min(100, memPct)))
while (cpuData.length > 60) cpuData.shift()
while (memData.length > 60) memData.shift()
if (cpuCanvas) draw(cpuCanvas, cpuData, 100, "#2563eb")
if (memCanvas) draw(memCanvas, memData, 100, "#10b981")
} catch {
// ignore
} finally {
setTimeout(pump, 3000)
}
}
pump()
panel.addEventListener("DOMNodeRemoved", () => { stop = true })
}
function renderSettingsPanel(config: AgentConfig) {
const panel = document.getElementById("tab-settings")
if (!panel) return
panel.innerHTML = `
<div class="machine-summary">
<div><strong>Ambiente</strong> ${config.appUrl}</div>
<div><strong>API</strong> ${config.apiBaseUrl}</div>
<div><strong>Criado em</strong> ${formatDate(config.createdAt)}</div>
<div><strong>Última sync</strong> ${formatDate(config.lastSyncedAt ?? null)}</div>
</div>
<div class="actions">
<button id="send-inventory">Enviar inventário agora</button>
<button id="open-app-settings" class="secondary">Abrir sistema</button>
</div>
<div class="actions">
<button id="reset-agent-settings" class="secondary">Reprovisionar</button>
</div>
<div class="machine-summary">
<div><strong>Intervalo do heartbeat (segundos)</strong></div>
<div style="display:flex; gap:8px; align-items:center;">
<input id="hb-interval" type="number" min="60" step="30" value="${String(config.heartbeatIntervalSec ?? 300)}" style="max-width:140px;" />
<button id="save-hb-interval">Salvar</button>
</div>
<p class="text-xs">Mínimo 60s. Salvar reinicia o processo de heartbeat.</p>
</div>
`
document.getElementById("open-app-settings")?.addEventListener("click", () => redirectToApp(config))
document.getElementById("reset-agent-settings")?.addEventListener("click", async () => {
await stopHeartbeat().catch(() => undefined)
await clearConfig()
setAlert("Configuração removida. Reiniciando fluxo de provisionamento.", "success")
setTimeout(() => window.location.reload(), 600)
})
document.getElementById("send-inventory")?.addEventListener("click", async () => {
setStatus("Enviando inventário…")
try {
const token = await getMachineToken()
if (!token) throw new Error("Token ausente no cofre do SO")
const [p, inv] = await Promise.all([collectMachineProfile(), collectMachineInventory()])
const payload = {
machineToken: token,
hostname: p.hostname,
os: p.os,
metrics: p.metrics as any,
inventory: inv,
}
const response = await fetch(`${apiBaseUrl}/api/machines/inventory`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) throw new Error(`Falha HTTP ${response.status}`)
setAlert("Inventário enviado com sucesso.", "success")
setStatus("Inventário sincronizado.")
} catch (error) {
console.error("[agent] Falha ao enviar inventário", error)
setAlert("Falha ao enviar inventário agora.", "error")
}
})
document.getElementById("save-hb-interval")?.addEventListener("click", async () => {
try {
const input = document.getElementById("hb-interval") as HTMLInputElement | null
const value = Math.max(60, Number(input?.value ?? 300))
const updated: AgentConfig = { ...config, heartbeatIntervalSec: value, lastSyncedAt: Date.now() }
await saveConfig(updated)
await stopHeartbeat().catch(() => undefined)
await startHeartbeat(updated)
setAlert("Intervalo do heartbeat atualizado.", "success")
} catch (error) {
console.error("[agent] Falha ao salvar intervalo", error)
setAlert("Falha ao salvar intervalo do heartbeat.", "error")
}
})
}
function formatBytes(bytes: number) {
if (!bytes || Number.isNaN(bytes)) return "—"
const units = ["B", "KB", "MB", "GB", "TB"]
let value = bytes
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
}
function formatPercent(value: number) {
if (Number.isNaN(value)) return "—"
return `${value.toFixed(1)}%`
}
function formatDate(timestamp?: number | null) {
if (!timestamp) return "—"
try {
return new Date(timestamp).toLocaleString()
} catch {
return "—"
}
}
function renderMachineSummary(profile: MachineProfile) {
if (!contentElement) return
const macs = profile.macAddresses.length > 0 ? profile.macAddresses.join(", ") : "—"
const serials = profile.serialNumbers.length > 0 ? profile.serialNumbers.join(", ") : "—"
const metrics = profile.metrics
const lastCollection = metrics.collectedAt ? new Date(metrics.collectedAt).toLocaleString() : "—"
return `
<div class="machine-summary">
<div><strong>Hostname:</strong> ${profile.hostname}</div>
<div><strong>Sistema:</strong> ${profile.os.name}${profile.os.version ? ` ${profile.os.version}` : ""} (${profile.os.architecture ?? "?"})</div>
<div><strong>Endereços MAC:</strong> ${macs}</div>
<div><strong>Identificadores:</strong> ${serials}</div>
<div><strong>CPU:</strong> ${metrics.cpuPhysicalCores ?? metrics.cpuLogicalCores} núcleos · uso ${formatPercent(metrics.cpuUsagePercent)}</div>
<div><strong>Memória:</strong> ${formatBytes(metrics.memoryUsedBytes)} / ${formatBytes(metrics.memoryTotalBytes)} (${formatPercent(metrics.memoryUsedPercent)})</div>
<div><strong>Coletado em:</strong> ${lastCollection}</div>
</div>
`
}
async function renderRegistered(config: AgentConfig) {
if (!contentElement) return
renderTabsShell()
try {
const [profile, inv] = await Promise.all([collectMachineProfile(), collectMachineInventory()])
renderOverviewPanel(profile)
renderInventoryPanel(inv, profile)
renderDiagnosticsPanel(profile)
renderSettingsPanel(config)
setStatus("Máquina provisionada.")
} catch (error) {
console.error("[agent] Falha ao preparar as abas", error)
setAlert("Não foi possível preparar a interface de inventário.", "error")
}
}
function renderProvisionForm(profile: MachineProfile) {
if (!contentElement) return
const summary = renderMachineSummary(profile) ?? ""
contentElement.innerHTML = `
<form id="provision-form">
<div class="field">
<label class="label">Código de provisionamento</label>
<div class="input-group">
<input id="provisioningSecret" type="password" name="provisioningSecret" placeholder="Insira o código fornecido" required autocomplete="one-time-code" />
<button type="button" id="toggle-secret" class="icon-button" aria-label="Mostrar/ocultar">
<svg class="icon-eye" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5C7 5 2.73 8.11 1 12c1.73 3.89 6 7 11 7s9.27-3.11 11-7c-1.73-3.89-6-7-11-7Z" stroke="currentColor" stroke-width="1.8"/>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/>
</svg>
</button>
</div>
<span class="optional">Segredo fornecido pelo servidor</span>
</div>
<div class="field">
<label class="label">Tenant (opcional)</label>
<span class="optional">Use apenas se houver múltiplos ambientes</span>
<input type="text" name="tenantId" placeholder="Ex.: tenant-atlas" />
</div>
<div class="field">
<label class="label">Empresa (slug opcional)</label>
<span class="optional">Informe para vincular à empresa correta</span>
<input type="text" name="companySlug" placeholder="Ex.: empresa-exemplo" />
</div>
<div class="actions">
<button type="submit">Registrar máquina</button>
<button type="button" class="secondary" id="refresh-profile">Atualizar dados</button>
</div>
</form>
${summary}
`
const form = document.getElementById("provision-form") as HTMLFormElement | null
const refreshButton = document.getElementById("refresh-profile")
form?.addEventListener("submit", (event) => {
event.preventDefault()
handleRegister(profile, form)
})
refreshButton?.addEventListener("click", async () => {
setStatus("Recolhendo informações atualizadas da máquina…")
try {
const updatedProfile = await collectMachineProfile()
renderProvisionForm(updatedProfile)
setStatus("Dados atualizados. Revise e confirme o provisionamento.")
} catch (error) {
console.error("[agent] Falha ao atualizar perfil da máquina", error)
setAlert("Não foi possível atualizar as informações da máquina.", "error")
}
})
// Olhinho: mostrar/ocultar segredo
const toggleBtn = document.getElementById("toggle-secret") as HTMLButtonElement | null
const secretInput = document.getElementById("provisioningSecret") as HTMLInputElement | null
toggleBtn?.addEventListener("click", () => {
if (!secretInput) return
const isPw = secretInput.type === "password"
secretInput.type = isPw ? "text" : "password"
// alterna ícone
toggleBtn.innerHTML = isPw
? '<svg class="icon-eye-off" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 3l18 18" stroke="currentColor" stroke-width="1.8"/><path d="M10.58 10.58A3 3 0 0012 15a3 3 0 001.42-.38M9.9 4.24A10.94 10.94 0 0112 4c5 0 9.27 3.11 11 7a11.65 11.65 0 01-4.31 5.22M6.35 6.35C4 7.76 2.27 9.7 1 12c1.73 3.89 6 7 11 7 1.53 0 2.99-.29 4.31-.82" stroke="currentColor" stroke-width="1.8"/></svg>'
: '<svg class="icon-eye" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 5C7 5 2.73 8.11 1 12c1.73 3.89 6 7 11 7s9.27-3.11 11-7c-1.73-3.89-6-7-11-7Z" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.8"/></svg>'
})
}
async function handleRegister(profile: MachineProfile, form: HTMLFormElement) {
const submitButton = form.querySelector("button[type=submit]") as HTMLButtonElement | null
const formData = new FormData(form)
const provisioningSecret = (formData.get("provisioningSecret") as string | null)?.trim()
const tenantId = (formData.get("tenantId") as string | null)?.trim()
const companySlug = (formData.get("companySlug") as string | null)?.trim()
if (!provisioningSecret) {
setAlert("Informe o código de provisionamento.", "error")
return
}
try {
if (submitButton) {
submitButton.disabled = true
}
setAlert(null)
setStatus("Enviando dados de registro da máquina…")
const payload = {
provisioningSecret,
tenantId: tenantId && tenantId.length > 0 ? tenantId : undefined,
companySlug: companySlug && companySlug.length > 0 ? companySlug : undefined,
hostname: profile.hostname,
os: profile.os,
macAddresses: profile.macAddresses,
serialNumbers: profile.serialNumbers,
metadata: {
inventory: profile.inventory,
metrics: profile.metrics,
},
registeredBy: "desktop-agent",
}
const response = await fetch(`${apiBaseUrl}/api/machines/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) {
const status = response.status
let message = `Falha ao registrar máquina (${status})`
try {
// tenta JSON
const ct = response.headers.get("content-type") || ""
if (ct.includes("application/json")) {
const errorBody = await response.json()
if (errorBody?.error) message = `${message}: ${String(errorBody.error)}`
if (errorBody?.details) message += `${String(errorBody.details)}`
} else {
const text = await response.text()
if (text && text.length < 500) message += `${text}`
}
} catch {
// ignore
}
throw new Error(message)
}
const data = (await response.json()) as MachineRegisterResponse
// Guarda token localmente (Store). Em produção, podemos trocar por keyring do SO.
await setMachineToken(data.machineToken)
const config: AgentConfig = {
machineId: data.machineId,
tenantId: data.tenantId ?? null,
companySlug: data.companySlug ?? null,
machineEmail: data.machineEmail ?? null,
apiBaseUrl,
appUrl,
createdAt: Date.now(),
lastSyncedAt: Date.now(),
expiresAt: data.expiresAt ?? null,
}
await saveConfig(config)
await startHeartbeat(config)
setAlert("Máquina registrada com sucesso! Abrindo a interface web…", "success")
setStatus("Autenticando dispositivo e abrindo o Sistema de Chamados.")
setTimeout(() => redirectToApp(config), 800)
} catch (error) {
console.error("[agent] Erro no registro da máquina", error)
const message = error instanceof Error ? error.message : "Erro desconhecido ao registrar a máquina."
const normalized = message.toLowerCase()
if (normalized.includes("failed to fetch") || normalized.includes("load failed") || normalized.includes("network")) {
setAlert("Não foi possível se conectar ao servidor. Verifique a conexão e o endereço configurado.", "error")
} else if (normalized.includes("401") || normalized.includes("403") || normalized.includes("código de provisionamento inválido")) {
setAlert("Código de provisionamento inválido. Confirme o segredo configurado no servidor e tente novamente.", "error")
} else {
setAlert(message, "error")
}
setStatus("Revise os dados e tente novamente.")
if (submitButton) {
submitButton.disabled = false
}
}
}
function redirectToApp(config: AgentConfig) {
const perform = async () => {
const token = await getMachineToken()
if (!token) {
setAlert("Token da máquina não encontrado. Reprovisione a máquina.", "error")
return
}
const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(token)}`
try {
await openUrl(url)
setAlert("Abrindo o Sistema de Chamados no navegador padrão…", "success")
} catch {
// fallback: navegar dentro da WebView
window.location.replace(url)
}
}
void perform()
}
async function ensureHeartbeat(config: AgentConfig): Promise<AgentConfig> {
const adjustedConfig = {
...config,
apiBaseUrl,
appUrl,
lastSyncedAt: Date.now(),
}
await saveConfig(adjustedConfig)
await startHeartbeat(adjustedConfig)
return adjustedConfig
}
async function bootstrap() {
setStatus("Iniciando agente desktop…")
setAlert(null)
try {
const stored = await loadConfig()
const token = await getMachineToken()
if (stored && token) {
const updated = await ensureHeartbeat(stored)
await renderRegistered(updated)
return
}
} catch (error) {
console.error("[agent] Falha ao iniciar com configuração existente", error)
setAlert("Não foi possível carregar a configuração armazenada. Você poderá reprovisionar abaixo.", "error")
}
try {
setStatus("Coletando informações básicas da máquina…")
const profile = await collectMachineProfile()
renderProvisionForm(profile)
setStatus("Informe o código de provisionamento para registrar esta máquina.")
} catch (error) {
console.error("[agent] Falha ao coletar dados da máquina", error)
setAlert("Não foi possível coletar dados da máquina. Verifique permissões do sistema e tente novamente.", "error")
setStatus("Interação necessária para continuar.")
}
}
document.addEventListener("DOMContentLoaded", () => {
void bootstrap()
})

View file

@ -1,8 +1,10 @@
import React, { useEffect, useMemo, useState } from "react"
import { useEffect, useState } from "react"
import { createRoot } from "react-dom/client"
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
import { ExternalLink, Eye, EyeOff } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils"
type MachineOs = {
name: string
@ -117,6 +119,7 @@ function App() {
const [profile, setProfile] = useState<MachineProfile | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const [showSecret, setShowSecret] = useState(false)
const [provisioningSecret, setProvisioningSecret] = useState("")
@ -138,6 +141,7 @@ function App() {
const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p)
}
setStatus(t ? "online" : null)
} catch (err) {
setError("Falha ao carregar estado do agente.")
}
@ -182,6 +186,7 @@ function App() {
await writeConfig(store, cfg)
setConfig(cfg); setToken(data.machineToken)
await invoke("start_machine_agent", { baseUrl: apiBaseUrl, token: data.machineToken, status: "online", intervalSeconds: 300 })
setStatus("online")
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@ -204,15 +209,45 @@ function App() {
async function reprovision() {
if (!store) return
await store.delete("token"); await store.delete("config"); await store.save()
setToken(null); setConfig(null)
setToken(null); setConfig(null); setStatus(null)
const p = await invoke<MachineProfile>("collect_machine_profile")
setProfile(p)
}
async function sendInventoryNow() {
if (!token || !profile) return
setBusy(true); setError(null)
try {
const payload = {
machineToken: token,
hostname: profile.hostname,
os: profile.os,
metrics: profile.metrics,
inventory: profile.inventory,
}
const res = await fetch(`${apiBaseUrl}/api/machines/inventory`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Falha ao enviar inventário (${res.status}): ${text.slice(0, 200)}`)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen grid place-items-center p-6">
<div className="w-full max-w-[560px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-xl font-semibold">Sistema de Chamados Agente Desktop</h1>
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex items-start justify-between gap-3">
<h1 className="text-xl font-semibold">Sistema de Chamados Agente Desktop</h1>
<StatusBadge status={status} />
</div>
{error ? <p className="mt-3 rounded-md bg-rose-50 p-2 text-sm text-rose-700">{error}</p> : null}
{!token ? (
<div className="mt-4 space-y-3">
@ -267,14 +302,66 @@ function App() {
</div>
</div>
) : (
<div className="mt-4 space-y-3">
<p className="text-sm text-slate-700">Máquina provisionada.</p>
<div className="grid gap-2">
<button onClick={openSystem} className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white hover:bg-black/90">
<ExternalLink className="size-4"/> Abrir sistema
</button>
<button onClick={reprovision} className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50">Reprovisionar</button>
</div>
<div className="mt-4">
<Tabs defaultValue="resumo" className="w-full">
<TabsList className="h-10">
<TabsTrigger value="resumo" className="rounded-lg px-3">Resumo</TabsTrigger>
<TabsTrigger value="inventario" className="rounded-lg px-3">Inventário</TabsTrigger>
<TabsTrigger value="diagnostico" className="rounded-lg px-3">Diagnóstico</TabsTrigger>
<TabsTrigger value="config" className="rounded-lg px-3">Configurações</TabsTrigger>
</TabsList>
<TabsContent value="resumo" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="stat-card">
<div className="text-xs text-slate-500">CPU</div>
<div className="text-sm font-semibold text-slate-900">{profile ? pct(profile.metrics.cpuUsagePercent) : "—"}</div>
</div>
<div className="stat-card">
<div className="text-xs text-slate-500">Memória</div>
<div className="text-sm font-semibold text-slate-900">{profile ? `${bytes(profile.metrics.memoryUsedBytes)} / ${bytes(profile.metrics.memoryTotalBytes)}` : "—"}</div>
</div>
</div>
<div className="mt-4 flex gap-2">
<button onClick={openSystem} className="btn btn-primary inline-flex items-center gap-2">
<ExternalLink className="size-4"/> Abrir sistema
</button>
<button onClick={reprovision} className="btn btn-outline">Reprovisionar</button>
</div>
</TabsContent>
<TabsContent value="inventario" className="mt-4 space-y-3">
<p className="text-sm text-slate-600">Inventário básico coletado localmente. Envie para sincronizar com o servidor.</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="card">
<div className="text-xs text-slate-500">Hostname</div>
<div className="text-sm font-semibold text-slate-900">{profile?.hostname ?? "—"}</div>
</div>
<div className="card">
<div className="text-xs text-slate-500">Sistema</div>
<div className="text-sm font-semibold text-slate-900">{profile?.os?.name ?? "—"} {profile?.os?.version ?? ""}</div>
</div>
</div>
<div className="flex gap-2">
<button disabled={busy} onClick={sendInventoryNow} className={cn("btn btn-primary", busy && "opacity-60")}>Enviar inventário agora</button>
<button onClick={openSystem} className="btn btn-outline">Ver no sistema</button>
</div>
</TabsContent>
<TabsContent value="diagnostico" className="mt-4 space-y-2">
<div className="card">
<p className="text-sm text-slate-700">Token armazenado: <span className="font-mono break-all text-xs">{token?.slice(0, 6)}{token?.slice(-6)}</span></p>
<p className="text-sm text-slate-700">Base URL: <span className="font-mono text-xs">{apiBaseUrl}</span></p>
</div>
</TabsContent>
<TabsContent value="config" className="mt-4 space-y-3">
<div className="grid gap-2">
<label className="label">E-mail do colaborador (opcional)</label>
<input className="input" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} />
</div>
<div className="grid gap-2">
<label className="label">Nome do colaborador (opcional)</label>
<input className="input" placeholder="Nome completo" value={collabName} onChange={(e)=>setCollabName(e.target.value)} />
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
@ -282,6 +369,22 @@ function App() {
)
}
function StatusBadge({ status }: { status: string | null }) {
const s = (status ?? "").toLowerCase()
const label = s === "online" ? "Online" : s === "offline" ? "Offline" : s === "maintenance" ? "Manutenção" : "Sem status"
const dot = s === "online" ? "bg-emerald-500" : s === "offline" ? "bg-rose-500" : s === "maintenance" ? "bg-amber-500" : "bg-slate-400"
const ring = s === "online" ? "bg-emerald-400/30" : s === "offline" ? "bg-rose-400/30" : s === "maintenance" ? "bg-amber-400/30" : "bg-slate-300/30"
const isOnline = s === "online"
return (
<span className="inline-flex h-8 items-center gap-4 rounded-full border border-slate-200 px-3 text-sm font-semibold">
<span className="relative inline-flex items-center">
<span className={cn("size-2 rounded-full", dot)} />
{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 [animation-duration:2s]", ring)} /> : null}
</span>
{label}
</span>
)
}
const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })()
createRoot(root).render(<App />)

View file

@ -1,266 +0,0 @@
:root {
color-scheme: light; /* força tema claro para máxima legibilidade */
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.5;
background-color: #f8fafc; /* slate-50 */
color: #0f172a; /* slate-900 */
margin: 0;
}
body {
margin: 0;
}
.app-root {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: radial-gradient(circle at top, rgba(59, 130, 246, 0.12), transparent 60%),
radial-gradient(circle at bottom, rgba(16, 185, 129, 0.12), transparent 55%);
}
.card {
width: min(520px, 100%);
background-color: #ffffff;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
padding: 28px;
}
.card header h1 {
margin: 0;
font-size: 1.75rem;
}
.subtitle {
margin: 4px 0 0;
color: #475569;
font-size: 0.95rem;
}
.alert {
margin-top: 16px;
font-size: 0.95rem;
color: #0f172a;
background-color: #e0f2fe;
border-radius: 12px;
padding: 12px 14px;
display: none;
}
.alert.visible {
display: block;
}
.alert.error {
background-color: #fee2e2;
color: #b91c1c;
}
.alert.success {
background-color: #dcfce7;
color: #166534;
}
form {
display: grid;
gap: 12px;
margin-top: 18px;
}
label {
display: block;
font-weight: 600;
color: #0f172a;
}
label span.optional {
font-weight: 400;
color: #64748b;
font-size: 0.85rem;
}
.field { display: grid; gap: 6px; }
.label { font-size: 0.9rem; }
.input-group { position: relative; }
.input-group input { width: 100%; padding-right: 40px; }
.icon-button {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px; height: 28px;
border-radius: 6px;
border: 1px solid transparent;
background: #f1f5f9;
color: #334155;
cursor: pointer;
}
.icon-button:hover { background: #e2e8f0; }
.icon-eye, .icon-eye-off { width: 18px; height: 18px; }
input,
select {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #cbd5e1; /* slate-300 */
font-size: 1rem;
background-color: #ffffff;
color: #0f172a;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
select:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
button {
padding: 12px 16px;
border-radius: 12px;
border: none;
background-color: #2563eb;
color: #ffffff;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
button.secondary {
background: none;
color: #2563eb;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #1d4ed8;
transform: translateY(-1px);
}
.machine-summary {
margin-top: 18px;
padding: 14px;
border-radius: 12px;
background-color: rgba(15, 23, 42, 0.05);
display: grid;
gap: 8px;
font-size: 0.95rem;
}
.machine-summary strong {
font-weight: 600;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-top: 20px;
}
.actions button {
flex: 1;
}
.status-text {
margin-top: 16px;
font-size: 0.95rem;
color: #334155;
}
/* Tabs (desktop app shell) */
.tabs {
margin-top: 18px;
}
.tab-list {
display: flex;
gap: 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.4);
padding-bottom: 8px;
}
.tab-list button {
background: none;
color: #334155;
border: 1px solid transparent;
border-bottom: 2px solid transparent;
padding: 8px 12px;
border-radius: 10px 10px 0 0;
}
.tab-list button.active {
background-color: rgba(241, 245, 249, 0.8);
border-color: rgba(148, 163, 184, 0.6);
border-bottom-color: #2563eb;
color: #0f172a;
}
.tab-panel {
display: none;
padding-top: 12px;
}
.tab-panel.active {
display: block;
}
/* Summary cards */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-top: 8px;
}
.card-item {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid rgba(148, 163, 184, 0.5);
background-color: rgba(248, 250, 252, 0.85);
padding: 10px 12px;
border-radius: 12px;
}
.card-icon {
font-size: 18px;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 3px solid rgba(37, 99, 235, 0.14);
border-top-color: #2563eb;
animation: spin 0.8s linear infinite;
display: inline-block;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-color-scheme: dark) {
/* forçamos visual claro também em modo escuro do sistema */
:root { background-color: #f8fafc; color: #0f172a; }
.card { background-color: #ffffff; color: #0f172a; box-shadow: 0 10px 30px rgba(15,23,42,0.12); }
.subtitle { color: #475569; }
.alert { background-color: #e0f2fe; color: #0f172a; }
.alert.error { background-color: #fee2e2; color: #b91c1c; }
.alert.success { background-color: #dcfce7; color: #166534; }
input, select { background-color: #ffffff; border-color: #cbd5e1; }
button.secondary { color: #2563eb; }
.machine-summary { background-color: rgba(15, 23, 42, 0.05); }
.status-text { color: #334155; }
}

View file

@ -17,7 +17,9 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}

356
pnpm-lock.yaml generated
View file

@ -216,6 +216,9 @@ importers:
apps/desktop:
dependencies:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tauri-apps/api':
specifier: ^2
version: 2.8.0
@ -225,10 +228,22 @@ importers:
'@tauri-apps/plugin-store':
specifier: ^2
version: 2.4.0
lucide-react:
specifier: ^0.544.0
version: 0.544.0(react@19.2.0)
react:
specifier: ^19.0.0
version: 19.2.0
react-dom:
specifier: ^19.0.0
version: 19.2.0(react@19.2.0)
devDependencies:
'@tauri-apps/cli':
specifier: ^2
version: 2.8.4
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.3.6(@types/node@20.19.19)(jiti@2.6.1)(lightningcss@1.30.1))
typescript:
specifier: ~5.6.2
version: 5.6.3
@ -242,10 +257,93 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.28.4':
resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==}
engines: {node: '>=6.9.0'}
'@babel/core@7.28.4':
resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.28.3':
resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.27.2':
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-imports@7.27.1':
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.28.3':
resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-plugin-utils@7.27.1':
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.28.4':
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.28.4':
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-transform-react-jsx-self@7.27.1':
resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-react-jsx-source@7.27.1':
resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.28.4':
resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.4':
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
engines: {node: '>=6.9.0'}
'@better-auth/core@1.3.26':
resolution: {integrity: sha512-S5ooXaOcn9eLV3/JayfbMsAB5PkfoTRaRrtpb5djwvI/UAJOgLyjqhd+rObsBycovQ/nPQvMKjzyM/G1oBKngA==}
@ -1443,6 +1541,9 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rollup/rollup-android-arm-eabi@4.52.4':
resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==}
cpu: [arm]
@ -1919,6 +2020,18 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
'@types/babel__generator@7.27.0':
resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
'@types/babel__template@7.4.4':
resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@ -2157,6 +2270,12 @@ packages:
cpu: [x64]
os: [win32]
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@ -2286,6 +2405,10 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.8.16:
resolution: {integrity: sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==}
hasBin: true
better-auth@1.3.26:
resolution: {integrity: sha512-umaOGmv29yF4sD6o2zlW6B0Oayko5yD/A8mKJOFDDEIoVP/pR7nJ/2KsqKy+xvBpnDsKdrZseqA8fmczPviUHw==}
peerDependencies:
@ -2331,6 +2454,11 @@ packages:
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserslist@4.26.3:
resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@ -2419,6 +2547,9 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
convex@1.27.3:
resolution: {integrity: sha512-Ebr9lPgXkL7JO5IFr3bG+gYvHskyJjc96Fx0BBNkJUDXrR/bd9/uI4q8QszbglK75XfDu068vR0d/HK2T7tB9Q==}
engines: {node: '>=18.0.0', npm: '>=7.0.0'}
@ -2600,6 +2731,9 @@ packages:
effect@3.16.12:
resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==}
electron-to-chromium@1.5.234:
resolution: {integrity: sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@ -2660,6 +2794,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@ -2878,6 +3016,10 @@ packages:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@ -3123,6 +3265,11 @@ packages:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@ -3136,6 +3283,11 @@ packages:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
hasBin: true
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@ -3252,6 +3404,9 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.544.0:
resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==}
peerDependencies:
@ -3350,6 +3505,9 @@ packages:
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-releases@2.0.23:
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
nypm@0.6.2:
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
engines: {node: ^14.16.0 || >=16.10.0}
@ -3602,6 +3760,10 @@ packages:
peerDependencies:
react: ^19.0.0
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@ -4012,6 +4174,12 @@ packages:
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -4183,6 +4351,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
@ -4216,8 +4387,120 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.28.4': {}
'@babel/core@7.28.4':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.3
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4)
'@babel/helpers': 7.28.4
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/generator@7.28.3':
dependencies:
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-compilation-targets@7.27.2':
dependencies:
'@babel/compat-data': 7.28.4
'@babel/helper-validator-option': 7.27.1
browserslist: 4.26.3
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-globals@7.28.0': {}
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/traverse': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/helper-plugin-utils@7.27.1': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helpers@7.28.4':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.28.4
'@babel/parser@7.28.4':
dependencies:
'@babel/types': 7.28.4
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/runtime@7.28.4': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@babel/traverse@7.28.4':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.3
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/types': 7.28.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@babel/types@7.28.4':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@better-auth/core@1.3.26':
dependencies:
better-call: 1.0.19
@ -5283,6 +5566,8 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.52.4':
optional: true
@ -5704,6 +5989,27 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.28.4
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.28.4
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@ -5939,6 +6245,18 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vitejs/plugin-react@4.7.0(vite@6.3.6(@types/node@20.19.19)(jiti@2.6.1)(lightningcss@1.30.1))':
dependencies:
'@babel/core': 7.28.4
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4)
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.3.6(@types/node@20.19.19)(jiti@2.6.1)(lightningcss@1.30.1)
transitivePeerDependencies:
- supports-color
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
@ -6099,6 +6417,8 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.8.16: {}
better-auth@1.3.26(next@15.5.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@better-auth/core': 1.3.26
@ -6144,6 +6464,14 @@ snapshots:
dependencies:
base64-js: 1.5.1
browserslist@4.26.3:
dependencies:
baseline-browser-mapping: 2.8.16
caniuse-lite: 1.0.30001747
electron-to-chromium: 1.5.234
node-releases: 2.0.23
update-browserslist-db: 1.1.3(browserslist@4.26.3)
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@ -6234,6 +6562,8 @@ snapshots:
consola@3.4.2: {}
convert-source-map@2.0.0: {}
convex@1.27.3(react@19.2.0):
dependencies:
esbuild: 0.25.4
@ -6394,6 +6724,8 @@ snapshots:
'@standard-schema/spec': 1.0.0
fast-check: 3.23.2
electron-to-chromium@1.5.234: {}
emoji-regex@9.2.2: {}
empathic@2.0.0: {}
@ -6562,6 +6894,8 @@ snapshots:
'@esbuild/win32-ia32': 0.25.4
'@esbuild/win32-x64': 0.25.4
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
eslint-config-next@15.5.4(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3):
@ -6863,6 +7197,8 @@ snapshots:
generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -7119,6 +7455,8 @@ snapshots:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
@ -7129,6 +7467,8 @@ snapshots:
dependencies:
minimist: 1.2.8
json5@2.2.3: {}
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
@ -7225,6 +7565,10 @@ snapshots:
loupe@3.2.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
lucide-react@0.544.0(react@19.2.0):
dependencies:
react: 19.2.0
@ -7311,6 +7655,8 @@ snapshots:
node-fetch-native@1.6.7: {}
node-releases@2.0.23: {}
nypm@0.6.2:
dependencies:
citty: 0.1.6
@ -7605,6 +7951,8 @@ snapshots:
react: 19.2.0
scheduler: 0.25.0
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.0)(react@19.2.0):
dependencies:
react: 19.2.0
@ -8124,6 +8472,12 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
update-browserslist-db@1.1.3(browserslist@4.26.3):
dependencies:
browserslist: 4.26.3
escalade: 3.2.0
picocolors: 1.1.1
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@ -8304,6 +8658,8 @@ snapshots:
word-wrap@1.2.5: {}
yallist@3.1.1: {}
yallist@5.0.0: {}
yocto-queue@0.1.0: {}

View file

@ -1,40 +1,22 @@
"use client"
import Link from "next/link"
import { use, useMemo } from "react"
import { useQuery } from "convex/react"
import type { Id } from "@/convex/_generated/dataModel"
import { use } from "react"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { AdminMachineDetailsClient } from "@/components/admin/machines/admin-machine-details.client"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcrumbs.client"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default function AdminMachineDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const { convexUserId } = useAuth()
const machines = useQuery(
convexUserId ? api.machines.listByTenant : "skip",
convexUserId ? { tenantId: DEFAULT_TENANT_ID, includeMetadata: false } : ("skip" as const)
) as Array<{ _id: Id<"machines">; hostname: string }> | undefined
const hostname = useMemo(() => machines?.find((m) => m._id === (id as unknown as Id<"machines">))?.hostname ?? "Hostname", [machines, id])
return (
<AppShell header={<SiteHeader title="Detalhe da máquina" lead="Inventário e métricas da máquina selecionada." />}>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<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>
<MachineBreadcrumbs tenantId={DEFAULT_TENANT_ID} machineId={id} />
<AdminMachineDetailsClient tenantId={DEFAULT_TENANT_ID} machineId={id} />
</div>
</AppShell>

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>
)
}