Refine machine details layout and improve download feedback

This commit is contained in:
Esdras Renan 2025-10-19 00:52:42 -03:00
parent 5f7efa13e6
commit 1251468b77
6 changed files with 96 additions and 10 deletions

View file

@ -188,6 +188,7 @@ function App() {
const [isMachineActive, setIsMachineActive] = useState(true) const [isMachineActive, setIsMachineActive] = useState(true)
const [showSecret, setShowSecret] = useState(false) const [showSecret, setShowSecret] = useState(false)
const [isLaunchingSystem, setIsLaunchingSystem] = useState(false) const [isLaunchingSystem, setIsLaunchingSystem] = useState(false)
const [tokenValidationTick, setTokenValidationTick] = useState(0)
const [, setIsValidatingToken] = useState(false) const [, setIsValidatingToken] = useState(false)
const tokenVerifiedRef = useRef(false) const tokenVerifiedRef = useRef(false)
@ -246,6 +247,7 @@ function App() {
if (res.ok) { if (res.ok) {
tokenVerifiedRef.current = true tokenVerifiedRef.current = true
setStatus("online") setStatus("online")
setTokenValidationTick((tick) => tick + 1)
try { try {
await invoke("start_machine_agent", { await invoke("start_machine_agent", {
baseUrl: apiBaseUrl, baseUrl: apiBaseUrl,
@ -290,9 +292,15 @@ function App() {
} else { } else {
// Não limpa token em falhas genéricas (ex.: rede); apenas informa // Não limpa token em falhas genéricas (ex.: rede); apenas informa
setError("Falha ao validar sessão da máquina. Tente novamente.") setError("Falha ao validar sessão da máquina. Tente novamente.")
tokenVerifiedRef.current = true
setTokenValidationTick((tick) => tick + 1)
} }
} catch (err) { } catch (err) {
if (!cancelled) console.error("Falha ao validar token (rede)", err) if (!cancelled) {
console.error("Falha ao validar token (rede)", err)
tokenVerifiedRef.current = true
setTokenValidationTick((tick) => tick + 1)
}
} finally { } finally {
if (!cancelled) setIsValidatingToken(false) if (!cancelled) setIsValidatingToken(false)
} }
@ -731,7 +739,7 @@ function App() {
autoLaunchRef.current = true autoLaunchRef.current = true
setIsLaunchingSystem(true) setIsLaunchingSystem(true)
openSystem() openSystem()
}, [token, status, config?.accessRole, openSystem]) }, [token, status, config?.accessRole, openSystem, tokenValidationTick])
if (isLaunchingSystem && token) { if (isLaunchingSystem && token) {
return ( return (

View file

@ -680,6 +680,50 @@ function formatAbsoluteDateTime(date?: Date | null) {
}).format(date) }).format(date)
} }
function parseDateish(value: unknown): Date | null {
if (!value) return null
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
if (typeof value === "number" && Number.isFinite(value)) {
const numeric = value > 1e12 ? value : value > 1e9 ? value * 1000 : value
const date = new Date(numeric)
return Number.isNaN(date.getTime()) ? null : date
}
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return null
const numericValue = Number(trimmed)
if (Number.isFinite(numericValue)) {
const asMs = trimmed.length >= 13 ? numericValue : numericValue * 1000
const date = new Date(asMs)
if (!Number.isNaN(date.getTime())) return date
}
const date = new Date(trimmed)
if (!Number.isNaN(date.getTime())) return date
}
return null
}
function getMetricsTimestamp(metrics: MachineMetrics): Date | null {
if (!metrics || typeof metrics !== "object") return null
const data = metrics as Record<string, unknown>
const candidates = [
data["collectedAt"],
data["collected_at"],
data["collected_at_iso"],
data["collected_at_ms"],
data["timestamp"],
data["updatedAt"],
data["updated_at"],
data["createdAt"],
data["created_at"],
]
for (const candidate of candidates) {
const parsed = parseDateish(candidate)
if (parsed) return parsed
}
return null
}
function formatBytes(bytes?: number | null) { function formatBytes(bytes?: number | null) {
if (!bytes || Number.isNaN(bytes)) return "—" if (!bytes || Number.isNaN(bytes)) return "—"
const units = ["B", "KB", "MB", "GB", "TB"] const units = ["B", "KB", "MB", "GB", "TB"]
@ -930,6 +974,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const { convexUserId } = useAuth() const { convexUserId } = useAuth()
const router = useRouter() const router = useRouter()
const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown" const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown"
const isActive = machine?.isActive ?? true
const isDeactivated = !isActive || effectiveStatus === "deactivated"
// Company name lookup (by slug) // Company name lookup (by slug)
const companyQueryArgs = convexUserId && machine ? { tenantId: machine.tenantId, viewerId: convexUserId as Id<"users"> } : undefined const companyQueryArgs = convexUserId && machine ? { tenantId: machine.tenantId, viewerId: convexUserId as Id<"users"> } : undefined
const companies = useQuery( const companies = useQuery(
@ -946,6 +992,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const machineAlertsHistory = alertsHistory ?? [] const machineAlertsHistory = alertsHistory ?? []
const metadata = machine?.inventory ?? null const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null const metrics = machine?.metrics ?? null
const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics])
const hardware = metadata?.hardware const hardware = metadata?.hardware
const network = metadata?.network ?? null const network = metadata?.network ?? null
const networkInterfaces = Array.isArray(network) ? network : null const networkInterfaces = Array.isArray(network) ? network : null
@ -972,7 +1019,6 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
? windowsBaseboardRaw ? windowsBaseboardRaw
: null : null
const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined
const isActive = machine?.isActive ?? true
const windowsMemoryModules = useMemo(() => { const windowsMemoryModules = useMemo(() => {
if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw
if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") return [windowsMemoryModulesRaw] if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") return [windowsMemoryModulesRaw]
@ -1307,9 +1353,9 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
return ( return (
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardHeader> <CardHeader className="gap-1">
<CardTitle>Detalhes</CardTitle> <CardTitle>Detalhes</CardTitle>
<CardDescription>Resumo da máquina selecionada.</CardDescription> <CardDescription>Resumo da máquina selecionada</CardDescription>
{machine ? ( {machine ? (
<CardAction> <CardAction>
<div className="flex flex-col items-end gap-2 text-xs sm:text-sm"> <div className="flex flex-col items-end gap-2 text-xs sm:text-sm">
@ -1318,7 +1364,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
{companyName} {companyName}
</div> </div>
) : null} ) : null}
<MachineStatusBadge status={effectiveStatus} /> {!isDeactivated ? <MachineStatusBadge status={effectiveStatus} /> : null}
{!isActive ? ( {!isActive ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700"> <Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
Máquina desativada Máquina desativada
@ -1499,7 +1545,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</section> </section>
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4> <div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
{metricsCapturedAt ? (
<span className="text-xs font-medium text-muted-foreground">
Última atualização {formatRelativeTime(metricsCapturedAt)}
</span>
) : null}
</div>
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} /> <MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
</section> </section>

View file

@ -328,14 +328,14 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
) : null} ) : null}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-5"> <div className="space-y-5">
<label htmlFor="comment" className="text-sm font-medium text-neutral-800"> <label htmlFor="comment" className="block text-sm font-medium text-neutral-800">
Enviar uma mensagem para a equipe Enviar uma mensagem para a equipe
</label> </label>
<RichTextEditor <RichTextEditor
value={comment} value={comment}
onChange={(html) => setComment(html)} onChange={(html) => setComment(html)}
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações." placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20" className="mt-3 rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
disabled={machineInactive} disabled={machineInactive}
/> />
</div> </div>
@ -610,6 +610,8 @@ function PortalCommentAttachmentCard({
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
const target = await ensureUrl() const target = await ensureUrl()
if (!target) return if (!target) return
const toastId = `portal-attachment-download-${attachment.id}`
toast.loading("Baixando anexo...", { id: toastId })
try { try {
const response = await fetch(target, { credentials: "include" }) const response = await fetch(target, { credentials: "include" })
if (!response.ok) { if (!response.ok) {
@ -627,9 +629,11 @@ function PortalCommentAttachmentCard({
window.setTimeout(() => { window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl) window.URL.revokeObjectURL(blobUrl)
}, 1000) }, 1000)
toast.success("Download concluído!", { id: toastId })
} catch (error) { } catch (error) {
console.error("Falha ao iniciar download do anexo", error) console.error("Falha ao iniciar download do anexo", error)
window.open(target, "_blank", "noopener,noreferrer") window.open(target, "_blank", "noopener,noreferrer")
toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId })
} }
}, [attachment.name, ensureUrl]) }, [attachment.name, ensureUrl])

View file

@ -613,6 +613,8 @@ function CommentAttachmentCard({
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
const target = url ?? (await ensureUrl()) const target = url ?? (await ensureUrl())
if (!target) return if (!target) return
const toastId = `attachment-download-${attachment.id}`
toast.loading("Baixando anexo...", { id: toastId })
try { try {
const response = await fetch(target, { credentials: "include" }) const response = await fetch(target, { credentials: "include" })
if (!response.ok) { if (!response.ok) {
@ -630,9 +632,11 @@ function CommentAttachmentCard({
window.setTimeout(() => { window.setTimeout(() => {
window.URL.revokeObjectURL(blobUrl) window.URL.revokeObjectURL(blobUrl)
}, 1000) }, 1000)
toast.success("Download concluído!", { id: toastId })
} catch (error) { } catch (error) {
console.error("Failed to download attachment", error) console.error("Failed to download attachment", error)
window.open(target, "_blank", "noopener,noreferrer") window.open(target, "_blank", "noopener,noreferrer")
toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId })
} }
}, [attachment.name, ensureUrl, url]) }, [attachment.name, ensureUrl, url])

View file

@ -16,6 +16,9 @@ const tagBadgeClass = "inline-flex items-center gap-1 rounded-full border border
const iconAccentClass = "size-3 text-neutral-700" const iconAccentClass = "size-3 text-neutral-700"
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
return ( return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3"> <CardHeader className="px-4 pb-3">
@ -27,6 +30,11 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge> <Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
</div> </div>
<Separator className="bg-slate-200" /> <Separator className="bg-slate-200" />
<div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Empresa</p>
<Badge className={queueBadgeClass}>{companyLabel}</Badge>
</div>
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words"> <div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">SLA</p> <p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">SLA</p>
{ticket.slaPolicy ? ( {ticket.slaPolicy ? (

View file

@ -165,6 +165,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName]) const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty
const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name
if (isAvulso) return "Cliente avulso"
return "Sem empresa vinculada"
}, [ticket.company?.name, isAvulso])
const activeCategory = useMemo( const activeCategory = useMemo(
() => categories.find((category) => category.id === selectedCategoryId) ?? null, () => categories.find((category) => category.id === selectedCategoryId) ?? null,
@ -726,6 +731,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionLabelClass}>Solicitante</span> <span className={sectionLabelClass}>Solicitante</span>
<span className={sectionValueClass}>{ticket.requester.name}</span> <span className={sectionValueClass}>{ticket.requester.name}</span>
</div> </div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Empresa</span>
<span className={sectionValueClass}>{companyLabel}</span>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Responsável</span> <span className={sectionLabelClass}>Responsável</span>
{editing ? ( {editing ? (