Refine machine details layout and improve download feedback
This commit is contained in:
parent
5f7efa13e6
commit
1251468b77
6 changed files with 96 additions and 10 deletions
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue