feat(desktop): migra abas do Tauri para shadcn/Radix Tabs, adiciona status badge e botão 'Enviar inventário agora'\n\nfix(web): corrige tipo do DetailLine (classNameValue) para build no CI\n\nchore(prisma): padroniza fluxo local DEV com DATABASE_URL=file:./prisma/db.dev.sqlite (db push + seed)\n\nchore: atualiza pnpm-lock.yaml após dependências do desktop
This commit is contained in:
parent
ce4b935e0c
commit
e3d6fea412
13 changed files with 683 additions and 1118 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
36
apps/desktop/src/components/ui/tabs.tsx
Normal file
36
apps/desktop/src/components/ui/tabs.tsx
Normal 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} />
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
4
apps/desktop/src/lib/utils.ts
Normal file
4
apps/desktop/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function cn(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -17,7 +17,9 @@
|
|||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue