Redesenho da UI de dispositivos e correcao de VRAM

- Reorganiza layout da tela de dispositivos admin
- Renomeia secao "Controles do dispositivo" para "Atalhos"
- Adiciona botao de Tickets com badge de quantidade
- Simplifica textos de botoes (Acesso, Resetar)
- Remove email da maquina do cabecalho
- Move empresa e status para mesma linha
- Remove chip de Build do resumo
- Corrige deteccao de VRAM para GPUs >4GB usando nvidia-smi
- Adiciona prefixo "VRAM" na exibicao de memoria da GPU
- Documenta sincronizacao RustDesk

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-06 17:01:40 -03:00
parent c5150fee8f
commit 23e7cf58ae
11 changed files with 863 additions and 441 deletions

View file

@ -931,7 +931,44 @@ fn collect_windows_extended() -> serde_json::Value {
.unwrap_or_else(|| json!({}));
let bios = ps("Get-CimInstance Win32_BIOS | Select-Object Manufacturer,SMBIOSBIOSVersion,ReleaseDate,Version").unwrap_or_else(|| json!({}));
let memory = ps("@(Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel,Capacity,Manufacturer,PartNumber,SerialNumber,ConfiguredClockSpeed,Speed,ConfiguredVoltage)").unwrap_or_else(|| json!([]));
let video = ps("@(Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID)").unwrap_or_else(|| json!([]));
// Coleta de GPU com VRAM correta (nvidia-smi para NVIDIA, registro como fallback para >4GB)
let video = ps(r#"
$gpus = @()
$wmiGpus = Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion,PNPDeviceID
foreach ($gpu in $wmiGpus) {
$vram = $gpu.AdapterRAM
# Tenta nvidia-smi para GPUs NVIDIA (retorna valor correto para >4GB)
if ($gpu.Name -match 'NVIDIA') {
try {
$nvidiaSmi = & 'nvidia-smi' '--query-gpu=memory.total' '--format=csv,noheader,nounits' 2>$null
if ($nvidiaSmi) {
$vramMB = [int64]($nvidiaSmi.Trim())
$vram = $vramMB * 1024 * 1024
}
} catch {}
}
# Fallback: tenta registro do Windows (qwMemorySize é uint64)
if ($vram -le 4294967296 -and $vram -gt 0) {
try {
$regPath = 'HKLM:\SYSTEM\ControlSet001\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}\0*'
$regGpus = Get-ItemProperty $regPath -ErrorAction SilentlyContinue
foreach ($reg in $regGpus) {
if ($reg.DriverDesc -eq $gpu.Name -and $reg.'HardwareInformation.qwMemorySize') {
$vram = [int64]$reg.'HardwareInformation.qwMemorySize'
break
}
}
} catch {}
}
$gpus += [PSCustomObject]@{
Name = $gpu.Name
AdapterRAM = $vram
DriverVersion = $gpu.DriverVersion
PNPDeviceID = $gpu.PNPDeviceID
}
}
@($gpus)
"#).unwrap_or_else(|| json!([]));
let disks = ps("@(Get-CimInstance Win32_DiskDrive | Select-Object Model,SerialNumber,Size,InterfaceType,MediaType)").unwrap_or_else(|| json!([]));
json!({
@ -1305,8 +1342,10 @@ impl AgentRuntime {
// Verifica politica USB apos heartbeat inicial
check_and_apply_usb_policy(&base_clone, &token_clone).await;
let mut ticker = tokio::time::interval(Duration::from_secs(interval));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut heartbeat_ticker = tokio::time::interval(Duration::from_secs(interval));
heartbeat_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut usb_ticker = tokio::time::interval(Duration::from_secs(60));
usb_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
// Wait interval
@ -1314,7 +1353,11 @@ impl AgentRuntime {
_ = stop_signal_clone.notified() => {
break;
}
_ = ticker.tick() => {}
_ = heartbeat_ticker.tick() => {}
_ = usb_ticker.tick() => {
check_and_apply_usb_policy(&base_clone, &token_clone).await;
continue;
}
}
if let Err(error) =

View file

@ -4,9 +4,13 @@ mod rustdesk;
mod usb_control;
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
use chrono::Local;
use usb_control::{UsbPolicy, UsbPolicyResult};
use tauri::{Emitter, Manager, WindowEvent};
use tauri_plugin_store::Builder as StorePluginBuilder;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
#[cfg(target_os = "windows")]
use std::process::Command;
#[cfg(target_os = "windows")]
@ -59,6 +63,38 @@ fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> {
Ok(())
}
#[tauri::command]
fn log_app_event(message: String) -> Result<(), String> {
append_app_log(&message)
}
fn append_app_log(message: &str) -> Result<(), String> {
let Some(dir) = logs_directory() else {
return Err("LOCALAPPDATA indisponivel para gravar logs".to_string());
};
std::fs::create_dir_all(&dir)
.map_err(|error| format!("Falha ao criar pasta de logs: {error}"))?;
let path = dir.join("app.log");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| format!("Falha ao abrir app.log: {error}"))?;
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
writeln!(file, "[{timestamp}] {message}")
.map_err(|error| format!("Falha ao escrever log: {error}"))?;
Ok(())
}
fn logs_directory() -> Option<PathBuf> {
let base = std::env::var("LOCALAPPDATA").ok()?;
Some(Path::new(&base).join("br.com.esdrasrenan.sistemadechamados").join("logs"))
}
#[tauri::command]
async fn ensure_rustdesk_and_emit(
app: tauri::AppHandle,
@ -150,6 +186,7 @@ pub fn run() {
start_machine_agent,
stop_machine_agent,
open_devtools,
log_app_event,
ensure_rustdesk_and_emit,
apply_usb_policy,
get_usb_policy,

View file

@ -282,6 +282,29 @@ pub fn ensure_rustdesk(
log_event("Dados do RustDesk salvos no machine-agent.json");
}
// Sincroniza com o backend imediatamente apos provisionar
// O Rust faz o HTTP direto, sem passar pelo CSP do webview
if let Err(error) = sync_remote_access_with_backend(&result) {
log_event(&format!("Aviso: falha ao sincronizar com backend: {error}"));
} else {
log_event("Acesso remoto sincronizado com backend");
// Atualiza lastSyncedAt no store
let synced_data = serde_json::json!({
"id": final_id,
"password": password,
"installedVersion": version,
"updated": freshly_installed,
"lastProvisionedAt": last_provisioned_at,
"lastSyncedAt": Utc::now().timestamp_millis(),
"lastError": serde_json::Value::Null
});
if let Err(e) = upsert_machine_store_value("rustdesk", synced_data) {
log_event(&format!("Aviso: falha ao atualizar lastSyncedAt: {e}"));
} else {
log_event("lastSyncedAt atualizado com sucesso");
}
}
log_event(&format!("Provisionamento concluído. ID final: {final_id}. Versão: {:?}", version));
Ok(result)
@ -1459,3 +1482,78 @@ fn mark_acl_unlock_flag() {
));
}
}
fn get_machine_store_path() -> Result<PathBuf, RustdeskError> {
let base = env::var("LOCALAPPDATA")
.map_err(|_| RustdeskError::MissingId)?;
Ok(Path::new(&base)
.join(APP_IDENTIFIER)
.join(MACHINE_STORE_FILENAME))
}
fn sync_remote_access_with_backend(result: &crate::RustdeskProvisioningResult) -> Result<(), RustdeskError> {
log_event("Iniciando sincronizacao com backend...");
// Le token e config do store
let store_path = get_machine_store_path()?;
let store_content = fs::read_to_string(&store_path)
.map_err(RustdeskError::Io)?;
let store: serde_json::Value = serde_json::from_str(&store_content)
.map_err(|_| RustdeskError::MissingId)?;
let token = store.get("token")
.and_then(|v| v.as_str())
.ok_or(RustdeskError::MissingId)?;
let config = store.get("config")
.ok_or(RustdeskError::MissingId)?;
let machine_id = config.get("machineId")
.and_then(|v| v.as_str())
.ok_or(RustdeskError::MissingId)?;
let api_base_url = config.get("apiBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("https://tickets.esdrasrenan.com.br");
log_event(&format!("Sincronizando com backend: {} (machineId: {})", api_base_url, machine_id));
// Monta payload conforme schema esperado pelo backend
// Schema: { machineToken, provider, identifier, password?, url?, username?, notes? }
let payload = serde_json::json!({
"machineToken": token,
"provider": "RustDesk",
"identifier": result.id,
"password": result.password,
"notes": format!("Versao: {}. Provisionado em: {}",
result.installed_version.as_deref().unwrap_or("desconhecida"),
result.last_provisioned_at)
});
// Faz POST para /api/machines/remote-access
let client = Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(30))
.build()?;
let url = format!("{}/api/machines/remote-access", api_base_url);
let response = client.post(&url)
.header("Content-Type", "application/json")
.header("Idempotency-Key", format!("{}:RustDesk:{}", machine_id, result.id))
.body(payload.to_string())
.send()?;
if response.status().is_success() {
log_event(&format!("Sync com backend OK: status {}", response.status()));
Ok(())
} else {
let status = response.status();
let body = response.text().unwrap_or_default();
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
log_event(&format!("Sync com backend falhou: {} - {}", status, body_preview));
Err(RustdeskError::CommandFailed {
command: "sync_remote_access".to_string(),
status: Some(status.as_u16() as i32)
})
}
}

View file

@ -65,6 +65,9 @@ pub enum UsbControlError {
#[cfg(target_os = "windows")]
mod windows_impl {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use winreg::enums::*;
use winreg::RegKey;
@ -80,6 +83,39 @@ mod windows_impl {
pub fn apply_usb_policy(policy: UsbPolicy) -> Result<UsbPolicyResult, UsbControlError> {
let now = chrono::Utc::now().timestamp_millis();
let direct_result = try_apply_policy_direct(policy);
match direct_result {
Ok(()) => Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
}),
Err(err) => {
// Tenta elevação se faltou permissão
if is_permission_error(&err) {
if let Err(elevated_err) = apply_policy_with_elevation(policy) {
return Err(elevated_err);
}
// Revalida a policy após elevação
let current = get_current_policy()?;
if current != policy {
return Err(UsbControlError::PermissionDenied);
}
return Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
});
}
Err(err)
}
}
}
fn try_apply_policy_direct(policy: UsbPolicy) -> Result<(), UsbControlError> {
// 1. Aplicar Removable Storage Access Policy
apply_removable_storage_policy(policy)?;
@ -93,12 +129,7 @@ mod windows_impl {
apply_write_protect(false)?;
}
Ok(UsbPolicyResult {
success: true,
policy: policy.as_str().to_string(),
error: None,
applied_at: Some(now),
})
Ok(())
}
fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> {
@ -120,27 +151,27 @@ mod windows_impl {
UsbPolicy::BlockAll => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
key.set_value("Deny_Read", &1u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &1u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
}
UsbPolicy::Readonly => {
let (key, _) = hklm
.create_subkey(&full_path)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
// Permite leitura, bloqueia escrita
key.set_value("Deny_Read", &0u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
key.set_value("Deny_Write", &1u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
key.set_value("Deny_Execute", &0u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
}
}
@ -152,13 +183,13 @@ mod windows_impl {
let key = hklm
.open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
match policy {
UsbPolicy::Allow => {
// Start = 3 habilita o driver
key.set_value("Start", &3u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
}
UsbPolicy::BlockAll | UsbPolicy::Readonly => {
// Start = 4 desabilita o driver
@ -166,11 +197,11 @@ mod windows_impl {
// Porem, como fallback de seguranca, desabilitamos para BlockAll
if policy == UsbPolicy::BlockAll {
key.set_value("Start", &4u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
} else {
// Readonly mantem driver ativo
key.set_value("Start", &3u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
}
}
}
@ -184,10 +215,10 @@ mod windows_impl {
if enable {
let (key, _) = hklm
.create_subkey(STORAGE_POLICY_PATH)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
key.set_value("WriteProtect", &1u32)
.map_err(|e| UsbControlError::RegistryError(e.to_string()))?;
.map_err(map_winreg_error)?;
} else {
if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) {
let _ = key.set_value("WriteProtect", &0u32);
@ -227,6 +258,99 @@ mod windows_impl {
Ok(UsbPolicy::Allow)
}
fn is_permission_error(error: &UsbControlError) -> bool {
match error {
UsbControlError::PermissionDenied => true,
UsbControlError::RegistryError(msg) => {
let lower = msg.to_lowercase();
lower.contains("access is denied") || lower.contains("acesso negado") || lower.contains("5")
}
_ => false,
}
}
fn apply_policy_with_elevation(policy: UsbPolicy) -> Result<(), UsbControlError> {
// Cria script temporário para aplicar as chaves via PowerShell elevado
let temp_dir = std::env::temp_dir();
let script_path: PathBuf = temp_dir.join("raven_usb_policy.ps1");
let policy_str = policy.as_str();
let script = format!(
r#"$ErrorActionPreference = 'Stop'
$guid = '{guid}'
$policy = '{policy}'
function Set-Allow {{
reg delete 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /f 2>$null
reg delete 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /f 2>$null
reg add 'HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR' /v Start /t REG_DWORD /d 3 /f | Out-Null
}}
function Set-BlockAll {{
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /f | Out-Null
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Read /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Write /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Execute /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR' /v Start /t REG_DWORD /d 4 /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /v WriteProtect /t REG_DWORD /d 0 /f | Out-Null
}}
function Set-Readonly {{
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /f | Out-Null
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Read /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Write /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKLM\Software\Policies\Microsoft\Windows\RemovableStorageDevices\{guid}' /v Deny_Execute /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR' /v Start /t REG_DWORD /d 3 /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\StorageDevicePolicies' /v WriteProtect /t REG_DWORD /d 1 /f | Out-Null
}}
switch ($policy) {{
'ALLOW' {{ Set-Allow }}
'BLOCK_ALL' {{ Set-BlockAll }}
'READONLY' {{ Set-Readonly }}
default {{ throw 'Politica invalida' }}
}}
try {{
gpupdate /target:computer /force | Out-Null
}} catch {{}}
"#,
guid = REMOVABLE_STORAGE_GUID,
policy = policy_str
);
fs::write(&script_path, script).map_err(|e| UsbControlError::Io(e))?;
// Start-Process com RunAs para acionar UAC
let arg = format!(
"Start-Process -WindowStyle Hidden -FilePath powershell -Verb RunAs -Wait -ArgumentList '-ExecutionPolicy Bypass -File \"{}\"'",
script_path.display()
);
let status = Command::new("powershell")
.arg("-Command")
.arg(arg)
.status()
.map_err(|e| UsbControlError::Io(e))?;
if !status.success() {
return Err(UsbControlError::PermissionDenied);
}
Ok(())
}
fn map_winreg_error(error: io::Error) -> UsbControlError {
if let Some(code) = error.raw_os_error() {
if code == 5 {
return UsbControlError::PermissionDenied;
}
}
UsbControlError::RegistryError(error.to_string())
}
pub fn refresh_group_policy() -> Result<(), UsbControlError> {
use std::os::windows::process::CommandExt;
use std::process::Command;

View file

@ -117,7 +117,6 @@ const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl)
const RUSTDESK_CONFIG_STRING = import.meta.env.VITE_RUSTDESK_CONFIG_STRING?.trim() || null
const RUSTDESK_DEFAULT_PASSWORD = import.meta.env.VITE_RUSTDESK_DEFAULT_PASSWORD?.trim() || null
const RUSTDESK_SYNC_INTERVAL_MS = 60 * 60 * 1000 // 1h
const TOKEN_SELF_HEAL_DEBOUNCE_MS = 30 * 1000
function sanitizeEmail(value: string | null | undefined) {
@ -244,7 +243,10 @@ async function writeRustdeskInfo(store: Store, info: RustdeskInfo): Promise<void
function logDesktop(message: string, data?: Record<string, unknown>) {
const enriched = data ? `${message} ${JSON.stringify(data)}` : message
console.log(`[raven] ${enriched}`)
const line = `[raven] ${enriched}`
console.log(line)
// Persiste em arquivo local para facilitar debugging fora do console
invoke("log_app_event", { message: line }).catch(() => {})
}
function bytes(n?: number) {
@ -782,99 +784,68 @@ const resolvedAppUrl = useMemo(() => {
return normalized
}, [config?.appUrl])
const syncRemoteAccessNow = useCallback(
async (info: RustdeskInfo, allowRetry = true) => {
if (!store) return
if (!config?.machineId) {
logDesktop("remoteAccess:sync:skipped", { reason: "unregistered" })
return
}
const payload = buildRemoteAccessPayload(info)
if (!payload) return
// Funcao simplificada de sync - sempre le do disco para evitar race conditions
const syncRemoteAccessDirect = useCallback(
async (info: RustdeskInfo, allowRetry = true): Promise<boolean> => {
try {
// Sempre le do disco para evitar race conditions com state React
const freshStore = await loadStore()
const freshConfig = await readConfig(freshStore)
const freshToken = await readToken(freshStore)
const resolveToken = async (allowHeal: boolean): Promise<string | null> => {
let currentToken = token
if (!currentToken) {
currentToken = (await readToken(store)) ?? null
if (currentToken) {
setToken(currentToken)
}
if (!freshConfig?.machineId || !freshToken) {
logDesktop("remoteAccess:sync:skip", {
hasMachineId: !!freshConfig?.machineId,
hasToken: !!freshToken,
})
return false
}
if (!currentToken && allowHeal) {
const healed = await attemptSelfHeal("remote-access")
if (healed) {
currentToken = (await readToken(store)) ?? null
if (currentToken) {
setToken(currentToken)
}
}
}
return currentToken
}
const sendRequest = async (machineToken: string, retryAllowed: boolean): Promise<void> => {
const payload = buildRemoteAccessPayload(info)
if (!payload) return false
logDesktop("remoteAccess:sync:start", { id: info.id })
const response = await fetch(`${apiBaseUrl}/api/machines/remote-access`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": `${config?.machineId ?? "unknown"}:RustDesk:${info.id}`,
"Idempotency-Key": `${freshConfig.machineId}:RustDesk:${info.id}`,
},
body: JSON.stringify({ machineToken, ...payload }),
body: JSON.stringify({ machineToken: freshToken, ...payload }),
})
if (!response.ok) {
logDesktop("remoteAccess:sync:error", { status: response.status })
const text = await response.text()
if (retryAllowed && (response.status === 401 || isTokenRevokedMessage(text))) {
const healed = await attemptSelfHeal("remote-access")
if (healed) {
const refreshedToken = await resolveToken(false)
if (refreshedToken) {
return sendRequest(refreshedToken, false)
}
}
}
throw new Error(text.slice(0, 300) || "Falha ao registrar acesso remoto")
if (response.ok) {
const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now(), lastError: null }
await writeRustdeskInfo(freshStore, nextInfo)
setRustdeskInfo(nextInfo)
logDesktop("remoteAccess:sync:success", { id: info.id })
return true
}
const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now(), lastError: null }
await writeRustdeskInfo(store, nextInfo)
setRustdeskInfo(nextInfo)
logDesktop("remoteAccess:sync:success", { id: info.id })
}
const errorText = await response.text()
logDesktop("remoteAccess:sync:error", { status: response.status, error: errorText.slice(0, 200) })
try {
const machineToken = await resolveToken(true)
if (!machineToken) {
const failedInfo: RustdeskInfo = {
...info,
lastError: "Token indisponível para sincronizar acesso remoto",
}
await writeRustdeskInfo(store, failedInfo)
setRustdeskInfo(failedInfo)
logDesktop("remoteAccess:sync:skipped", { reason: "missing-token" })
return
}
await sendRequest(machineToken, allowRetry)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error("Falha ao sincronizar acesso remoto com a plataforma", error)
const failedInfo: RustdeskInfo = { ...info, lastError: message }
await writeRustdeskInfo(store, failedInfo)
setRustdeskInfo(failedInfo)
if (allowRetry && isTokenRevokedMessage(message)) {
// Se token invalido, tenta self-heal uma vez
if (allowRetry && (response.status === 401 || isTokenRevokedMessage(errorText))) {
const healed = await attemptSelfHeal("remote-access")
if (healed) {
const refreshedToken = await resolveToken(false)
if (refreshedToken) {
return syncRemoteAccessNow(failedInfo, false)
}
return syncRemoteAccessDirect(info, false)
}
}
logDesktop("remoteAccess:sync:failed", { id: info.id, error: message })
// Salva erro no store
const failedInfo: RustdeskInfo = { ...info, lastError: errorText.slice(0, 200) }
await writeRustdeskInfo(freshStore, failedInfo)
setRustdeskInfo(failedInfo)
return false
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logDesktop("remoteAccess:sync:exception", { error: message })
return false
}
},
[store, token, config?.machineId, attemptSelfHeal, setToken]
[attemptSelfHeal]
)
const handleRustdeskProvision = useCallback(
@ -1007,23 +978,58 @@ const resolvedAppUrl = useMemo(() => {
}
}, [store, handleRustdeskProvision])
// Bootstrap do RustDesk + retry simplificado (60s)
useEffect(() => {
if (!store || !config?.machineId) return
if (!rustdeskInfo && !isRustdeskProvisioning && !rustdeskBootstrapRef.current) {
rustdeskBootstrapRef.current = true
ensureRustdesk().finally(() => {
rustdeskBootstrapRef.current = false
})
return
}
if (rustdeskInfo && !isRustdeskProvisioning) {
const lastSync = rustdeskInfo.lastSyncedAt ?? 0
const needsSync = Date.now() - lastSync > RUSTDESK_SYNC_INTERVAL_MS
if (needsSync) {
syncRemoteAccessNow(rustdeskInfo)
let disposed = false
async function bootstrap() {
// Se nao tem rustdeskInfo, provisiona primeiro
if (!rustdeskInfo && !isRustdeskProvisioning && !rustdeskBootstrapRef.current) {
rustdeskBootstrapRef.current = true
try {
await ensureRustdesk()
} finally {
rustdeskBootstrapRef.current = false
}
return // handleRustdeskProvision fara o sync
}
// Se ja tem rustdeskInfo mas nunca sincronizou, tenta sync
if (rustdeskInfo && !rustdeskInfo.lastSyncedAt) {
logDesktop("remoteAccess:sync:bootstrap", { id: rustdeskInfo.id })
await syncRemoteAccessDirect(rustdeskInfo)
}
}
}, [store, config?.machineId, rustdeskInfo, ensureRustdesk, syncRemoteAccessNow, isRustdeskProvisioning])
bootstrap()
// Retry a cada 30s se nunca sincronizou (o Rust faz o sync automaticamente)
const interval = setInterval(async () => {
if (disposed) return
try {
const freshStore = await loadStore()
const freshRustdesk = await readRustdeskInfo(freshStore)
if (freshRustdesk && !freshRustdesk.lastSyncedAt) {
logDesktop("remoteAccess:sync:retry:fallback", { id: freshRustdesk.id })
// Re-invoca o Rust para tentar sync novamente
await invoke("ensure_rustdesk_and_emit", {
configString: RUSTDESK_CONFIG_STRING || null,
password: RUSTDESK_DEFAULT_PASSWORD || null,
machineId: config?.machineId,
})
}
} catch (err) {
logDesktop("remoteAccess:sync:retry:error", { error: String(err) })
}
}, 30_000)
return () => {
disposed = true
clearInterval(interval)
}
}, [store, config?.machineId, rustdeskInfo, isRustdeskProvisioning, ensureRustdesk, syncRemoteAccessDirect])
async function register() {
if (!profile) return
@ -1100,10 +1106,23 @@ const resolvedAppUrl = useMemo(() => {
},
})
await ensureRustdesk()
logDesktop("register:rustdesk:done", { machineId: data.machineId })
// Provisiona RustDesk em background (fire-and-forget)
// O Rust faz o sync com o backend automaticamente, sem passar pelo CSP do webview
logDesktop("register:rustdesk:start", { machineId: data.machineId })
invoke<RustdeskProvisioningResult>("ensure_rustdesk_and_emit", {
configString: RUSTDESK_CONFIG_STRING || null,
password: RUSTDESK_DEFAULT_PASSWORD || null,
machineId: data.machineId,
}).then((result) => {
logDesktop("register:rustdesk:done", { machineId: data.machineId, id: result.id })
}).catch((err) => {
const msg = err instanceof Error ? err.message : String(err)
if (!msg.toLowerCase().includes("apenas no windows")) {
logDesktop("register:rustdesk:error", { error: msg })
}
})
// Abre o sistema imediatamente após registrar (evita ficar com token inválido no fluxo antigo)
// Redireciona imediatamente (nao espera RustDesk)
try {
await fetch(`${apiBaseUrl}/api/machines/sessions`, {
method: "POST",

View file

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "web",
@ -40,7 +41,7 @@
"@tiptap/starter-kit": "^3.10.0",
"@tiptap/suggestion": "^3.10.0",
"better-auth": "^1.3.26",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.29.2",
@ -971,7 +972,7 @@
"better-call": ["better-call@1.1.4", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-NJouLY6IVKv0nDuFoc6FcbKDFzEnmgMNofC9F60Mwx1Ecm7X6/Ecyoe5b+JSVZ42F/0n46/M89gbYP1ZCVv8xQ=="],
"better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="],
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@ -2041,8 +2042,6 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@prisma/adapter-better-sqlite3/better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="],
"@prisma/dev/std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.1.0", "", { "dependencies": { "@prisma/debug": "7.1.0" } }, "sha512-lq8hMdjKiZftuT5SssYB3EtQj8+YjL24/ZTLflQqzFquArKxBcyp6Xrblto+4lzIKJqnpOjfMiBjMvl7YuD7+Q=="],

View file

@ -9,6 +9,7 @@ Este índice consolida a documentação viva e move conteúdos históricos para
- Build: `docs/desktop/build.md`
- Updater: `docs/desktop/updater.md`
- Handshake/troubleshooting: `docs/desktop/handshake-troubleshooting.md`
- RustDesk sync: `docs/desktop/rustdesk-sync.md`
- Tickets: `docs/ticket-snapshots.md`
- Administração (UI): `docs/admin/admin-inventory-ui.md`

View file

@ -0,0 +1,151 @@
# Desktop (Tauri) - Sincronizacao RustDesk com Backend
Este documento descreve o mecanismo de sincronizacao automatica do RustDesk com o backend apos o provisionamento no aplicativo desktop.
## Visao Geral
O fluxo de sincronizacao do RustDesk garante que as credenciais de acesso remoto (ID e senha) sejam automaticamente registradas no backend assim que o RustDesk for provisionado na maquina. Isso elimina a necessidade de cadastro manual e permite acesso remoto imediato.
## Arquitetura
### Fluxo de Provisionamento
```
1. Usuario clica "Registrar" no app desktop
2. TypeScript faz POST /api/machines/register
3. TypeScript salva token/config no store local
4. TypeScript inicia RustDesk em background (fire-and-forget)
5. TypeScript redireciona IMEDIATAMENTE para o sistema
6. [Background] Rust provisiona RustDesk (~30s)
7. [Background] Rust faz POST /api/machines/remote-access (SYNC)
8. [Background] Rust atualiza lastSyncedAt no store
```
### Por que o Sync e feito no Rust?
| Problema anterior | Solucao Rust |
|-------------------|--------------|
| CSP do Tauri bloqueava `fetch()` | Rust usa reqwest diretamente, sem CSP |
| Race conditions com React state | Fluxo sincrono no Rust |
| Fire-and-forget nao completava | Sync acontece antes do return |
| Timeout do navegador | Rust tem controle total do HTTP |
## Implementacao
### Arquivo: `apps/desktop/src-tauri/src/rustdesk.rs`
O sync e executado automaticamente apos o provisionamento bem-sucedido:
```rust
// Apos salvar dados do RustDesk no store local
sync_remote_access_with_backend(&result)?;
```
### Payload da API
```json
{
"machineToken": "<token-da-maquina>",
"provider": "RustDesk",
"identifier": "<id-do-rustdesk>",
"password": "<senha-permanente>",
"notes": "Versao: 1.4.4. Provisionado em: 1765045637482"
}
```
### Headers
- `Content-Type: application/json`
- `Idempotency-Key: <machineId>:RustDesk:<rustdeskId>` (previne duplicatas)
### Endpoint
```
POST /api/machines/remote-access
```
## Arquivos de Configuracao
### Store Local
Caminho: `%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\machine-agent.json`
Estrutura relevante:
```json
{
"config": {
"machineId": "...",
"apiBaseUrl": "https://tickets.esdrasrenan.com.br"
},
"token": "...",
"rustdesk": {
"id": "497869711",
"password": "...",
"installedVersion": "1.4.4",
"lastProvisionedAt": 1765045637482,
"lastSyncedAt": 1765045638173,
"lastError": null
}
}
```
### Logs
Caminho: `%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\logs\rustdesk.log`
Mensagens de sucesso:
```
[...] Iniciando sincronizacao com backend...
[...] Sync com backend OK: status 200 OK
[...] Acesso remoto sincronizado com backend
```
## Diagnostico
### Verificar se o Sync Funcionou
1. Abrir `machine-agent.json`:
```
%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\machine-agent.json
```
2. Verificar campo `rustdesk.lastSyncedAt`:
- Se tem valor numerico: sync OK
- Se `null`: sync falhou
3. Verificar logs:
```
%LOCALAPPDATA%\br.com.esdrasrenan.sistemadechamados\logs\rustdesk.log
```
### Possiveis Erros
| Erro | Causa | Solucao |
|------|-------|---------|
| `400 Bad Request` | Payload incorreto | Verificar schema da API |
| `401 Unauthorized` | Token invalido/expirado | Re-registrar maquina |
| `Connection refused` | Backend indisponivel | Verificar URL da API |
| `lastSyncedAt: null` | Sync nao executou | Verificar logs do Rust |
### Fallback Manual
Se o sync automatico falhar, o sistema tenta novamente a cada 30 segundos em background. Tambem e possivel forcar o re-provisionamento via:
1. Aba "Configuracoes" no app desktop
2. Clicar "Reprovisionar RustDesk"
## Historico de Mudancas
### Dezembro 2025 - Migracao do Sync para Rust
**Problema:** O sync feito em TypeScript (`fetch()`) falhava com "Failed to fetch" devido ao CSP do Tauri v2 bloquear requisicoes externas do webview.
**Solucao:** Mover o sync para o Rust, que faz HTTP diretamente via reqwest sem restricoes de CSP.
**Arquivos modificados:**
- `apps/desktop/src-tauri/src/rustdesk.rs` - Adicionado `sync_remote_access_with_backend()`
- `apps/desktop/src/main.tsx` - Simplificado para fire-and-forget
---
Ultima atualizacao: Dezembro 2025 - Sync automatico via Rust apos provisionamento.

View file

@ -1,4 +1,11 @@
const nextConfig = {
import type { NextConfig } from "next"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __filename = fileURLToPath(import.meta.url)
const projectRoot = path.dirname(__filename)
const nextConfig: NextConfig = {
typescript: {
// Pula verificacao de tipos durante build para evitar OOM no servidor de producao
ignoreBuildErrors: true,
@ -6,6 +13,13 @@ const nextConfig = {
experimental: {
turbopackFileSystemCacheForDev: true,
},
turbopack: {
// Define a raiz do projeto para evitar warning de multiplos lockfiles
root: projectRoot,
},
// Pacotes nativos que nao devem ser bundled pelo servidor
serverExternalPackages: ["better-sqlite3"],
}
export default nextConfig

View file

@ -62,7 +62,7 @@
"@tiptap/starter-kit": "^3.10.0",
"@tiptap/suggestion": "^3.10.0",
"better-auth": "^1.3.26",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "12.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.29.2",

View file

@ -41,6 +41,7 @@ import {
Usb,
Loader2,
X,
TicketCheck,
} from "lucide-react"
import { api } from "@/convex/_generated/api"
@ -3154,14 +3155,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
icon: <Cpu className="size-4 text-neutral-500" />,
})
}
if (windowsBuildLabel) {
chips.push({
key: "build",
label: "Build",
value: windowsBuildLabel,
icon: <ServerCog className="size-4 text-neutral-500" />,
})
}
if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) {
chips.push({
key: "activation",
@ -3222,7 +3215,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
device?.osVersion,
device?.architecture,
windowsVersionLabel,
windowsBuildLabel,
windowsActivationStatus,
primaryLinkedUser?.email,
primaryLinkedUser?.name,
@ -3867,25 +3859,49 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
return (
<Card className="border-slate-200">
<CardHeader className="gap-1">
<CardTitle>Detalhes</CardTitle>
<CardDescription>Resumo do dispositivo selecionado</CardDescription>
{device ? (
<CardAction>
<div className="flex flex-col items-end gap-2 text-xs sm:text-sm">
{companyName ? (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 font-semibold text-neutral-600 shadow-sm">
{companyName}
</div>
<>
<CardTitle className="flex items-center gap-2">
<span className="break-words text-2xl font-semibold text-neutral-900">
{device.displayName ?? device.hostname ?? "Dispositivo"}
</span>
{isManualMobile ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-neutral-600">
Identificação interna
</span>
) : null}
{!isDeactivated ? <DeviceStatusBadge status={effectiveStatus} /> : null}
{!isActiveLocal ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
Dispositivo desativada
</Badge>
) : null}
</div>
</CardAction>
) : null}
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => {
setNewName(device.displayName ?? device.hostname ?? "")
setRenaming(true)
}}
>
<Pencil className="size-4" />
<span className="sr-only">Renomear dispositivo</span>
</Button>
</CardTitle>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm">
{companyName ? (
<div className="max-w-[200px] truncate rounded-lg border border-slate-200 bg-white px-3 py-1 font-semibold text-neutral-600 shadow-sm" title={companyName}>
{companyName}
</div>
) : null}
{!isDeactivated ? <DeviceStatusBadge status={effectiveStatus} /> : null}
{!isActiveLocal ? (
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
Dispositivo desativado
</Badge>
) : null}
</div>
</CardAction>
</>
) : (
<CardTitle>Detalhes</CardTitle>
)}
</CardHeader>
<CardContent className="space-y-6">
{!device ? (
@ -3893,47 +3909,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex flex-wrap items-start gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-neutral-900">
{device.displayName ?? device.hostname ?? "Dispositivo"}
</h1>
{isManualMobile ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-neutral-600">
Identificação interna
</span>
) : null}
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => {
setNewName(device.displayName ?? device.hostname ?? "")
setRenaming(true)
}}
>
<Pencil className="size-4" />
<span className="sr-only">Renomear dispositivo</span>
</Button>
</div>
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{device.authEmail ?? "E-mail não definido"}</span>
{device.authEmail ? (
<button
type="button"
onClick={copyEmail}
className="inline-flex items-center rounded-md p-1 text-neutral-500 transition hover:bg-[#00d6eb]/15 hover:text-[#0a4760] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40 focus-visible:ring-offset-2"
title="Copiar e-mail do dispositivo"
aria-label="Copiar e-mail do dispositivo"
>
<ClipboardCopy className="size-3.5" />
</button>
) : null}
</p>
</div>
</div>
{/* ping integrado na badge de status */}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{summaryChips.map((chip) => (
<InfoChip
@ -3975,17 +3950,32 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<div className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controles do dispositivo</p>
{device.registeredBy ? (
<span className="text-xs font-medium text-slate-500">
Registrada via <span className="text-slate-800">{device.registeredBy}</span>
</span>
) : null}
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Atalhos</p>
<span className="text-xs font-medium text-slate-500">
{device.registeredBy === "desktop-agent" ? (
<span className="text-slate-800">Agente na máquina</span>
) : (
<span className="text-slate-800">Manual</span>
)}
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{deviceTicketsHref ? (
<Button size="sm" variant="outline" className="gap-2 border-dashed" asChild>
<Link href={deviceTicketsHref}>
<TicketCheck className="size-4" />
Tickets
{totalOpenTickets > 0 ? (
<Badge variant="secondary" className="ml-1 rounded-full px-2 py-0 text-[10px] font-semibold">
{totalOpenTickets}
</Badge>
) : null}
</Link>
</Button>
) : null}
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
<ShieldCheck className="size-4" />
Ajustar acesso
Acesso
</Button>
{!isManualMobile ? (
<>
@ -3997,7 +3987,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
disabled={isResettingAgent}
>
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
{isResettingAgent ? "Resetando..." : "Resetar"}
</Button>
<Button
size="sm"
@ -4029,7 +4019,195 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div>
</div>
{/* Campos personalizados (posicionado logo após métricas) */}
{/* Acesso remoto */}
<div className="space-y-3 border-t border-slate-100 pt-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Acesso remoto</h4>
{hasRemoteAccess ? (
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
{remoteAccessEntries.length === 1 ? "1 acesso" : `${remoteAccessEntries.length} acessos`}
</Badge>
) : null}
</div>
{canManageRemoteAccess ? (
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed"
onClick={() => {
setEditingRemoteAccessClientId(null)
setRemoteAccessDialog(true)
}}
>
<Key className="size-4" />
{hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"}
</Button>
) : null}
</div>
{hasRemoteAccess ? (
<div className="space-y-3">
{remoteAccessEntries.map((entry) => {
const lastVerifiedDate =
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
? new Date(entry.lastVerifiedAt)
: null
const isRustDesk = isRustDeskAccess(entry)
const secretVisible = Boolean(visibleRemoteSecrets[entry.clientId])
return (
<div key={entry.clientId} className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-700">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">ID</span>
{entry.identifier ? (
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm font-semibold text-neutral-800">
{entry.identifier}
</code>
) : null}
{entry.identifier ? (
<Button
variant="ghost"
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
title="Copiar ID"
aria-label="Copiar ID"
>
<ClipboardCopy className="size-3.5" />
</Button>
) : null}
</div>
{entry.username || entry.password ? (
<div className="flex flex-col gap-1">
{entry.username ? (
<div className="inline-flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Usuário</span>
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
{entry.username}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
title="Copiar usuário"
aria-label="Copiar usuário"
>
<ClipboardCopy className="size-3.5" />
</Button>
</div>
) : null}
{entry.password ? (
<div className="inline-flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">Senha</span>
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm text-slate-700">
{secretVisible ? entry.password : "••••••••"}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => toggleRemoteSecret(entry.clientId)}
title={secretVisible ? "Ocultar senha" : "Mostrar senha"}
aria-label={secretVisible ? "Ocultar senha" : "Mostrar senha"}
>
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
title="Copiar senha"
aria-label="Copiar senha"
>
<ClipboardCopy className="size-3.5" />
</Button>
</div>
) : null}
</div>
) : null}
{entry.url && !isRustDesk ? (
<a
href={entry.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-slate-600 underline-offset-4 hover:text-slate-900 hover:underline"
>
Abrir console remoto
</a>
) : null}
</div>
<div className="flex flex-col items-end gap-1 text-right">
{entry.notes ? (
<p className="whitespace-pre-wrap text-xs text-slate-600">{entry.notes}</p>
) : null}
{lastVerifiedDate ? (
<p className="text-xs text-slate-500">
Atualizado {formatRelativeTime(lastVerifiedDate)}{" "}
<span className="text-slate-400">({formatAbsoluteDateTime(lastVerifiedDate)})</span>
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 pt-3 mt-3">
<div>
{isRustDesk && (entry.identifier || entry.password) ? (
<Button
variant="outline"
size="sm"
className="inline-flex items-center gap-2 border-slate-300 bg-white text-slate-800 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50 hover:text-slate-900 focus-visible:border-slate-400 focus-visible:ring-slate-200"
onClick={() => handleRustDeskConnect(entry)}
>
<MonitorSmartphone className="size-4 text-[#4b5563]" /> Conectar via RustDesk
</Button>
) : null}
</div>
<div className="flex items-center gap-2">
{entry.provider ? (
<Button variant="outline" size="sm" className="gap-2 border-slate-300 cursor-default hover:bg-white">
{entry.provider}
</Button>
) : null}
{canManageRemoteAccess ? (
<>
<Button
size="sm"
variant="outline"
className="gap-2 border-slate-300"
onClick={() => {
setEditingRemoteAccessClientId(entry.clientId)
setRemoteAccessDialog(true)
}}
>
<Pencil className="size-3.5" /> Editar
</Button>
<Button
size="sm"
variant="ghost"
className="gap-2 text-rose-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
onClick={() => void handleRemoveRemoteAccess(entry)}
disabled={remoteAccessSaving}
>
<ShieldOff className="size-3.5" /> Remover
</Button>
</>
) : null}
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-600">
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
</div>
)}
</div>
{/* Campos personalizados */}
<div className="space-y-3 border-t border-slate-100 pt-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
@ -4068,248 +4246,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div>
) : null}
</div>
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por este dispositivo</h4>
{totalOpenTickets === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Nenhum chamado em aberto registrado diretamente por este dispositivo.
</p>
) : hasAdditionalOpenTickets ? (
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70">
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados em aberto
</p>
) : (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Últimos chamados vinculados a este dispositivo.
</p>
)}
</div>
<div className="flex items-center">
<div className="flex h-10 min-w-[56px] items-center justify-center rounded-xl border border-[color:var(--accent)] bg-white px-3 text-[color:var(--accent-foreground)] shadow-sm">
<span className="text-lg font-semibold leading-none tabular-nums">{totalOpenTickets}</span>
</div>
</div>
</div>
{totalOpenTickets > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{displayedDeviceTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="group flex h-full flex-col justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white p-3 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="space-y-1">
<p className="line-clamp-2 font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
)
})}
</div>
) : null}
{deviceTicketsHref ? (
<div className="mt-4">
<Link
href={deviceTicketsHref}
className="text-xs font-semibold text-[color:var(--accent-foreground)] underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
</div>
) : null}
</div>
<div className="space-y-3 border-t border-slate-100 pt-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Acesso remoto</h4>
{hasRemoteAccess ? (
<Badge variant="outline" className="border-slate-200 bg-slate-100 text-[11px] font-semibold text-slate-700">
{remoteAccessEntries.length === 1
? remoteAccessEntries[0].provider ?? "Configuração única"
: `${remoteAccessEntries.length} acessos`}
</Badge>
) : null}
</div>
{canManageRemoteAccess ? (
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed"
onClick={() => {
setEditingRemoteAccessClientId(null)
setRemoteAccessDialog(true)
}}
>
<Key className="size-4" />
{hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"}
</Button>
) : null}
</div>
{hasRemoteAccess ? (
<div className="space-y-3">
{remoteAccessEntries.map((entry) => {
const lastVerifiedDate =
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
? new Date(entry.lastVerifiedAt)
: null
const isRustDesk = isRustDeskAccess(entry)
const secretVisible = Boolean(visibleRemoteSecrets[entry.clientId])
return (
<div key={entry.clientId} className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-700">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{entry.provider ? (
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-semibold text-slate-700">
{entry.provider}
</Badge>
) : null}
{entry.identifier ? (
<span className="font-semibold text-neutral-800">{entry.identifier}</span>
) : null}
{entry.identifier ? (
<Button
variant="ghost"
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
title="Copiar ID"
aria-label="Copiar ID"
>
<ClipboardCopy className="size-3.5" />
</Button>
) : null}
</div>
{entry.username || entry.password ? (
<div className="flex flex-col gap-1">
{entry.username ? (
<div className="inline-flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Usuário</span>
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
{entry.username}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
title="Copiar usuário"
aria-label="Copiar usuário"
>
<ClipboardCopy className="size-3.5" />
</Button>
</div>
) : null}
{entry.password ? (
<div className="inline-flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Senha</span>
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
{secretVisible ? entry.password : "••••••••"}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => toggleRemoteSecret(entry.clientId)}
>
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
{secretVisible ? "Ocultar" : "Mostrar"}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
title="Copiar senha"
aria-label="Copiar senha"
>
<ClipboardCopy className="size-3.5" />
</Button>
</div>
) : null}
</div>
) : null}
{entry.url && !isRustDesk ? (
<a
href={entry.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-slate-600 underline-offset-4 hover:text-slate-900 hover:underline"
>
Abrir console remoto
</a>
) : null}
{isRustDesk && (entry.identifier || entry.password) ? (
<Button
variant="outline"
size="sm"
className="mt-1 inline-flex items-center gap-2 border-slate-300 bg-white text-slate-800 shadow-sm transition-colors hover:border-slate-400 hover:bg-slate-50 hover:text-slate-900 focus-visible:border-slate-400 focus-visible:ring-slate-200"
onClick={() => handleRustDeskConnect(entry)}
>
<MonitorSmartphone className="size-4 text-[#4b5563]" /> Conectar via RustDesk
</Button>
) : null}
{entry.notes ? (
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{entry.notes}</p>
) : null}
{lastVerifiedDate ? (
<p className="text-[11px] text-slate-500">
Atualizado {formatRelativeTime(lastVerifiedDate)}{" "}
<span className="text-slate-400">({formatAbsoluteDateTime(lastVerifiedDate)})</span>
</p>
) : null}
</div>
{canManageRemoteAccess ? (
<div className="flex items-start gap-2">
<Button
size="sm"
variant="outline"
className="gap-2 border-slate-300"
onClick={() => {
setEditingRemoteAccessClientId(entry.clientId)
setRemoteAccessDialog(true)
}}
>
<Pencil className="size-3.5" /> Editar
</Button>
<Button
size="sm"
variant="ghost"
className="gap-2 text-rose-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
onClick={() => void handleRemoveRemoteAccess(entry)}
disabled={remoteAccessSaving}
>
<ShieldOff className="size-3.5" /> Remover
</Button>
</div>
) : null}
</div>
</div>
)
})}
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-600">
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
</div>
)}
</div>
</section>
<section className="space-y-3 border-t border-slate-100 pt-6">
@ -4686,7 +4622,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
return (
<li key={`gpu-${idx}`}>
<span className="font-medium text-foreground">{name ?? "Adaptador de vídeo"}</span>
{memoryBytes ? <span className="ml-1 text-muted-foreground">{formatBytes(memoryBytes)}</span> : null}
{memoryBytes ? <span className="ml-1 text-muted-foreground">VRAM {formatBytes(memoryBytes)}</span> : null}
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
{driver ? <span className="ml-1 text-muted-foreground">· Driver {driver}</span> : null}
</li>