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:
parent
c5150fee8f
commit
23e7cf58ae
11 changed files with 863 additions and 441 deletions
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
7
bun.lock
7
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
151
docs/desktop/rustdesk-sync.md
Normal file
151
docs/desktop/rustdesk-sync.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue