feat: habilitar provisionamento desktop e rotas CORS

This commit is contained in:
Esdras Renan 2025-10-08 23:07:49 -03:00
parent 7569986ffc
commit 152550a9a0
19 changed files with 1806 additions and 211 deletions

View file

@ -1,6 +1,67 @@
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
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
machineToken: string
tenantId?: string | null
companySlug?: string | null
machineEmail?: string | null
apiBaseUrl: string
appUrl: string
createdAt: number
lastSyncedAt?: number | null
expiresAt?: number | null
}
declare global {
interface ImportMetaEnv {
readonly VITE_APP_URL?: string
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
@ -8,23 +69,370 @@ declare global {
}
}
const DEFAULT_URL = "http://localhost:3000";
const STORE_FILENAME = "machine-agent.json"
const DEFAULT_APP_URL = "http://localhost:3000"
function resolveTargetUrl() {
const fromEnv = import.meta?.env?.VITE_APP_URL;
if (fromEnv && fromEnv.trim().length > 0) {
return fromEnv.trim();
function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
const trimmed = (value ?? fallback).trim()
if (!trimmed.startsWith("http")) {
return fallback
}
return DEFAULT_URL;
return trimmed.replace(/\/+$/, "")
}
function bootstrap() {
const targetUrl = resolveTargetUrl();
if (!targetUrl.startsWith("http")) {
console.error("URL inválida para o app desktop:", targetUrl);
return;
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
}
window.location.replace(targetUrl);
alertElement.textContent = message
const extra = variant === "info" ? "" : ` ${variant}`
alertElement.className = `alert visible${extra}`
}
document.addEventListener("DOMContentLoaded", bootstrap);
function setStatus(message: string) {
if (statusElement) {
statusElement.textContent = message
}
}
const store = new Store(STORE_FILENAME)
let storeLoaded = false
async function ensureStoreLoaded() {
if (!storeLoaded) {
try {
await store.load()
} catch (error) {
console.error("[agent] Falha ao carregar store", error)
}
storeLoaded = true
}
}
async function loadConfig(): Promise<AgentConfig | null> {
try {
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) {
await ensureStoreLoaded()
await store.set("config", config)
await store.save()
}
async function clearConfig() {
await ensureStoreLoaded()
await store.delete("config")
await store.save()
}
async function collectMachineProfile(): Promise<MachineProfile> {
return await invoke<MachineProfile>("collect_machine_profile")
}
async function startHeartbeat(config: AgentConfig) {
await invoke("start_machine_agent", {
baseUrl: config.apiBaseUrl,
token: config.machineToken,
status: "online",
intervalSeconds: 300,
})
}
async function stopHeartbeat() {
await invoke("stop_machine_agent")
}
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>
`
}
function renderRegistered(config: AgentConfig) {
if (!contentElement) return
const summaryHtml = `
<div class="machine-summary">
<div><strong>ID da máquina:</strong> ${config.machineId}</div>
<div><strong>Email vinculado:</strong> ${config.machineEmail ?? "—"}</div>
<div><strong>Tenant:</strong> ${config.tenantId ?? "padrão"}</div>
<div><strong>Empresa:</strong> ${config.companySlug ?? "não vinculada"}</div>
<div><strong>Token expira em:</strong> ${formatDate(config.expiresAt)}</div>
<div><strong>Última sincronização:</strong> ${formatDate(config.lastSyncedAt)}</div>
<div><strong>Ambiente:</strong> ${config.appUrl}</div>
</div>
`
contentElement.innerHTML = `
<p>Esta máquina está provisionada e com heartbeat ativo.</p>
${summaryHtml}
<div class="actions">
<button id="open-app">Abrir sistema</button>
<button class="secondary" id="reset-agent">Reprovisionar</button>
</div>
`
const openButton = document.getElementById("open-app")
const resetButton = document.getElementById("reset-agent")
openButton?.addEventListener("click", () => redirectToApp(config))
resetButton?.addEventListener("click", async () => {
await stopHeartbeat().catch(() => undefined)
await clearConfig()
setAlert("Configuração removida. Reiniciando fluxo de provisionamento.", "success")
setTimeout(() => window.location.reload(), 600)
})
setStatus("Máquina provisionada. Redirecionando para a interface web…")
setTimeout(() => redirectToApp(config), 1500)
}
function renderProvisionForm(profile: MachineProfile) {
if (!contentElement) return
const summary = renderMachineSummary(profile) ?? ""
contentElement.innerHTML = `
<form id="provision-form">
<label>
Código de provisionamento
<input type="password" name="provisioningSecret" placeholder="Insira o código fornecido" required autocomplete="one-time-code" />
</label>
<label>
Tenant (opcional)
<span class="optional">Use apenas se houver múltiplos ambientes</span>
<input type="text" name="tenantId" placeholder="Ex.: tenant-atlas" />
</label>
<label>
Empresa (slug opcional)
<span class="optional">Informe para vincular à empresa correta</span>
<input type="text" name="companySlug" placeholder="Ex.: empresa-exemplo" />
</label>
<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")
}
})
}
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) {
let message = `Falha ao registrar máquina (${response.status})`
try {
const errorBody = await response.json()
if (errorBody?.error) {
message = errorBody.error
}
} catch {
// ignore
}
throw new Error(message)
}
const data = (await response.json()) as MachineRegisterResponse
const config: AgentConfig = {
machineId: data.machineId,
machineToken: data.machineToken,
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 url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(config.machineToken)}`
window.location.replace(url)
}
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()
if (stored?.machineToken) {
const updated = await ensureHeartbeat(stored)
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,116 +1,235 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.typescript:hover {
filter: drop-shadow(0 0 2em #2d79c7);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
color-scheme: light dark;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
line-height: 1.5;
background-color: #f1f5f9;
color: #0f172a;
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
body {
margin: 0;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
.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%);
}
.row {
display: flex;
justify-content: center;
.card {
width: min(440px, 100%);
background-color: rgba(255, 255, 255, 0.85);
border-radius: 16px;
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.16);
padding: 28px;
backdrop-filter: blur(12px);
}
a {
.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: grid;
gap: 6px;
font-weight: 500;
color: #646cff;
text-decoration: inherit;
color: #0f172a;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
label span.optional {
font-weight: 400;
color: #64748b;
font-size: 0.85rem;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
select {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.6);
font-size: 1rem;
background-color: rgba(241, 245, 249, 0.8);
color: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
input:focus,
select:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
#greet-input {
margin-right: 5px;
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;
}
.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) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
background-color: #0f172a;
color: #e2e8f0;
}
a:hover {
color: #24c8db;
.card {
background-color: rgba(15, 23, 42, 0.75);
color: #e2e8f0;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.4);
}
.subtitle {
color: #94a3b8;
}
.alert {
background-color: rgba(37, 99, 235, 0.16);
color: #bfdbfe;
}
.alert.error {
background-color: rgba(248, 113, 113, 0.2);
color: #fecaca;
}
.alert.success {
background-color: rgba(34, 197, 94, 0.18);
color: #bbf7d0;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
select {
background-color: rgba(15, 23, 42, 0.5);
border-color: rgba(148, 163, 184, 0.35);
}
button:active {
background-color: #0f0f0f69;
input:focus,
select:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.32);
}
button.secondary {
color: #93c5fd;
}
.machine-summary {
background-color: rgba(148, 163, 184, 0.12);
}
.status-text {
color: #cbd5f5;
}
}