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"]
}