Compare commits

...

8 Commits

Author SHA1 Message Date
Owen Schwartz
0391296181 New translations en-us.json (Italian) 2026-04-08 04:20:46 -04:00
Owen Schwartz
4de9afee41 New translations en-us.json (Italian) 2026-04-08 01:58:37 -04:00
Owen Schwartz
660c8fb6f7 New translations en-us.json (Italian) 2026-04-08 00:28:34 -04:00
Owen Schwartz
6c66053ebe New translations en-us.json (Italian) 2026-04-07 22:13:26 -04:00
Owen
28ef5238c9 Add CODEOWNERS 2026-04-07 11:36:02 -04:00
Owen
d948d2ec33 Try to prevent deadlocks 2026-04-03 22:55:04 -04:00
Owen
6b8a3c8d77 Revert #2570
Fix #2782
2026-04-03 22:37:42 -04:00
Owen
ba9794c067 Put middleware back
Fix #2781
2026-04-03 22:16:26 -04:00
6 changed files with 165 additions and 164 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @oschwartz10612 @miloschwartz

View File

@@ -86,6 +86,8 @@ entryPoints:
http: http:
tls: tls:
certResolver: "letsencrypt" certResolver: "letsencrypt"
middlewares:
- crowdsec@file
encodedCharacters: encodedCharacters:
allowEncodedSlash: true allowEncodedSlash: true
allowEncodedQuestionMark: true allowEncodedQuestionMark: true

View File

@@ -1,19 +1,19 @@
{ {
"setupCreate": "Creare l'organizzazione, il sito e le risorse", "setupCreate": "Creare l'organizzazione, il sito e le risorse",
"headerAuthCompatibilityInfo": "Abilita questo per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.", "headerAuthCompatibilityInfo": "Abilita questa funzionalità per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.",
"headerAuthCompatibility": "Compatibilità estesa", "headerAuthCompatibility": "Compatibilità estesa",
"setupNewOrg": "Nuova Organizzazione", "setupNewOrg": "Nuova Organizzazione",
"setupCreateOrg": "Crea Organizzazione", "setupCreateOrg": "Crea Organizzazione",
"setupCreateResources": "Crea Risorse", "setupCreateResources": "Crea Risorse",
"setupOrgName": "Nome Dell'Organizzazione", "setupOrgName": "Nome dell'Organizzazione",
"orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.", "orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.",
"orgId": "Id Organizzazione", "orgId": "Id Organizzazione",
"setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.", "setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.",
"setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.", "setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.",
"componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.", "componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.",
"componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.", "componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.",
"welcome": "Benvenuti a Pangolin", "welcome": "Benvenuto su Pangolin!",
"welcomeTo": "Benvenuto a", "welcomeTo": "Benvenuto su Pangolin!",
"componentsCreateOrg": "Crea un'organizzazione", "componentsCreateOrg": "Crea un'organizzazione",
"componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.", "componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.",
"componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.", "componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.",
@@ -27,7 +27,7 @@
"inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.", "inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.",
"inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.", "inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.",
"inviteCreateUser": "Si prega di creare un account prima.", "inviteCreateUser": "Si prega di creare un account prima.",
"goHome": "Vai A Home", "goHome": "Vai alla Home",
"inviteLogInOtherUser": "Accedi come utente diverso", "inviteLogInOtherUser": "Accedi come utente diverso",
"createAnAccount": "Crea un account", "createAnAccount": "Crea un account",
"inviteNotAccepted": "Invito Non Accettato", "inviteNotAccepted": "Invito Non Accettato",
@@ -51,7 +51,7 @@
"edit": "Modifica", "edit": "Modifica",
"siteConfirmDelete": "Conferma Eliminazione Sito", "siteConfirmDelete": "Conferma Eliminazione Sito",
"siteDelete": "Elimina Sito", "siteDelete": "Elimina Sito",
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli obiettivi associati al sito verranno rimossi.", "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.",
"siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?",
"siteManageSites": "Gestisci Siti", "siteManageSites": "Gestisci Siti",
"siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private",
@@ -75,9 +75,9 @@
"siteLoadWGConfig": "Caricamento configurazione WireGuard...", "siteLoadWGConfig": "Caricamento configurazione WireGuard...",
"siteDocker": "Espandi per i dettagli di distribuzione Docker", "siteDocker": "Espandi per i dettagli di distribuzione Docker",
"toggle": "Attiva/disattiva", "toggle": "Attiva/disattiva",
"dockerCompose": "Composizione Docker", "dockerCompose": "Docker Compose",
"dockerRun": "Corsa Docker", "dockerRun": "Corsa Docker",
"siteLearnLocal": "I siti locali non tunnel, saperne di più", "siteLearnLocal": "I siti locali non effettuano il tunnel, per saperne di più",
"siteConfirmCopy": "Ho copiato la configurazione", "siteConfirmCopy": "Ho copiato la configurazione",
"searchSitesProgress": "Cerca siti...", "searchSitesProgress": "Cerca siti...",
"siteAdd": "Aggiungi Sito", "siteAdd": "Aggiungi Sito",
@@ -88,29 +88,29 @@
"operatingSystem": "Sistema Operativo", "operatingSystem": "Sistema Operativo",
"commands": "Comandi", "commands": "Comandi",
"recommended": "Consigliato", "recommended": "Consigliato",
"siteNewtDescription": "Per la migliore esperienza utente, utilizzare Newt. Utilizza WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.", "siteNewtDescription": "Per la migliore esperienza utente utilizzare Newt, che usa WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.",
"siteRunsInDocker": "Esegue nel Docker", "siteRunsInDocker": "Esegue nel Docker",
"siteRunsInShell": "Esegue in shell su macOS, Linux e Windows", "siteRunsInShell": "Esegue in shell su macOS, Linux e Windows",
"siteErrorDelete": "Errore nell'eliminare il sito", "siteErrorDelete": "Errore nella eliminazione del sito",
"siteErrorUpdate": "Impossibile aggiornare il sito", "siteErrorUpdate": "Impossibile aggiornare il sito",
"siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.", "siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.",
"siteUpdated": "Sito aggiornato", "siteUpdated": "Sito aggiornato",
"siteUpdatedDescription": "Il sito è stato aggiornato.", "siteUpdatedDescription": "Il sito è stato aggiornato.",
"siteGeneralDescription": "Configura le impostazioni generali per questo sito", "siteGeneralDescription": "Configura le impostazioni generali per questo sito",
"siteSettingDescription": "Configura le impostazioni del sito", "siteSettingDescription": "Configura le impostazioni del sito",
"siteSetting": "Impostazioni {siteName}", "siteSetting": "Impostazioni del sito {siteName}",
"siteNewtTunnel": "Nuovo Sito (Consigliato)", "siteNewtTunnel": "Nuovo Sito (Consigliato)",
"siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.", "siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.",
"siteWg": "WireGuard Base", "siteWg": "WireGuard Base",
"siteWgDescription": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.", "siteWgDescription": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
"siteWgDescriptionSaas": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta. FUNZIONA SOLO SU NODI AUTO-OSPITATI", "siteWgDescriptionSaas": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
"siteLocalDescription": "Solo risorse locali. Nessun tunneling.", "siteLocalDescription": "Solo risorse locali. Nessun tunneling.",
"siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.", "siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.",
"siteSeeAll": "Vedi Tutti I Siti", "siteSeeAll": "Vedi Tutti I Siti",
"siteTunnelDescription": "Determinare come si desidera connettersi al sito", "siteTunnelDescription": "Selezionare la modalità con la quale si desidera connettersi al sito",
"siteNewtCredentials": "Credenziali", "siteNewtCredentials": "Credenziali",
"siteNewtCredentialsDescription": "Questo è come il sito si autenticerà con il server", "siteNewtCredentialsDescription": "Questo è come il sito si autenticherà con il server",
"remoteNodeCredentialsDescription": "Questo è come il nodo remoto si autenticherà con il server", "remoteNodeCredentialsDescription": "Questo è il modo in cui il nodo remoto si autenticherà con il server",
"siteCredentialsSave": "Salva le credenziali", "siteCredentialsSave": "Salva le credenziali",
"siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", "siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.",
"siteInfo": "Informazioni Sito", "siteInfo": "Informazioni Sito",
@@ -140,8 +140,8 @@
"shareCreateDescription": "Chiunque con questo link può accedere alla risorsa", "shareCreateDescription": "Chiunque con questo link può accedere alla risorsa",
"shareTitleOptional": "Titolo (facoltativo)", "shareTitleOptional": "Titolo (facoltativo)",
"expireIn": "Scadenza In", "expireIn": "Scadenza In",
"neverExpire": "Mai scadere", "neverExpire": "Nessuna scadenza",
"shareExpireDescription": "Il tempo di scadenza è per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", "shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.",
"shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.", "shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.",
"shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.", "shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.",
"shareTokenUsage": "Vedi Utilizzo Token Di Accesso", "shareTokenUsage": "Vedi Utilizzo Token Di Accesso",
@@ -161,9 +161,9 @@
"never": "Mai", "never": "Mai",
"shareErrorSelectResource": "Seleziona una risorsa", "shareErrorSelectResource": "Seleziona una risorsa",
"proxyResourceTitle": "Gestisci Risorse Pubbliche", "proxyResourceTitle": "Gestisci Risorse Pubbliche",
"proxyResourceDescription": "Creare e gestire risorse accessibili al pubblico tramite un browser web", "proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
"proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web", "proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", "proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
"clientResourceTitle": "Gestisci Risorse Private", "clientResourceTitle": "Gestisci Risorse Private",
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust", "privateResourcesBannerTitle": "Accesso Privato Zero-Trust",
@@ -174,12 +174,12 @@
"authentication": "Autenticazione", "authentication": "Autenticazione",
"protected": "Protetto", "protected": "Protetto",
"notProtected": "Non Protetto", "notProtected": "Non Protetto",
"resourceMessageRemove": "Una volta rimossa, la risorsa non sarà più accessibile. Tutti gli obiettivi associati alla risorsa saranno rimossi.", "resourceMessageRemove": "Una volta rimossa la risorsa non sarà più accessibile. Tutti gli oggetti target associati alla risorsa saranno rimossi.",
"resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?", "resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?",
"resourceHTTP": "Risorsa HTTPS", "resourceHTTP": "Risorsa HTTPS",
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.", "resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
"resourceRaw": "Risorsa Raw TCP/UDP", "resourceRaw": "Risorsa Raw TCP/UDP",
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.", "resourceRawDescription": "Richieste proxy su TCP/UDP raw utilizzando un numero di porta.",
"resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.", "resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.",
"resourceCreate": "Crea Risorsa", "resourceCreate": "Crea Risorsa",
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa", "resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
@@ -192,7 +192,7 @@
"selectCountry": "Seleziona paese", "selectCountry": "Seleziona paese",
"searchCountries": "Cerca paesi...", "searchCountries": "Cerca paesi...",
"noCountryFound": "Nessun paese trovato.", "noCountryFound": "Nessun paese trovato.",
"siteSelectionDescription": "Questo sito fornirà connettività all'obiettivo.", "siteSelectionDescription": "Questo sito fornirà connettività all'oggetto target.",
"resourceType": "Tipo Di Risorsa", "resourceType": "Tipo Di Risorsa",
"resourceTypeDescription": "Determinare come accedere alla risorsa", "resourceTypeDescription": "Determinare come accedere alla risorsa",
"resourceHTTPSSettings": "Impostazioni HTTPS", "resourceHTTPSSettings": "Impostazioni HTTPS",
@@ -206,13 +206,13 @@
"protocol": "Protocollo", "protocol": "Protocollo",
"protocolSelect": "Seleziona un protocollo", "protocolSelect": "Seleziona un protocollo",
"resourcePortNumber": "Numero Porta", "resourcePortNumber": "Numero Porta",
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.", "resourcePortNumberDescription": "Il numero di porta esterna per le richieste proxy.",
"back": "Indietro", "back": "Indietro",
"cancel": "Annulla", "cancel": "Annulla",
"resourceConfig": "Snippet Di Configurazione", "resourceConfig": "Snippet Di Configurazione",
"resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP", "resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP",
"resourceAddEntrypoints": "Traefik: Aggiungi Ingresso", "resourceAddEntrypoints": "Traefik: Aggiungi Entrypoint",
"resourceExposePorts": "Gerbil: espone le porte in Docker componi", "resourceExposePorts": "Gerbil: espone le porte in Docker Compose",
"resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP", "resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP",
"resourceBack": "Torna alle risorse", "resourceBack": "Torna alle risorse",
"resourceGoTo": "Vai alla Risorsa", "resourceGoTo": "Vai alla Risorsa",
@@ -228,7 +228,7 @@
"rules": "Regole", "rules": "Regole",
"resourceSettingDescription": "Configura le impostazioni sulla risorsa", "resourceSettingDescription": "Configura le impostazioni sulla risorsa",
"resourceSetting": "Impostazioni {resourceName}", "resourceSetting": "Impostazioni {resourceName}",
"alwaysAllow": "Autenticazione Bypass", "alwaysAllow": "Bypass Autenticazione",
"alwaysDeny": "Blocca Accesso", "alwaysDeny": "Blocca Accesso",
"passToAuth": "Passa all'autenticazione", "passToAuth": "Passa all'autenticazione",
"orgSettingsDescription": "Configura le impostazioni dell'organizzazione", "orgSettingsDescription": "Configura le impostazioni dell'organizzazione",
@@ -237,11 +237,11 @@
"saveGeneralSettings": "Salva Impostazioni Generali", "saveGeneralSettings": "Salva Impostazioni Generali",
"saveSettings": "Salva Impostazioni", "saveSettings": "Salva Impostazioni",
"orgDangerZone": "Zona Pericolosa", "orgDangerZone": "Zona Pericolosa",
"orgDangerZoneDescription": "Una volta che si elimina questo org, non c'è ritorno. Si prega di essere certi.", "orgDangerZoneDescription": "Una volta che si elimina questa org non sarà possibile tornare indietro, assicurarsi quindi di essere certi della decisione.",
"orgDelete": "Elimina Organizzazione", "orgDelete": "Elimina Organizzazione",
"orgDeleteConfirm": "Conferma Elimina Organizzazione", "orgDeleteConfirm": "Conferma Elimina Organizzazione",
"orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.", "orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.",
"orgMessageConfirm": "Per confermare, digita il nome dell'organizzazione qui sotto.", "orgMessageConfirm": "Per confermare digita il nome dell'organizzazione qui sotto.",
"orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?", "orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?",
"orgUpdated": "Organizzazione aggiornata", "orgUpdated": "Organizzazione aggiornata",
"orgUpdatedDescription": "L'organizzazione è stata aggiornata.", "orgUpdatedDescription": "L'organizzazione è stata aggiornata.",
@@ -254,10 +254,10 @@
"orgDeleted": "Organizzazione eliminata", "orgDeleted": "Organizzazione eliminata",
"orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.", "orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.",
"deleteAccount": "Elimina Account", "deleteAccount": "Elimina Account",
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", "deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
"deleteAccountButton": "Elimina Account", "deleteAccountButton": "Elimina Account",
"deleteAccountConfirmTitle": "Elimina Account", "deleteAccountConfirmTitle": "Elimina Account",
"deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", "deleteAccountConfirmMessage": "Questa operazione cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
"deleteAccountConfirmString": "elimina account", "deleteAccountConfirmString": "elimina account",
"deleteAccountSuccess": "Account Eliminato", "deleteAccountSuccess": "Account Eliminato",
"deleteAccountSuccessMessage": "Il tuo account è stato eliminato.", "deleteAccountSuccessMessage": "Il tuo account è stato eliminato.",
@@ -272,7 +272,7 @@
"accessUserCreate": "Crea Utente", "accessUserCreate": "Crea Utente",
"accessUserRemove": "Rimuovi Utente", "accessUserRemove": "Rimuovi Utente",
"username": "Nome utente", "username": "Nome utente",
"identityProvider": "Provider Di Identità", "identityProvider": "Provider Identità",
"role": "Ruolo", "role": "Ruolo",
"nameRequired": "Il nome è obbligatorio", "nameRequired": "Il nome è obbligatorio",
"accessRolesManage": "Gestisci Ruoli", "accessRolesManage": "Gestisci Ruoli",
@@ -328,8 +328,8 @@
"apiKeysDelete": "Elimina Chiave API", "apiKeysDelete": "Elimina Chiave API",
"apiKeysManage": "Gestisci Chiavi API", "apiKeysManage": "Gestisci Chiavi API",
"apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione", "apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione",
"provisioningKeysTitle": "Chiave Di Provvedimento", "provisioningKeysTitle": "Chiave di provisioning",
"provisioningKeysManage": "Gestisci Chiavi Di Provvedimento", "provisioningKeysManage": "Gestisci Chiavi di provisioning",
"provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.", "provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.",
"provisioningManage": "Accantonamento", "provisioningManage": "Accantonamento",
"provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.", "provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.",
@@ -337,25 +337,25 @@
"siteApproveSuccess": "Sito approvato con successo", "siteApproveSuccess": "Sito approvato con successo",
"siteApproveError": "Errore nell'approvazione del sito", "siteApproveError": "Errore nell'approvazione del sito",
"provisioningKeys": "Chiavi Di Provvedimento", "provisioningKeys": "Chiavi Di Provvedimento",
"searchProvisioningKeys": "Cerca i tasti di provisioning ...", "searchProvisioningKeys": "Cerca le chiavi di provisioning...",
"provisioningKeysAdd": "Genera Chiave Di Provvedimento", "provisioningKeysAdd": "Genera Chiave di provisioning",
"provisioningKeysErrorDelete": "Errore nell'eliminare la chiave di provisioning", "provisioningKeysErrorDelete": "Errore nell'eliminazione della chiave di provisioning",
"provisioningKeysErrorDeleteMessage": "Errore nell'eliminare la chiave di provisioning", "provisioningKeysErrorDeleteMessage": "Errore nell'eliminazione della chiave di provisioning",
"provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?", "provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?",
"provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.", "provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.",
"provisioningKeysDeleteConfirm": "Conferma Elimina Chiave Provvisoria", "provisioningKeysDeleteConfirm": "Conferma Eliminazione della chiave di provisioning",
"provisioningKeysDelete": "Elimina chiave di provisioning", "provisioningKeysDelete": "Elimina chiave di provisioning",
"provisioningKeysCreate": "Genera Chiave Di Provvedimento", "provisioningKeysCreate": "Genera Chiave di provisioning",
"provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione", "provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione",
"provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning", "provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning",
"provisioningKeysSave": "Salva la chiave di provisioning", "provisioningKeysSave": "Salva la chiave di provisioning",
"provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.", "provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.",
"provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning", "provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning",
"provisioningKeysList": "Nuova chiave di provisioning", "provisioningKeysList": "Nuova chiave di provisioning",
"provisioningKeysMaxBatchSize": "Dimensione massima lotto", "provisioningKeysMaxBatchSize": "Dimensione massima batch",
"provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del lotto (nessun limite)", "provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del batch (nessun limite)",
"provisioningKeysMaxBatchUnlimited": "Illimitato", "provisioningKeysMaxBatchUnlimited": "Illimitato",
"provisioningKeysMaxBatchSizeInvalid": "Inserisci un lotto massimo valido (11.000.000).", "provisioningKeysMaxBatchSizeInvalid": "Inserisci una dimensione massima valida del batch (11.000.000).",
"provisioningKeysValidUntil": "Valido fino al", "provisioningKeysValidUntil": "Valido fino al",
"provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.", "provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.",
"provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.", "provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.",
@@ -363,14 +363,14 @@
"provisioningKeysLastUsed": "Ultimo utilizzo", "provisioningKeysLastUsed": "Ultimo utilizzo",
"provisioningKeysNoExpiry": "Nessuna scadenza", "provisioningKeysNoExpiry": "Nessuna scadenza",
"provisioningKeysNeverUsed": "Mai", "provisioningKeysNeverUsed": "Mai",
"provisioningKeysEdit": "Modifica Chiave Di Provvedimento", "provisioningKeysEdit": "Modifica Chiave di provisioning",
"provisioningKeysEditDescription": "Aggiorna la dimensione massima del lotto e il tempo di scadenza per questa chiave.", "provisioningKeysEditDescription": "Aggiorna la dimensione massima del batch e il tempo di scadenza per questa chiave.",
"provisioningKeysApproveNewSites": "Approva nuovi siti", "provisioningKeysApproveNewSites": "Approva nuovi siti",
"provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.", "provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.",
"provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning", "provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning",
"provisioningKeysUpdated": "Chiave di accantonamento aggiornata", "provisioningKeysUpdated": "Chiave di provisioning aggiornata",
"provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.", "provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.",
"provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito", "provisioningKeysBannerTitle": "Chiavi di provisioning del Sito",
"provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.", "provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.",
"provisioningKeysBannerButtonText": "Scopri di più", "provisioningKeysBannerButtonText": "Scopri di più",
"pendingSitesBannerTitle": "Siti In Attesa", "pendingSitesBannerTitle": "Siti In Attesa",
@@ -386,7 +386,7 @@
"userErrorDelete": "Errore nell'eliminare l'utente", "userErrorDelete": "Errore nell'eliminare l'utente",
"userDeleteConfirm": "Conferma Eliminazione Utente", "userDeleteConfirm": "Conferma Eliminazione Utente",
"userDeleteServer": "Elimina utente dal server", "userDeleteServer": "Elimina utente dal server",
"userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni ed essere completamente rimosso dal server.", "userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni e verrà completamente rimosso dal server.",
"userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?", "userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?",
"licenseKey": "Chiave Di Licenza", "licenseKey": "Chiave Di Licenza",
"valid": "Valido", "valid": "Valido",
@@ -404,9 +404,9 @@
"licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.", "licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.",
"licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita", "licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita",
"licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.", "licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.",
"licenseAbout": "Informazioni Su Licenze", "licenseAbout": "Informazioni sul Licensing",
"communityEdition": "Edizione Community", "communityEdition": "Edizione Community",
"licenseAboutDescription": "Questo è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.", "licenseAboutDescription": "Questa sezione è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.",
"licenseKeyActivated": "Chiave di licenza attivata", "licenseKeyActivated": "Chiave di licenza attivata",
"licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.", "licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.",
"licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza", "licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza",
@@ -429,7 +429,7 @@
"licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.", "licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.",
"licensedNot": "Non Licenziato", "licensedNot": "Non Licenziato",
"hostId": "ID Host", "hostId": "ID Host",
"licenseReckeckAll": "Ricontrolla Tutte Le Tasti", "licenseReckeckAll": "Ricontrolla Tutte le chiavi",
"licenseSiteUsage": "Utilizzo Siti", "licenseSiteUsage": "Utilizzo Siti",
"licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.", "licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.",
"licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.", "licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.",
@@ -480,7 +480,7 @@
"userOrgRemoved": "Utente rimosso", "userOrgRemoved": "Utente rimosso",
"userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.", "userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.",
"userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?", "userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?",
"userMessageOrgRemove": "Una volta rimosso, questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.", "userMessageOrgRemove": "Una volta rimosso questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
"userRemoveOrgConfirm": "Conferma Rimozione Utente", "userRemoveOrgConfirm": "Conferma Rimozione Utente",
"userRemoveOrg": "Rimuovi Utente dall'Organizzazione", "userRemoveOrg": "Rimuovi Utente dall'Organizzazione",
"users": "Utenti", "users": "Utenti",
@@ -532,13 +532,13 @@
"approve": "Approva", "approve": "Approva",
"approved": "Approvato", "approved": "Approvato",
"denied": "Negato", "denied": "Negato",
"deniedApproval": "Omologazione Negata", "deniedApproval": "Approvazione Negata",
"all": "Tutti", "all": "Tutti",
"deny": "Nega", "deny": "Nega",
"viewDetails": "Visualizza Dettagli", "viewDetails": "Visualizza Dettagli",
"requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo", "requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo",
"resetFilters": "Ripristina Filtri", "resetFilters": "Ripristina Filtri",
"totalBlocked": "Richieste Bloccate Da Pangolino", "totalBlocked": "Richieste Bloccate Da Pangolin",
"totalRequests": "Totale Richieste", "totalRequests": "Totale Richieste",
"requestsByCountry": "Richieste Per Paese", "requestsByCountry": "Richieste Per Paese",
"requestsByDay": "Richieste Per Giorno", "requestsByDay": "Richieste Per Giorno",
@@ -546,7 +546,7 @@
"allowed": "Consentito", "allowed": "Consentito",
"topCountries": "Paesi Principali", "topCountries": "Paesi Principali",
"accessRoleSelect": "Seleziona ruolo", "accessRoleSelect": "Seleziona ruolo",
"inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. Devono accedere al link per accettare l'invito.", "inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. L'utente deve accedere al link per accettare l'invito.",
"inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.", "inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.",
"inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.", "inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.",
"idpTitle": "Informazioni Generali", "idpTitle": "Informazioni Generali",
@@ -562,7 +562,7 @@
"userSaved": "Utente salvato", "userSaved": "Utente salvato",
"userSavedDescription": "L'utente è stato aggiornato.", "userSavedDescription": "L'utente è stato aggiornato.",
"autoProvisioned": "Auto Provisioned", "autoProvisioned": "Auto Provisioned",
"autoProvisionSettings": "Impostazioni Automatiche Di Fornitura", "autoProvisionSettings": "Impostazioni Automatiche di provisioning",
"autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità", "autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità",
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione", "accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
"accessControlsSubmit": "Salva Controlli di Accesso", "accessControlsSubmit": "Salva Controlli di Accesso",
@@ -576,9 +576,9 @@
"proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.", "proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.",
"proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.", "proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.",
"proxyEnableSSL": "Abilita SSL", "proxyEnableSSL": "Abilita SSL",
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure agli obiettivi.", "proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.",
"target": "Target", "target": "Target",
"configureTarget": "Configura Obiettivi", "configureTarget": "Configura Risorse Interne",
"targetErrorFetch": "Impossibile recuperare i target", "targetErrorFetch": "Impossibile recuperare i target",
"targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target", "targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target",
"siteErrorFetch": "Impossibile recuperare la risorsa", "siteErrorFetch": "Impossibile recuperare la risorsa",

View File

@@ -479,10 +479,7 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER // TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return ( return (
@@ -610,10 +607,7 @@ export async function getTraefikConfig(
servers: (() => { servers: (() => {
// Check if any sites are online // Check if any sites are online
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return targets return targets

View File

@@ -671,10 +671,7 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER // TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return ( return (
@@ -802,10 +799,7 @@ export async function getTraefikConfig(
servers: (() => { servers: (() => {
// Check if any sites are online // Check if any sites are online
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => (target) => target.site.online
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return targets return targets

View File

@@ -1,6 +1,6 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { sites, clients, olms } from "@server/db"; import { sites, clients, olms } from "@server/db";
import { eq, inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
/** /**
@@ -21,7 +21,7 @@ import logger from "@server/logger";
*/ */
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 2; const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
@@ -36,6 +36,14 @@ const pendingOlmArchiveResets: Set<string> = new Set();
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
/**
* Guard that prevents two flush cycles from running concurrently.
* setInterval does not await async callbacks, so without this a slow flush
* (e.g. due to DB latency) would overlap with the next scheduled cycle and
* the two concurrent bulk UPDATEs would deadlock each other.
*/
let isFlushing = false;
// ── Public API ───────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────
/** /**
@@ -72,6 +80,12 @@ export function recordClientPing(
/** /**
* Flush all accumulated site pings to the database. * Flush all accumulated site pings to the database.
*
* Each batch of up to BATCH_SIZE rows is written with a **single** UPDATE
* statement. We use the maximum timestamp across the batch so that `lastPing`
* reflects the most recent ping seen for any site in the group. This avoids
* the multi-statement transaction that previously created additional
* row-lock ordering hazards.
*/ */
async function flushSitePingsToDb(): Promise<void> { async function flushSitePingsToDb(): Promise<void> {
if (pendingSitePings.size === 0) { if (pendingSitePings.size === 0) {
@@ -83,55 +97,35 @@ async function flushSitePingsToDb(): Promise<void> {
const pingsToFlush = new Map(pendingSitePings); const pingsToFlush = new Map(pendingSitePings);
pendingSitePings.clear(); pendingSitePings.clear();
// Sort by siteId for consistent lock ordering (prevents deadlocks) const entries = Array.from(pingsToFlush.entries());
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
([a], [b]) => a - b
);
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) { for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = sortedEntries.slice(i, i + BATCH_SIZE); const batch = entries.slice(i, i + BATCH_SIZE);
// Use the latest timestamp in the batch so that `lastPing` always
// moves forward. Using a single timestamp for the whole batch means
// we only ever need one UPDATE statement (no transaction).
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
const siteIds = batch.map(([id]) => id);
try { try {
await withRetry(async () => { await withRetry(async () => {
// Group by timestamp for efficient bulk updates await db
const byTimestamp = new Map<number, number[]>(); .update(sites)
for (const [siteId, timestamp] of batch) { .set({
const group = byTimestamp.get(timestamp) || []; online: true,
group.push(siteId); lastPing: maxTimestamp
byTimestamp.set(timestamp, group); })
} .where(inArray(sites.siteId, siteIds));
if (byTimestamp.size === 1) {
const [timestamp, siteIds] = Array.from(
byTimestamp.entries()
)[0];
await db
.update(sites)
.set({
online: true,
lastPing: timestamp
})
.where(inArray(sites.siteId, siteIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, siteIds] of byTimestamp) {
await tx
.update(sites)
.set({
online: true,
lastPing: timestamp
})
.where(inArray(sites.siteId, siteIds));
}
});
}
}, "flushSitePingsToDb"); }, "flushSitePingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`, `Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
{ error } { error }
); );
// Re-queue only if the preserved timestamp is newer than any
// update that may have landed since we snapshotted.
for (const [siteId, timestamp] of batch) { for (const [siteId, timestamp] of batch) {
const existing = pendingSitePings.get(siteId); const existing = pendingSitePings.get(siteId);
if (!existing || existing < timestamp) { if (!existing || existing < timestamp) {
@@ -144,6 +138,8 @@ async function flushSitePingsToDb(): Promise<void> {
/** /**
* Flush all accumulated client (OLM) pings to the database. * Flush all accumulated client (OLM) pings to the database.
*
* Same single-UPDATE-per-batch approach as `flushSitePingsToDb`.
*/ */
async function flushClientPingsToDb(): Promise<void> { async function flushClientPingsToDb(): Promise<void> {
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) { if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
@@ -159,51 +155,25 @@ async function flushClientPingsToDb(): Promise<void> {
// ── Flush client pings ───────────────────────────────────────────── // ── Flush client pings ─────────────────────────────────────────────
if (pingsToFlush.size > 0) { if (pingsToFlush.size > 0) {
const sortedEntries = Array.from(pingsToFlush.entries()).sort( const entries = Array.from(pingsToFlush.entries());
([a], [b]) => a - b
);
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) { for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = sortedEntries.slice(i, i + BATCH_SIZE); const batch = entries.slice(i, i + BATCH_SIZE);
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
const clientIds = batch.map(([id]) => id);
try { try {
await withRetry(async () => { await withRetry(async () => {
const byTimestamp = new Map<number, number[]>(); await db
for (const [clientId, timestamp] of batch) { .update(clients)
const group = byTimestamp.get(timestamp) || []; .set({
group.push(clientId); lastPing: maxTimestamp,
byTimestamp.set(timestamp, group); online: true,
} archived: false
})
if (byTimestamp.size === 1) { .where(inArray(clients.clientId, clientIds));
const [timestamp, clientIds] = Array.from(
byTimestamp.entries()
)[0];
await db
.update(clients)
.set({
lastPing: timestamp,
online: true,
archived: false
})
.where(inArray(clients.clientId, clientIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, clientIds] of byTimestamp) {
await tx
.update(clients)
.set({
lastPing: timestamp,
online: true,
archived: false
})
.where(
inArray(clients.clientId, clientIds)
);
}
});
}
}, "flushClientPingsToDb"); }, "flushClientPingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -260,7 +230,12 @@ export async function flushPingsToDb(): Promise<void> {
/** /**
* Simple retry wrapper with exponential backoff for transient errors * Simple retry wrapper with exponential backoff for transient errors
* (connection timeouts, unexpected disconnects). * (deadlocks, connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/ */
async function withRetry<T>( async function withRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
@@ -277,7 +252,8 @@ async function withRetry<T>(
const jitter = Math.random() * baseDelay; const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` `Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
); );
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;
@@ -288,14 +264,14 @@ async function withRetry<T>(
} }
/** /**
* Detect transient connection errors that are safe to retry. * Detect transient errors that are safe to retry.
*/ */
function isTransientError(error: any): boolean { function isTransientError(error: any): boolean {
if (!error) return false; if (!error) return false;
const message = (error.message || "").toLowerCase(); const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase(); const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || ""; const code = error.code || error.cause?.code || "";
// Connection timeout / terminated // Connection timeout / terminated
if ( if (
@@ -308,12 +284,17 @@ function isTransientError(error: any): boolean {
return true; return true;
} }
// PostgreSQL deadlock // PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) { if (code === "40P01" || message.includes("deadlock")) {
return true; return true;
} }
// ECONNRESET, ECONNREFUSED, EPIPE // PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if ( if (
code === "ECONNRESET" || code === "ECONNRESET" ||
code === "ECONNREFUSED" || code === "ECONNREFUSED" ||
@@ -337,12 +318,26 @@ export function startPingAccumulator(): void {
} }
flushTimer = setInterval(async () => { flushTimer = setInterval(async () => {
// Skip this tick if the previous flush is still in progress.
// setInterval does not await async callbacks, so without this guard
// two flush cycles can run concurrently and deadlock each other on
// overlapping bulk UPDATE statements.
if (isFlushing) {
logger.debug(
"Ping accumulator: previous flush still in progress, skipping cycle"
);
return;
}
isFlushing = true;
try { try {
await flushPingsToDb(); await flushPingsToDb();
} catch (error) { } catch (error) {
logger.error("Unhandled error in ping accumulator flush", { logger.error("Unhandled error in ping accumulator flush", {
error error
}); });
} finally {
isFlushing = false;
} }
}, FLUSH_INTERVAL_MS); }, FLUSH_INTERVAL_MS);
@@ -364,7 +359,22 @@ export async function stopPingAccumulator(): Promise<void> {
flushTimer = null; flushTimer = null;
} }
// Final flush to persist any remaining pings // Final flush to persist any remaining pings.
// Wait for any in-progress flush to finish first so we don't race.
if (isFlushing) {
logger.debug(
"Ping accumulator: waiting for in-progress flush before stopping…"
);
await new Promise<void>((resolve) => {
const poll = setInterval(() => {
if (!isFlushing) {
clearInterval(poll);
resolve();
}
}, 50);
});
}
try { try {
await flushPingsToDb(); await flushPingsToDb();
} catch (error) { } catch (error) {