From ea46514da5153f0c653eb548ef511c5a5854b6ba Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Fri, 10 Oct 2025 11:03:06 -0300 Subject: [PATCH] ui(machines): integrate pulsating dot inside status badge with spacing; add breadcrumbs; Defender badges; Linux lsblk table; search by company name via Convex; refine card heights --- apps/desktop/index.html | 16 +- apps/desktop/package.json | 10 +- apps/desktop/src/index.css | 14 + apps/desktop/src/main.tsx | 287 ++++++++++++++++++ apps/desktop/vite.config.ts | 2 + convex/companies.ts | 3 +- src/app/admin/machines/[id]/page.tsx | 11 +- .../machines/admin-machines-overview.tsx | 127 ++++++-- 8 files changed, 432 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/index.css create mode 100644 apps/desktop/src/main.tsx diff --git a/apps/desktop/index.html b/apps/desktop/index.html index dbb411d..00e916c 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -3,22 +3,12 @@ - + Sistema de Chamados — Agente Desktop - + -
-
-
-

Sistema de Chamados

-

Agente desktop para provisionamento de máquinas

-
-
-
-

-
-
+
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ea65d0b..75bc20b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,11 +12,15 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-store": "^2" + "@tauri-apps/plugin-store": "^2", + "lucide-react": "^0.544.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "devDependencies": { "@tauri-apps/cli": "^2", - "vite": "^6.0.3", - "typescript": "~5.6.2" + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.2", + "vite": "^6.0.3" } } diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css new file mode 100644 index 0000000..e39e270 --- /dev/null +++ b/apps/desktop/src/index.css @@ -0,0 +1,14 @@ +@import "tailwindcss"; + +:root { + color-scheme: light; +} + +html, body, #root { + height: 100%; +} + +body { + @apply bg-slate-50 text-slate-900; +} + diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..1b70305 --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,287 @@ +import React, { useEffect, useMemo, 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" + +type MachineOs = { + name: string + version?: string | null + architecture?: string | null +} + +type MachineMetrics = { + collectedAt: string + cpuLogicalCores: number + cpuPhysicalCores?: number | null + cpuUsagePercent: number + 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" +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) + +async function loadStore(): Promise { + return await Store.load(STORE_FILENAME) +} + +async function readToken(store: Store): Promise { + return (await store.get("token")) ?? null +} + +async function writeToken(store: Store, token: string): Promise { + await store.set("token", token) + await store.save() +} + +async function readConfig(store: Store): Promise { + return (await store.get("config")) ?? null +} + +async function writeConfig(store: Store, cfg: AgentConfig): Promise { + await store.set("config", cfg) + await store.save() +} + +function bytes(n?: number) { + if (!n || !Number.isFinite(n)) return "—" + const u = ["B","KB","MB","GB","TB"] + let v = n; let i = 0 + while (v >= 1024 && i < u.length - 1) { v/=1024; i++ } + return `${v.toFixed(v>=10||i===0?0:1)} ${u[i]}` +} + +function pct(p?: number) { return !p && p !== 0 ? "—" : `${p.toFixed(0)}%` } + +function App() { + const [store, setStore] = useState(null) + const [token, setToken] = useState(null) + const [config, setConfig] = useState(null) + const [profile, setProfile] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [showSecret, setShowSecret] = useState(false) + + const [provisioningSecret, setProvisioningSecret] = useState("") + const [tenantId, setTenantId] = useState("") + const [company, setCompany] = useState("") + const [collabEmail, setCollabEmail] = useState("") + const [collabName, setCollabName] = useState("") + + useEffect(() => { + (async () => { + try { + const s = await loadStore() + setStore(s) + const t = await readToken(s) + setToken(t) + const cfg = await readConfig(s) + setConfig(cfg) + if (!t) { + const p = await invoke("collect_machine_profile") + setProfile(p) + } + } catch (err) { + setError("Falha ao carregar estado do agente.") + } + })() + }, []) + + async function register() { + if (!profile) return + if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return } + setBusy(true); setError(null) + try { + const payload = { + provisioningSecret: provisioningSecret.trim(), + tenantId: tenantId.trim() || undefined, + companySlug: company.trim() || undefined, + hostname: profile.hostname, + os: profile.os, + macAddresses: profile.macAddresses, + serialNumbers: profile.serialNumbers, + metadata: { inventory: profile.inventory, metrics: profile.metrics, collaborator: collabEmail ? { email: collabEmail.trim(), name: collabName.trim() || undefined } : undefined }, + registeredBy: "desktop-agent", + } + const res = await fetch(`${apiBaseUrl}/api/machines/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) + if (!res.ok) { + const text = await res.text() + throw new Error(`Falha no registro (${res.status}): ${text.slice(0,300)}`) + } + const data = (await res.json()) as MachineRegisterResponse + if (!store) throw new Error("Store ausente") + await writeToken(store, data.machineToken) + const cfg: 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 writeConfig(store, cfg) + setConfig(cfg); setToken(data.machineToken) + await invoke("start_machine_agent", { baseUrl: apiBaseUrl, token: data.machineToken, status: "online", intervalSeconds: 300 }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + async function openSystem() { + if (!token || !config) return + const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(token)}` + try { + // open in default browser; fallback to in-webview + const { openUrl } = await import("@tauri-apps/plugin-opener") + await openUrl(url) + } catch { + window.location.replace(url) + } + } + + async function reprovision() { + if (!store) return + await store.delete("token"); await store.delete("config"); await store.save() + setToken(null); setConfig(null) + const p = await invoke("collect_machine_profile") + setProfile(p) + } + + return ( +
+
+

Sistema de Chamados — Agente Desktop

+ {error ?

{error}

: null} + {!token ? ( +
+

Informe os dados para registrar esta máquina.

+
+ +
+ setProvisioningSecret(e.target.value)} /> + +
+
+
+ + setCompany(e.target.value)} /> +
+
+ + setCollabEmail(e.target.value)} /> +
+
+ + setCollabName(e.target.value)} /> +
+
+ + setTenantId(e.target.value)} /> +
+ {profile ? ( +
+
+
Hostname
+
{profile.hostname}
+
+
+
Sistema
+
{profile.os.name}
+
+
+
CPU
+
{pct(profile.metrics.cpuUsagePercent)}
+
+
+
Memória
+
{bytes(profile.metrics.memoryUsedBytes)} / {bytes(profile.metrics.memoryTotalBytes)}
+
+
+ ) : null} +
+ +
+
+ ) : ( +
+

Máquina provisionada.

+
+ + +
+
+ )} +
+
+ ) +} + +const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })() +createRoot(root).render() + diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 73b983f..7b8d65b 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,11 +1,13 @@ // @ts-nocheck import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; const host = process.env.TAURI_DEV_HOST; // https://vite.dev/config/ export default defineConfig(async () => ({ + plugins: [react()], // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // diff --git a/convex/companies.ts b/convex/companies.ts index 07a149a..42c7717 100644 --- a/convex/companies.ts +++ b/convex/companies.ts @@ -10,7 +10,6 @@ export const list = query({ .query("companies") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() - return companies.map((c) => ({ id: c._id, name: c.name })) + return companies.map((c) => ({ id: c._id, name: c.name, slug: c.slug })) }, }) - diff --git a/src/app/admin/machines/[id]/page.tsx b/src/app/admin/machines/[id]/page.tsx index c8f0824..9239f0b 100644 --- a/src/app/admin/machines/[id]/page.tsx +++ b/src/app/admin/machines/[id]/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link" import { AppShell } from "@/components/app-shell" import { SiteHeader } from "@/components/site-header" import { DEFAULT_TENANT_ID } from "@/lib/constants" @@ -13,9 +14,17 @@ export default function AdminMachineDetailsPage({ params }: { params: { id: stri header={} >
+
) } - diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index d2ead0e..2020525 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -5,7 +5,7 @@ import { useQuery } from "convex/react" import { format, formatDistanceToNowStrict } from "date-fns" import { ptBR } from "date-fns/locale" import { toast } from "sonner" -import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil } from "lucide-react" +import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert } from "lucide-react" import { api } from "@/convex/_generated/api" import { Badge } from "@/components/ui/badge" @@ -27,6 +27,7 @@ import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" import Link from "next/link" import { useAuth } from "@/lib/auth-client" +import type { Id } from "@/convex/_generated/dataModel" type MachineMetrics = Record | null @@ -219,6 +220,16 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { const [osFilter, setOsFilter] = useState("all") const [companyQuery, setCompanyQuery] = useState("") const [onlyAlerts, setOnlyAlerts] = useState(false) + const { convexUserId } = useAuth() + const companies = useQuery( + convexUserId ? api.companies.list : "skip", + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : ("skip" as const) + ) as Array<{ id: string; name: string; slug?: string }> | undefined + const companyNameBySlug = useMemo(() => { + const map = new Map() + ;(companies ?? []).forEach((c) => c.slug && map.set(c.slug, c.name)) + return map + }, [companies]) const osOptions = useMemo(() => { const set = new Set() @@ -226,11 +237,7 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { return Array.from(set).sort() }, [machines]) - const companyOptions = useMemo(() => { - const set = new Set() - machines.forEach((m) => m.companySlug && set.add(m.companySlug)) - return Array.from(set).sort() - }, [machines]) + const companyNameOptions = useMemo(() => (companies ?? []).map((c) => c.name).sort((a,b)=>a.localeCompare(b,"pt-BR")), [companies]) const filteredMachines = useMemo(() => { const text = q.trim().toLowerCase() @@ -242,8 +249,8 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { } if (osFilter !== "all" && (m.osName ?? "").toLowerCase() !== osFilter.toLowerCase()) return false if (companyQuery && companyQuery.trim().length > 0) { - const slug = (m.companySlug ?? "").toLowerCase() - if (!slug.includes(companyQuery.trim().toLowerCase())) return false + const name = companyNameBySlug.get(m.companySlug ?? "")?.toLowerCase() ?? "" + if (!name.includes(companyQuery.trim().toLowerCase())) return false } if (!text) return true const hay = [ @@ -296,12 +303,12 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { setCompanyQuery(e.target.value)} - placeholder="Buscar empresa (slug)" + placeholder="Buscar empresa" className="min-w-[220px]" /> - {companyQuery && companyOptions.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase())).slice(0,6).length > 0 ? ( + {companyQuery && companyNameOptions.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase())).slice(0,6).length > 0 ? (
- {companyOptions + {companyNameOptions .filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase())) .slice(0, 8) .map((c) => ( @@ -336,7 +343,40 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { function MachineStatusBadge({ status }: { status?: string | null }) { const { label, className } = getStatusVariant(status) - return {label} + const s = String(status ?? "").toLowerCase() + const colorClass = + s === "online" + ? "bg-emerald-500" + : s === "offline" + ? "bg-rose-500" + : s === "maintenance" + ? "bg-amber-500" + : s === "blocked" + ? "bg-orange-500" + : "bg-slate-400" + const ringClass = + s === "online" + ? "bg-emerald-400/30" + : s === "offline" + ? "bg-rose-400/30" + : s === "maintenance" + ? "bg-amber-400/30" + : s === "blocked" + ? "bg-orange-400/30" + : "bg-slate-300/30" + + const isOnline = s === "online" + return ( + + + + {isOnline ? ( + + ) : null} + + {label} + + ) } function EmptyState() { @@ -499,7 +539,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
-

{machine.hostname}

+

{machine.hostname}