mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-09 09:49:51 +00:00
Compare commits
46 Commits
dependabot
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fb677e952 | ||
|
|
88d8414eb8 | ||
|
|
5f3fafb1b0 | ||
|
|
de1338a8cd | ||
|
|
0800aa2a61 | ||
|
|
4959d66ac1 | ||
|
|
9320df8be6 | ||
|
|
13ec6b6620 | ||
|
|
2ca3ef019c | ||
|
|
724e41a54f | ||
|
|
ce5e62d216 | ||
|
|
874dc2b33e | ||
|
|
3b2622d590 | ||
|
|
c81d855741 | ||
|
|
3bce8d3596 | ||
|
|
ee2a1e2bc3 | ||
|
|
a0f3ee74f9 | ||
|
|
82a36fd632 | ||
|
|
c5084137ab | ||
|
|
65ec8da100 | ||
|
|
e76e7581a5 | ||
|
|
a97a4b6ec1 | ||
|
|
e38bbde348 | ||
|
|
026260ddfb | ||
|
|
97be5eb7d5 | ||
|
|
d7b96ba3f5 | ||
|
|
b42672530f | ||
|
|
b6b2dbd8ab | ||
|
|
975f3a01f5 | ||
|
|
4de2dfff85 | ||
|
|
27d230647f | ||
|
|
114486608e | ||
|
|
10fa9274d0 | ||
|
|
cbdc74768f | ||
|
|
10f95896aa | ||
|
|
5b8994d143 | ||
|
|
c46ef2fe9c | ||
|
|
4cd025dd91 | ||
|
|
ce04ea9720 | ||
|
|
a3ce382725 | ||
|
|
4eb49e3e60 | ||
|
|
2a9481023a | ||
|
|
8ed01372b8 | ||
|
|
6a7d4fd385 | ||
|
|
7bc08c0425 | ||
|
|
451f3d24a8 |
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Пресочвайте събития директно към вашият акаунт в Datadog. Очаквайте скоро.",
|
"streamingDatadogDescription": "Пресочвайте събития директно към вашият акаунт в Datadog. Очаквайте скоро.",
|
||||||
"streamingTypePickerDescription": "Изберете вид на дестинацията, за да започнете.",
|
"streamingTypePickerDescription": "Изберете вид на дестинацията, за да започнете.",
|
||||||
"streamingFailedToLoad": "Неуспешно зареждане на дестинации",
|
"streamingLastSyncError": "Възникна грешка при последната синхронизация",
|
||||||
"streamingUnexpectedError": "Възникна неочаквана грешка.",
|
"streamingUnexpectedError": "Възникна неочаквана грешка.",
|
||||||
"streamingFailedToUpdate": "Неуспешно актуализиране на дестинация",
|
"streamingFailedToUpdate": "Неуспешно актуализиране на дестинация",
|
||||||
"streamingDeletedSuccess": "Дестинацията беше изтрита успешно",
|
"streamingDeletedSuccess": "Дестинацията беше изтрита успешно",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Редактиране на дестинацията",
|
"S3DestEditTitle": "Редактиране на дестинацията",
|
||||||
"S3DestAddTitle": "Добавете S3 дестинация",
|
"S3DestAddTitle": "Добавете S3 дестинация",
|
||||||
"S3DestEditDescription": "Актуализирайте конфигурацията за тази S3 дестинация за предаване на събития.",
|
"S3DestEditDescription": "Актуализирайте конфигурацията за тази S3 дестинация за предаване на събития.",
|
||||||
"S3DestAddDescription": "Конфигурирайте нов крайна точка на S3, за да получавате събития на вашата организация.",
|
"S3DestAddDescription": "Конфигурирайте ново хранилище Amazon S3 (или съвместимо с S3), за да получавате събития на вашата организация.",
|
||||||
|
"s3DestTabSettings": "Настройки",
|
||||||
|
"s3DestTabFormat": "Формат",
|
||||||
|
"s3DestNameLabel": "Име",
|
||||||
|
"s3DestNamePlaceholder": "Моята S3 дестинация",
|
||||||
|
"s3DestAccessKeyIdLabel": "Идентификатор на достъп за AWS Key ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "Тайният ключ за достъп на AWS",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Вашият таен ключ за достъп за AWS",
|
||||||
|
"s3DestRegionLabel": "AWS Регион",
|
||||||
|
"s3DestBucketLabel": "Име на хранилище",
|
||||||
|
"s3DestPrefixLabel": "Префикс на ключ (по избор)",
|
||||||
|
"s3DestPrefixDescription": "По избор пътеводен префикс, добавен към всеки обектен ключ. Обектите се съхраняват в {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Потребителски крайна точка (по избор)",
|
||||||
|
"s3DestEndpointDescription": "Заместете крайната точка на S3 за съвместимо с S3 хранилище като MinIO или Cloudflare R2. Оставете празно за стандартното AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Gzip компресия",
|
||||||
|
"s3DestGzipDescription": "Компресирайте всеки качен обект с gzip. Намалява разходите за съхранение и размера на качването.",
|
||||||
|
"s3DestFormatTitle": "Формат на файл",
|
||||||
|
"s3DestFormatDescription": "Как събитията са сериализирани вътре във всеки качен обект.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Всеки обект е JSON масив от записи на събития. Съвместим с повечето аналитични инструменти.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Всеки обект съдържа един JSON запис на ред (форматиран JSON с нов ред). Съвместим с Athena, BigQuery и Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Всеки обект е RFC-4180 CSV файл с ред заглавие. Имената на колоните са извлечени от полетата на данните за събитията.",
|
||||||
|
"s3DestSaveChanges": "Запази промените",
|
||||||
|
"s3DestCreateDestination": "Създаване на дестинация",
|
||||||
|
"s3DestUpdatedSuccess": "Дестинацията е актуализирана успешно",
|
||||||
|
"s3DestCreatedSuccess": "Дестинацията е създадена успешно",
|
||||||
|
"s3DestUpdateFailed": "Неуспешно актуализиране на дестинацията",
|
||||||
|
"s3DestCreateFailed": "Неуспешно създаване на дестинация",
|
||||||
"datadogDestEditTitle": "Редактиране на дестинация",
|
"datadogDestEditTitle": "Редактиране на дестинация",
|
||||||
"datadogDestAddTitle": "Добавяне на Datadog дестинация",
|
"datadogDestAddTitle": "Добавяне на Datadog дестинация",
|
||||||
"datadogDestEditDescription": "Актуализирайте конфигурацията за тази Datadog дестинация за предаване на събития.",
|
"datadogDestEditDescription": "Актуализирайте конфигурацията за тази Datadog дестинация за предаване на събития.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Přeposlat události přímo do vašeho účtu Datadog účtu. Brzy přijde.",
|
"streamingDatadogDescription": "Přeposlat události přímo do vašeho účtu Datadog účtu. Brzy přijde.",
|
||||||
"streamingTypePickerDescription": "Vyberte cílový typ pro začátek.",
|
"streamingTypePickerDescription": "Vyberte cílový typ pro začátek.",
|
||||||
"streamingFailedToLoad": "Nepodařilo se načíst destinace",
|
"streamingLastSyncError": "Došlo k chybě při poslední synchronizaci",
|
||||||
"streamingUnexpectedError": "Došlo k neočekávané chybě.",
|
"streamingUnexpectedError": "Došlo k neočekávané chybě.",
|
||||||
"streamingFailedToUpdate": "Nepodařilo se aktualizovat cíl",
|
"streamingFailedToUpdate": "Nepodařilo se aktualizovat cíl",
|
||||||
"streamingDeletedSuccess": "Cíl byl úspěšně odstraněn",
|
"streamingDeletedSuccess": "Cíl byl úspěšně odstraněn",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Upravit cíl",
|
"S3DestEditTitle": "Upravit cíl",
|
||||||
"S3DestAddTitle": "Přidat S3 cíl",
|
"S3DestAddTitle": "Přidat S3 cíl",
|
||||||
"S3DestEditDescription": "Aktualizujte konfiguraci tohoto S3 cíle pro streamování událostí.",
|
"S3DestEditDescription": "Aktualizujte konfiguraci tohoto S3 cíle pro streamování událostí.",
|
||||||
"S3DestAddDescription": "Konfigurujte nový S3 koncový bod pro přijímání událostí vaší organizace.",
|
"S3DestAddDescription": "Nakonfigurujte nový Amazon S3 (nebo S3-kompatibilní) bucket, aby přijímal události vaší organizace.",
|
||||||
|
"s3DestTabSettings": "Nastavení",
|
||||||
|
"s3DestTabFormat": "Formát",
|
||||||
|
"s3DestNameLabel": "Jméno",
|
||||||
|
"s3DestNamePlaceholder": "Moje cílové S3",
|
||||||
|
"s3DestAccessKeyIdLabel": "ID přístupového klíče AWS",
|
||||||
|
"s3DestSecretAccessKeyLabel": "Tajný přístupový klíč AWS",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Váš tajný přístupový klíč AWS",
|
||||||
|
"s3DestRegionLabel": "Oblast AWS",
|
||||||
|
"s3DestBucketLabel": "Název bucketu",
|
||||||
|
"s3DestPrefixLabel": "Předpona klíče (volitelné)",
|
||||||
|
"s3DestPrefixDescription": "Volitelná cesta předpony přidaná ke každému objektovému klíči. Objekty jsou uloženy na {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Vlastní koncový bod (volitelné)",
|
||||||
|
"s3DestEndpointDescription": "Přepište koncový bod S3 pro S3-kompatibilní úložiště, jako je MinIO nebo Cloudflare R2. Nechte prázdné pro standardní AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Komprese Gzip",
|
||||||
|
"s3DestGzipDescription": "Komprimujte každý nahraný objekt pomocí gzip. Snižuje náklady na uložení a velikost nahrávání.",
|
||||||
|
"s3DestFormatTitle": "Formát souboru",
|
||||||
|
"s3DestFormatDescription": "Jak jsou události serializovány v každém nahraném objektu.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Každý objekt je pole JSON záznamů událostí. Kompatibilní s většinou analytických nástrojů.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Každý objekt obsahuje jeden JSON záznam na řádku (newline-delimited JSON). Kompatibilní s Athena, BigQuery a Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Každý objekt je soubor CSV podle RFC-4180 s řádkem záhlaví. Názvy sloupců jsou odvozeny z polí dat událostí.",
|
||||||
|
"s3DestSaveChanges": "Uložit změny",
|
||||||
|
"s3DestCreateDestination": "Vytvořit destinaci",
|
||||||
|
"s3DestUpdatedSuccess": "Destinace úspěšně aktualizována",
|
||||||
|
"s3DestCreatedSuccess": "Destinace úspěšně vytvořena",
|
||||||
|
"s3DestUpdateFailed": "Aktualizace destinace se nezdařila",
|
||||||
|
"s3DestCreateFailed": "Vytvoření destinace se nezdařilo",
|
||||||
"datadogDestEditTitle": "Upravit cíl",
|
"datadogDestEditTitle": "Upravit cíl",
|
||||||
"datadogDestAddTitle": "Přidat Datadog cíl",
|
"datadogDestAddTitle": "Přidat Datadog cíl",
|
||||||
"datadogDestEditDescription": "Aktualizujte konfiguraci tohoto Datadog cíle pro streamování událostí.",
|
"datadogDestEditDescription": "Aktualizujte konfiguraci tohoto Datadog cíle pro streamování událostí.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Events direkt an Ihr Datadog Konto weiterleiten. Kommen Sie bald.",
|
"streamingDatadogDescription": "Events direkt an Ihr Datadog Konto weiterleiten. Kommen Sie bald.",
|
||||||
"streamingTypePickerDescription": "Wählen Sie einen Zieltyp aus, um loszulegen.",
|
"streamingTypePickerDescription": "Wählen Sie einen Zieltyp aus, um loszulegen.",
|
||||||
"streamingFailedToLoad": "Fehler beim Laden der Ziele",
|
"streamingLastSyncError": "Beim letzten Synchronisieren ist ein Fehler aufgetreten.",
|
||||||
"streamingUnexpectedError": "Ein unerwarteter Fehler ist aufgetreten.",
|
"streamingUnexpectedError": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||||
"streamingFailedToUpdate": "Fehler beim Aktualisieren des Ziels",
|
"streamingFailedToUpdate": "Fehler beim Aktualisieren des Ziels",
|
||||||
"streamingDeletedSuccess": "Ziel erfolgreich gelöscht",
|
"streamingDeletedSuccess": "Ziel erfolgreich gelöscht",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Ziel bearbeiten",
|
"S3DestEditTitle": "Ziel bearbeiten",
|
||||||
"S3DestAddTitle": "S3-Ziel hinzufügen",
|
"S3DestAddTitle": "S3-Ziel hinzufügen",
|
||||||
"S3DestEditDescription": "Konfiguration für dieses S3-Ereignis-Streamingziel aktualisieren.",
|
"S3DestEditDescription": "Konfiguration für dieses S3-Ereignis-Streamingziel aktualisieren.",
|
||||||
"S3DestAddDescription": "Neuen S3-Endpunkt konfigurieren, um die Ereignisse Ihrer Organisation zu erhalten.",
|
"S3DestAddDescription": "Konfigurieren Sie einen neuen Amazon S3 (oder S3-kompatiblen) Bucket, um die Ereignisse Ihrer Organisation zu empfangen.",
|
||||||
|
"s3DestTabSettings": "Einstellungen",
|
||||||
|
"s3DestTabFormat": "Format",
|
||||||
|
"s3DestNameLabel": "Name",
|
||||||
|
"s3DestNamePlaceholder": "Mein S3-Ziel",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS-Zugriffsschlüssel-ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS-Geheimzugriffsschlüssel",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Ihr AWS-Geheimzugriffsschlüssel",
|
||||||
|
"s3DestRegionLabel": "AWS-Region",
|
||||||
|
"s3DestBucketLabel": "Bucket-Name",
|
||||||
|
"s3DestPrefixLabel": "Schlüssel-Präfix (optional)",
|
||||||
|
"s3DestPrefixDescription": "Optionales Pfadpräfix, das jedem Objektschlüssel vorangestellt wird. Objekte werden unter {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename} gespeichert.",
|
||||||
|
"s3DestEndpointLabel": "Benutzerdefinierter Endpunkt (optional)",
|
||||||
|
"s3DestEndpointDescription": "Überschreiben Sie den S3-Endpunkt für S3-kompatiblen Speicher wie MinIO oder Cloudflare R2. Lassen Sie das Feld leer für standardmäßiges AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Gzip-Komprimierung",
|
||||||
|
"s3DestGzipDescription": "Jedes hochgeladene Objekt mit Gzip komprimieren. Reduziert die Speicherkosten und die Upload-Größe.",
|
||||||
|
"s3DestFormatTitle": "Dateiformat",
|
||||||
|
"s3DestFormatDescription": "Wie Ereignisse in jedem hochgeladenen Objekt serialisiert werden.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Jedes Objekt ist ein JSON-Array von Ereignisdaten. Kompatibel mit den meisten Analysetools.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Jedes Objekt enthält einen JSON-Datensatz pro Zeile (newline-delimited JSON). Kompatibel mit Athena, BigQuery und Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Jedes Objekt ist eine RFC-4180 CSV-Datei mit einer Kopfzeile. Spaltennamen werden aus den Ereignisdatenfeldern abgeleitet.",
|
||||||
|
"s3DestSaveChanges": "Änderungen speichern",
|
||||||
|
"s3DestCreateDestination": "Ziel erstellen",
|
||||||
|
"s3DestUpdatedSuccess": "Ziel erfolgreich aktualisiert",
|
||||||
|
"s3DestCreatedSuccess": "Ziel erfolgreich erstellt",
|
||||||
|
"s3DestUpdateFailed": "Fehler beim Aktualisieren des Ziels",
|
||||||
|
"s3DestCreateFailed": "Fehler beim Erstellen des Ziels",
|
||||||
"datadogDestEditTitle": "Ziel bearbeiten",
|
"datadogDestEditTitle": "Ziel bearbeiten",
|
||||||
"datadogDestAddTitle": "Datadog-Ziel hinzufügen",
|
"datadogDestAddTitle": "Datadog-Ziel hinzufügen",
|
||||||
"datadogDestEditDescription": "Konfiguration für dieses Datadog-Ereignis-Streamingziel aktualisieren.",
|
"datadogDestEditDescription": "Konfiguration für dieses Datadog-Ereignis-Streamingziel aktualisieren.",
|
||||||
|
|||||||
@@ -523,6 +523,12 @@
|
|||||||
"userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.",
|
"userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.",
|
||||||
"userRemoveOrgConfirm": "Confirm Remove User",
|
"userRemoveOrgConfirm": "Confirm Remove User",
|
||||||
"userRemoveOrg": "Remove User from Organization",
|
"userRemoveOrg": "Remove User from Organization",
|
||||||
|
"userQuestionOrgRemoveSelf": "Are you sure you want to remove yourself from this organization?",
|
||||||
|
"userMessageOrgRemoveSelf": "You will lose access immediately. An administrator can invite you again later, but you will need to accept a new invitation.",
|
||||||
|
"userRemoveOrgConfirmSelf": "Confirm Remove Myself",
|
||||||
|
"userRemoveOrgSelf": "Remove yourself from the organization",
|
||||||
|
"userRemoveOrgSelfWarning": "You will lose access to this organization immediately.",
|
||||||
|
"userRemoveOrgConfirmPhraseSelf": "REMOVE MYSELF FROM ORG",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"accessRoleMember": "Member",
|
"accessRoleMember": "Member",
|
||||||
"accessRoleOwner": "Owner",
|
"accessRoleOwner": "Owner",
|
||||||
@@ -531,6 +537,11 @@
|
|||||||
"emailInvalid": "Invalid email address",
|
"emailInvalid": "Invalid email address",
|
||||||
"inviteValidityDuration": "Please select a duration",
|
"inviteValidityDuration": "Please select a duration",
|
||||||
"accessRoleSelectPlease": "Please select a role",
|
"accessRoleSelectPlease": "Please select a role",
|
||||||
|
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
||||||
|
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
||||||
|
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
||||||
|
"removeOwnAdminRoleConfirmPhrase": "REMOVE MY ADMIN ACCESS",
|
||||||
|
"ownerMustRetainAdminRole": "The organization owner must keep at least one administrator role.",
|
||||||
"usernameRequired": "Username is required",
|
"usernameRequired": "Username is required",
|
||||||
"idpSelectPlease": "Please select an identity provider",
|
"idpSelectPlease": "Please select an identity provider",
|
||||||
"idpGenericOidc": "Generic OAuth2/OIDC provider.",
|
"idpGenericOidc": "Generic OAuth2/OIDC provider.",
|
||||||
@@ -3062,7 +3073,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Forward events directly to your Datadog account.",
|
"streamingDatadogDescription": "Forward events directly to your Datadog account.",
|
||||||
"streamingTypePickerDescription": "Choose a destination type to get started.",
|
"streamingTypePickerDescription": "Choose a destination type to get started.",
|
||||||
"streamingFailedToLoad": "Failed to load destinations",
|
"streamingLastSyncError": "An error occurred on the last sync",
|
||||||
"streamingUnexpectedError": "An unexpected error occurred.",
|
"streamingUnexpectedError": "An unexpected error occurred.",
|
||||||
"streamingFailedToUpdate": "Failed to update destination",
|
"streamingFailedToUpdate": "Failed to update destination",
|
||||||
"streamingDeletedSuccess": "Destination deleted successfully",
|
"streamingDeletedSuccess": "Destination deleted successfully",
|
||||||
@@ -3079,7 +3090,34 @@
|
|||||||
"S3DestEditTitle": "Edit Destination",
|
"S3DestEditTitle": "Edit Destination",
|
||||||
"S3DestAddTitle": "Add S3 Destination",
|
"S3DestAddTitle": "Add S3 Destination",
|
||||||
"S3DestEditDescription": "Update the configuration for this S3 event streaming destination.",
|
"S3DestEditDescription": "Update the configuration for this S3 event streaming destination.",
|
||||||
"S3DestAddDescription": "Configure a new S3 endpoint to receive your organization's events.",
|
"S3DestAddDescription": "Configure a new Amazon S3 (or S3-compatible) bucket to receive your organization's events.",
|
||||||
|
"s3DestTabSettings": "Settings",
|
||||||
|
"s3DestTabFormat": "Format",
|
||||||
|
"s3DestNameLabel": "Name",
|
||||||
|
"s3DestNamePlaceholder": "My S3 destination",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS Access Key ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS Secret Access Key",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Your AWS secret access key",
|
||||||
|
"s3DestRegionLabel": "AWS Region",
|
||||||
|
"s3DestBucketLabel": "Bucket Name",
|
||||||
|
"s3DestPrefixLabel": "Key Prefix (optional)",
|
||||||
|
"s3DestPrefixDescription": "Optional path prefix prepended to every object key. Objects are stored at {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Custom Endpoint (optional)",
|
||||||
|
"s3DestEndpointDescription": "Override the S3 endpoint for S3-compatible storage such as MinIO or Cloudflare R2. Leave blank for standard AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Gzip compression",
|
||||||
|
"s3DestGzipDescription": "Compress each uploaded object with gzip. Reduces storage costs and upload size.",
|
||||||
|
"s3DestFormatTitle": "File Format",
|
||||||
|
"s3DestFormatDescription": "How events are serialised inside each uploaded object.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Each object is a JSON array of event records. Compatible with most analytics tools.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Each object contains one JSON record per line (newline-delimited JSON). Compatible with Athena, BigQuery, and Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Each object is an RFC-4180 CSV file with a header row. Column names are derived from the event data fields.",
|
||||||
|
"s3DestSaveChanges": "Save Changes",
|
||||||
|
"s3DestCreateDestination": "Create Destination",
|
||||||
|
"s3DestUpdatedSuccess": "Destination updated successfully",
|
||||||
|
"s3DestCreatedSuccess": "Destination created successfully",
|
||||||
|
"s3DestUpdateFailed": "Failed to update destination",
|
||||||
|
"s3DestCreateFailed": "Failed to create destination",
|
||||||
"datadogDestEditTitle": "Edit Destination",
|
"datadogDestEditTitle": "Edit Destination",
|
||||||
"datadogDestAddTitle": "Add Datadog Destination",
|
"datadogDestAddTitle": "Add Datadog Destination",
|
||||||
"datadogDestEditDescription": "Update the configuration for this Datadog event streaming destination.",
|
"datadogDestEditDescription": "Update the configuration for this Datadog event streaming destination.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Reenviar eventos directamente a tu cuenta de Datadog. Próximamente.",
|
"streamingDatadogDescription": "Reenviar eventos directamente a tu cuenta de Datadog. Próximamente.",
|
||||||
"streamingTypePickerDescription": "Elija un tipo de destino para empezar.",
|
"streamingTypePickerDescription": "Elija un tipo de destino para empezar.",
|
||||||
"streamingFailedToLoad": "Error al cargar destinos",
|
"streamingLastSyncError": "Ocurrió un error en la última sincronización.",
|
||||||
"streamingUnexpectedError": "Se ha producido un error inesperado.",
|
"streamingUnexpectedError": "Se ha producido un error inesperado.",
|
||||||
"streamingFailedToUpdate": "Error al actualizar destino",
|
"streamingFailedToUpdate": "Error al actualizar destino",
|
||||||
"streamingDeletedSuccess": "Destino eliminado correctamente",
|
"streamingDeletedSuccess": "Destino eliminado correctamente",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Editar destino",
|
"S3DestEditTitle": "Editar destino",
|
||||||
"S3DestAddTitle": "Añadir destino S3",
|
"S3DestAddTitle": "Añadir destino S3",
|
||||||
"S3DestEditDescription": "Actualice la configuración para este destino de transmisión de eventos S3.",
|
"S3DestEditDescription": "Actualice la configuración para este destino de transmisión de eventos S3.",
|
||||||
"S3DestAddDescription": "Configure un nuevo punto final S3 para recibir los eventos de su organización.",
|
"S3DestAddDescription": "Configura un nuevo bucket de Amazon S3 (o compatible con S3) para recibir los eventos de tu organización.",
|
||||||
|
"s3DestTabSettings": "Ajustes",
|
||||||
|
"s3DestTabFormat": "Formato",
|
||||||
|
"s3DestNameLabel": "Nombre",
|
||||||
|
"s3DestNamePlaceholder": "Mi destino S3",
|
||||||
|
"s3DestAccessKeyIdLabel": "ID de clave de acceso de AWS",
|
||||||
|
"s3DestSecretAccessKeyLabel": "Clave de acceso secreta de AWS",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Tu clave de acceso secreta de AWS",
|
||||||
|
"s3DestRegionLabel": "Región de AWS",
|
||||||
|
"s3DestBucketLabel": "Nombre del bucket",
|
||||||
|
"s3DestPrefixLabel": "Prefijo clave (opcional)",
|
||||||
|
"s3DestPrefixDescription": "Prefijo de ruta opcional preanexado a cada clave de objeto. Los objetos se almacenan en {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Punto final personalizado (opcional)",
|
||||||
|
"s3DestEndpointDescription": "Sobrescribe el punto final de S3 para almacenamiento compatible con S3 como MinIO o Cloudflare R2. Deja en blanco para el estándar AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Compresión Gzip",
|
||||||
|
"s3DestGzipDescription": "Comprime cada objeto subido con gzip. Reduce costos de almacenamiento y tamaño de carga.",
|
||||||
|
"s3DestFormatTitle": "Formato de archivo",
|
||||||
|
"s3DestFormatDescription": "Cómo se serializan los eventos dentro de cada objeto cargado.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Cada objeto es un arreglo JSON de registros de eventos. Compatible con la mayoría de las herramientas de analítica.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Cada objeto contiene un registro JSON por línea (JSON delimitado por nueva línea). Compatible con Athena, BigQuery y Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Cada objeto es un archivo CSV conforme a RFC-4180 con una fila de encabezado. Los nombres de columna se derivan de los campos de datos del evento.",
|
||||||
|
"s3DestSaveChanges": "Guardar cambios",
|
||||||
|
"s3DestCreateDestination": "Crear destino",
|
||||||
|
"s3DestUpdatedSuccess": "Destino actualizado con éxito",
|
||||||
|
"s3DestCreatedSuccess": "Destino creado con éxito",
|
||||||
|
"s3DestUpdateFailed": "No se pudo actualizar el destino",
|
||||||
|
"s3DestCreateFailed": "No se pudo crear el destino",
|
||||||
"datadogDestEditTitle": "Editar destino",
|
"datadogDestEditTitle": "Editar destino",
|
||||||
"datadogDestAddTitle": "Añadir destino Datadog",
|
"datadogDestAddTitle": "Añadir destino Datadog",
|
||||||
"datadogDestEditDescription": "Actualice la configuración para este destino de transmisión de eventos Datadog.",
|
"datadogDestEditDescription": "Actualice la configuración para este destino de transmisión de eventos Datadog.",
|
||||||
|
|||||||
@@ -1356,7 +1356,7 @@
|
|||||||
"sidebarSites": "Nœuds",
|
"sidebarSites": "Nœuds",
|
||||||
"sidebarApprovals": "Demandes d'approbation",
|
"sidebarApprovals": "Demandes d'approbation",
|
||||||
"sidebarResources": "Ressource",
|
"sidebarResources": "Ressource",
|
||||||
"sidebarProxyResources": "Publiques",
|
"sidebarProxyResources": "Publique",
|
||||||
"sidebarClientResources": "Privé",
|
"sidebarClientResources": "Privé",
|
||||||
"sidebarAccessControl": "Contrôle d'accès",
|
"sidebarAccessControl": "Contrôle d'accès",
|
||||||
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
||||||
@@ -2458,8 +2458,8 @@
|
|||||||
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
|
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
|
||||||
"downloadClientBannerTitle": "Télécharger le client Pangolin",
|
"downloadClientBannerTitle": "Télécharger le client Pangolin",
|
||||||
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
|
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
|
||||||
"manageMachineClients": "Gérer les machines",
|
"manageMachineClients": "Gérer les clients de la machine",
|
||||||
"manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources",
|
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources",
|
||||||
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
|
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
|
||||||
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
|
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
|
||||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Transférer des événements directement sur votre compte Datadog. Prochainement.",
|
"streamingDatadogDescription": "Transférer des événements directement sur votre compte Datadog. Prochainement.",
|
||||||
"streamingTypePickerDescription": "Choisissez un type de destination pour commencer.",
|
"streamingTypePickerDescription": "Choisissez un type de destination pour commencer.",
|
||||||
"streamingFailedToLoad": "Impossible de charger les destinations",
|
"streamingLastSyncError": "Une erreur s'est produite lors de la dernière synchronisation",
|
||||||
"streamingUnexpectedError": "Une erreur inattendue s'est produite.",
|
"streamingUnexpectedError": "Une erreur inattendue s'est produite.",
|
||||||
"streamingFailedToUpdate": "Impossible de mettre à jour la destination",
|
"streamingFailedToUpdate": "Impossible de mettre à jour la destination",
|
||||||
"streamingDeletedSuccess": "Destination supprimée avec succès",
|
"streamingDeletedSuccess": "Destination supprimée avec succès",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Modifier la destination",
|
"S3DestEditTitle": "Modifier la destination",
|
||||||
"S3DestAddTitle": "Ajouter une destination S3",
|
"S3DestAddTitle": "Ajouter une destination S3",
|
||||||
"S3DestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements S3.",
|
"S3DestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements S3.",
|
||||||
"S3DestAddDescription": "Configurer un nouveau point de terminaison S3 pour recevoir les événements de votre organisation.",
|
"S3DestAddDescription": "Configurez un nouveau bucket Amazon S3 (ou compatible S3) pour recevoir les événements de votre organisation.",
|
||||||
|
"s3DestTabSettings": "Réglages",
|
||||||
|
"s3DestTabFormat": "Format",
|
||||||
|
"s3DestNameLabel": "Nom",
|
||||||
|
"s3DestNamePlaceholder": "Ma destination S3",
|
||||||
|
"s3DestAccessKeyIdLabel": "ID de clé d'accès AWS",
|
||||||
|
"s3DestSecretAccessKeyLabel": "Clé d'accès secrète AWS",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Votre clé d'accès secrète AWS",
|
||||||
|
"s3DestRegionLabel": "Région AWS",
|
||||||
|
"s3DestBucketLabel": "Nom du bucket",
|
||||||
|
"s3DestPrefixLabel": "Préfixe clé (facultatif)",
|
||||||
|
"s3DestPrefixDescription": "Préfixe de chemin facultatif préfixé à chaque clé d'objet. Les objets sont stockés à {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Point de terminaison personnalisé (facultatif)",
|
||||||
|
"s3DestEndpointDescription": "Modifiez le point de terminaison S3 pour un stockage compatible S3 tel que MinIO ou Cloudflare R2. Laissez vide pour l'AWS S3 standard.",
|
||||||
|
"s3DestGzipLabel": "Compression Gzip",
|
||||||
|
"s3DestGzipDescription": "Compressez chaque objet téléchargé avec gzip. Réduit les coûts de stockage et la taille de téléchargement.",
|
||||||
|
"s3DestFormatTitle": "Format de fichier",
|
||||||
|
"s3DestFormatDescription": "Comment les événements sont sérialisés dans chaque objet téléchargé.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Chaque objet est un tableau JSON des enregistrements d'événements. Compatible avec la plupart des outils d'analyse.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Chaque objet contient un enregistrement JSON par ligne (JSON délimité par saut de ligne). Compatible avec Athena, BigQuery et Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Chaque objet est un fichier CSV RFC-4180 avec une ligne d'en-tête. Les noms de colonne sont dérivés des champs de données de l'événement.",
|
||||||
|
"s3DestSaveChanges": "Enregistrer les modifications",
|
||||||
|
"s3DestCreateDestination": "Créer une destination",
|
||||||
|
"s3DestUpdatedSuccess": "Destination mise à jour avec succès",
|
||||||
|
"s3DestCreatedSuccess": "Destination créée avec succès",
|
||||||
|
"s3DestUpdateFailed": "Échec de la mise à jour de la destination",
|
||||||
|
"s3DestCreateFailed": "Échec de la création de la destination",
|
||||||
"datadogDestEditTitle": "Modifier la destination",
|
"datadogDestEditTitle": "Modifier la destination",
|
||||||
"datadogDestAddTitle": "Ajouter une destination Datadog",
|
"datadogDestAddTitle": "Ajouter une destination Datadog",
|
||||||
"datadogDestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements Datadog.",
|
"datadogDestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements Datadog.",
|
||||||
@@ -3154,7 +3181,6 @@
|
|||||||
"healthCheckTabAdvanced": "Avancé",
|
"healthCheckTabAdvanced": "Avancé",
|
||||||
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
|
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
|
||||||
"uptime30d": "Disponibilité (30j)",
|
"uptime30d": "Disponibilité (30j)",
|
||||||
"uptimeNoData": "Aucune donnée",
|
|
||||||
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
|
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
|
||||||
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
|
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
|
||||||
"idpImportDialogTitle": "Importer le fournisseur d'identité",
|
"idpImportDialogTitle": "Importer le fournisseur d'identité",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Inoltra gli eventi direttamente al tuo account Datadog. In arrivo.",
|
"streamingDatadogDescription": "Inoltra gli eventi direttamente al tuo account Datadog. In arrivo.",
|
||||||
"streamingTypePickerDescription": "Scegli un tipo di destinazione per iniziare.",
|
"streamingTypePickerDescription": "Scegli un tipo di destinazione per iniziare.",
|
||||||
"streamingFailedToLoad": "Impossibile caricare le destinazioni",
|
"streamingLastSyncError": "Si è verificato un errore durante l'ultima sincronizzazione",
|
||||||
"streamingUnexpectedError": "Si è verificato un errore imprevisto.",
|
"streamingUnexpectedError": "Si è verificato un errore imprevisto.",
|
||||||
"streamingFailedToUpdate": "Impossibile aggiornare la destinazione",
|
"streamingFailedToUpdate": "Impossibile aggiornare la destinazione",
|
||||||
"streamingDeletedSuccess": "Destinazione eliminata con successo",
|
"streamingDeletedSuccess": "Destinazione eliminata con successo",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Modifica Destinazione",
|
"S3DestEditTitle": "Modifica Destinazione",
|
||||||
"S3DestAddTitle": "Aggiungi Destinazione S3",
|
"S3DestAddTitle": "Aggiungi Destinazione S3",
|
||||||
"S3DestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi S3.",
|
"S3DestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi S3.",
|
||||||
"S3DestAddDescription": "Configura un nuovo endpoint S3 per ricevere gli eventi della tua organizzazione.",
|
"S3DestAddDescription": "Configura un nuovo bucket Amazon S3 (o compatibile con S3) per ricevere gli eventi della tua organizzazione.",
|
||||||
|
"s3DestTabSettings": "Impostazioni",
|
||||||
|
"s3DestTabFormat": "Formato",
|
||||||
|
"s3DestNameLabel": "Nome",
|
||||||
|
"s3DestNamePlaceholder": "La mia destinazione S3",
|
||||||
|
"s3DestAccessKeyIdLabel": "ID Chiave Accesso AWS",
|
||||||
|
"s3DestSecretAccessKeyLabel": "Chiave Segreta Accesso AWS",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "La tua chiave segreta di accesso AWS",
|
||||||
|
"s3DestRegionLabel": "Regione AWS",
|
||||||
|
"s3DestBucketLabel": "Nome Bucket",
|
||||||
|
"s3DestPrefixLabel": "Prefisso Chiave (facoltativo)",
|
||||||
|
"s3DestPrefixDescription": "Prefisso percorso facoltativo anteposto a ogni chiave oggetto. Gli oggetti vengono archiviati in {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Endpoint personalizzato (facoltativo)",
|
||||||
|
"s3DestEndpointDescription": "Sostituisci l'endpoint S3 per lo storage compatibile con S3 come MinIO o Cloudflare R2. Lasciare vuoto per l'AWS S3 standard.",
|
||||||
|
"s3DestGzipLabel": "Compressione Gzip",
|
||||||
|
"s3DestGzipDescription": "Comprimi ogni oggetto caricato con gzip. Riduce i costi di archiviazione e la dimensione di caricamento.",
|
||||||
|
"s3DestFormatTitle": "Formato del File",
|
||||||
|
"s3DestFormatDescription": "Come gli eventi sono serializzati all'interno di ciascun oggetto caricato.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Ogni oggetto è un array JSON di record di eventi. Compatibile con la maggior parte degli strumenti analitici.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Ogni oggetto contiene un record JSON per linea (JSON delimitato da newline). Compatibile con Athena, BigQuery e Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "\"CSV\"",
|
||||||
|
"s3DestFormatCsvDescription": "Ogni oggetto è un file CSV RFC-4180 con una riga di intestazione. I nomi delle colonne sono derivati dai campi dei dati degli eventi.",
|
||||||
|
"s3DestSaveChanges": "Salva modifiche",
|
||||||
|
"s3DestCreateDestination": "Crea destinazione",
|
||||||
|
"s3DestUpdatedSuccess": "Destinazione aggiornata con successo",
|
||||||
|
"s3DestCreatedSuccess": "Destinazione creata con successo",
|
||||||
|
"s3DestUpdateFailed": "Aggiornamento della destinazione fallito",
|
||||||
|
"s3DestCreateFailed": "Creazione della destinazione fallita",
|
||||||
"datadogDestEditTitle": "Modifica Destinazione",
|
"datadogDestEditTitle": "Modifica Destinazione",
|
||||||
"datadogDestAddTitle": "Aggiungi Destinazione Datadog",
|
"datadogDestAddTitle": "Aggiungi Destinazione Datadog",
|
||||||
"datadogDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi Datadog.",
|
"datadogDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi Datadog.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "데이터독",
|
"streamingDatadogTitle": "데이터독",
|
||||||
"streamingDatadogDescription": "이벤트를 직접 Datadog 계정으로 전달합니다. 곧 제공됩니다.",
|
"streamingDatadogDescription": "이벤트를 직접 Datadog 계정으로 전달합니다. 곧 제공됩니다.",
|
||||||
"streamingTypePickerDescription": "목표 유형을 선택하여 시작합니다.",
|
"streamingTypePickerDescription": "목표 유형을 선택하여 시작합니다.",
|
||||||
"streamingFailedToLoad": "대상 로드에 실패했습니다",
|
"streamingLastSyncError": "마지막 동기화에서 오류가 발생했습니다.",
|
||||||
"streamingUnexpectedError": "예기치 않은 오류가 발생했습니다.",
|
"streamingUnexpectedError": "예기치 않은 오류가 발생했습니다.",
|
||||||
"streamingFailedToUpdate": "대상지를 업데이트하는 데 실패했습니다",
|
"streamingFailedToUpdate": "대상지를 업데이트하는 데 실패했습니다",
|
||||||
"streamingDeletedSuccess": "대상지가 성공적으로 삭제되었습니다",
|
"streamingDeletedSuccess": "대상지가 성공적으로 삭제되었습니다",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "대상지 수정",
|
"S3DestEditTitle": "대상지 수정",
|
||||||
"S3DestAddTitle": "S3 대상지 추가",
|
"S3DestAddTitle": "S3 대상지 추가",
|
||||||
"S3DestEditDescription": "이 S3 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
|
"S3DestEditDescription": "이 S3 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
|
||||||
"S3DestAddDescription": "조직의 이벤트를 받기 위한 새로운 S3 엔드포인트를 구성하세요.",
|
"S3DestAddDescription": "조직의 이벤트를 수신할 새로운 Amazon S3(또는 S3 호환) 버킷을 구성하세요.",
|
||||||
|
"s3DestTabSettings": "설정",
|
||||||
|
"s3DestTabFormat": "형식",
|
||||||
|
"s3DestNameLabel": "이름",
|
||||||
|
"s3DestNamePlaceholder": "내 S3 대상",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS 액세스 키 ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS 비밀 액세스 키",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "귀하의 AWS 비밀 액세스 키",
|
||||||
|
"s3DestRegionLabel": "AWS 지역",
|
||||||
|
"s3DestBucketLabel": "버킷 이름",
|
||||||
|
"s3DestPrefixLabel": "키 접두사(선택 사항)",
|
||||||
|
"s3DestPrefixDescription": "하나의 객체 키 앞에 붙이는 선택적 경로 접두사입니다. 객체는 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}에 저장됩니다.",
|
||||||
|
"s3DestEndpointLabel": "사용자 정의 엔드포인트(선택 사항)",
|
||||||
|
"s3DestEndpointDescription": "MinIO 또는 Cloudflare R2와 같은 S3 호환 저장소에 대한 S3 엔드포인트를 재정의합니다. 표준 AWS S3의 경우 비워 두십시오.",
|
||||||
|
"s3DestGzipLabel": "Gzip 압축",
|
||||||
|
"s3DestGzipDescription": "각 업로드된 객체를 gzip으로 압축합니다. 저장 비용과 업로드 크기를 줄입니다.",
|
||||||
|
"s3DestFormatTitle": "파일 형식",
|
||||||
|
"s3DestFormatDescription": "업로드된 각 객체 내에서 이벤트가 직렬화되는 방식입니다.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "각 객체는 이벤트 기록의 JSON 배열입니다. 대부분의 분석 도구와 호환됩니다.",
|
||||||
|
"s3DestFormatNdjsonDescription": "각 객체는 한 줄당 하나의 JSON 레코드를 포함합니다(새 줄로 구분된 JSON). Athena, BigQuery, Spark와 호환됩니다.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "각 객체는 헤더 행이 있는 RFC-4180 CSV 파일입니다. 열 이름은 이벤트 데이터 필드에서 파생됩니다.",
|
||||||
|
"s3DestSaveChanges": "변경 사항 저장",
|
||||||
|
"s3DestCreateDestination": "대상 생성",
|
||||||
|
"s3DestUpdatedSuccess": "대상이 성공적으로 업데이트되었습니다",
|
||||||
|
"s3DestCreatedSuccess": "대상이 성공적으로 생성되었습니다",
|
||||||
|
"s3DestUpdateFailed": "대상 업데이트에 실패했습니다",
|
||||||
|
"s3DestCreateFailed": "대상 생성에 실패했습니다",
|
||||||
"datadogDestEditTitle": "대상지 수정",
|
"datadogDestEditTitle": "대상지 수정",
|
||||||
"datadogDestAddTitle": "Datadog 대상지 추가",
|
"datadogDestAddTitle": "Datadog 대상지 추가",
|
||||||
"datadogDestEditDescription": "이 Datadog 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
|
"datadogDestEditDescription": "이 Datadog 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Videresend arrangementer direkte til din Datadog-konto. Kommer snart.",
|
"streamingDatadogDescription": "Videresend arrangementer direkte til din Datadog-konto. Kommer snart.",
|
||||||
"streamingTypePickerDescription": "Velg en måltype for å komme i gang.",
|
"streamingTypePickerDescription": "Velg en måltype for å komme i gang.",
|
||||||
"streamingFailedToLoad": "Kan ikke laste inn destinasjoner",
|
"streamingLastSyncError": "Det oppstod en feil under siste synkronisering",
|
||||||
"streamingUnexpectedError": "En uventet feil oppstod.",
|
"streamingUnexpectedError": "En uventet feil oppstod.",
|
||||||
"streamingFailedToUpdate": "Kunne ikke oppdatere destinasjon",
|
"streamingFailedToUpdate": "Kunne ikke oppdatere destinasjon",
|
||||||
"streamingDeletedSuccess": "Målet ble slettet",
|
"streamingDeletedSuccess": "Målet ble slettet",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Rediger destinasjon",
|
"S3DestEditTitle": "Rediger destinasjon",
|
||||||
"S3DestAddTitle": "Legg til S3 destinasjon",
|
"S3DestAddTitle": "Legg til S3 destinasjon",
|
||||||
"S3DestEditDescription": "Oppdatere konfigurasjonen for denne S3-hendelsesstrømmingsdestinasjonen.",
|
"S3DestEditDescription": "Oppdatere konfigurasjonen for denne S3-hendelsesstrømmingsdestinasjonen.",
|
||||||
"S3DestAddDescription": "Konfigurer et nytt S3-endepunkt for å motta organisasjonens hendelser.",
|
"S3DestAddDescription": "Konfigurer en ny Amazon S3 (eller S3-kompatibel) bucket for å motta din organisasjons hendelser.",
|
||||||
|
"s3DestTabSettings": "Innstillinger",
|
||||||
|
"s3DestTabFormat": "Format",
|
||||||
|
"s3DestNameLabel": "Navn",
|
||||||
|
"s3DestNamePlaceholder": "Min S3-destinasjon",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS tilgangsnøkkel-ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS hemmelige tilgangsnøkkel",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Din AWS secret access key",
|
||||||
|
"s3DestRegionLabel": "AWS-region",
|
||||||
|
"s3DestBucketLabel": "Bucket-navn",
|
||||||
|
"s3DestPrefixLabel": "Nøkkelprefiks (valgfritt)",
|
||||||
|
"s3DestPrefixDescription": "Valgfritt bane-prefiks lagt til hver objektnøkkel. Objekter er lagret på {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Egendefinert endepunkt (valgfritt)",
|
||||||
|
"s3DestEndpointDescription": "Overstyr S3-endepunktet for S3-kompatibel lagring som MinIO eller Cloudflare R2. La stå tomt for standard AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Gzip-komprimering",
|
||||||
|
"s3DestGzipDescription": "Komprimer hvert opplastede objekt med gzip. Reduserer lagringskostnader og opplastingsstørrelse.",
|
||||||
|
"s3DestFormatTitle": "Filformat",
|
||||||
|
"s3DestFormatDescription": "Hvordan hendelser er serialisert inni hvert opplastede objekt.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Hvert objekt er et JSON-array av hendelsesposter. Kompatibel med de fleste analyseverktøy.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Hvert objekt inneholder en JSON-post per linje (nylinje-delt JSON). Kompatibel med Athena, BigQuery, og Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Hvert objekt er en RFC-4180 CSV-fil med en overskriftsrad. Kolonnenavn er avledet fra hendelsesdatafeltene.",
|
||||||
|
"s3DestSaveChanges": "Lagre endringer",
|
||||||
|
"s3DestCreateDestination": "Opprett destinasjon",
|
||||||
|
"s3DestUpdatedSuccess": "Destinasjon oppdatert vellykket",
|
||||||
|
"s3DestCreatedSuccess": "Destinasjon opprettet vellykket",
|
||||||
|
"s3DestUpdateFailed": "Kunne ikke oppdatere destinasjon",
|
||||||
|
"s3DestCreateFailed": "Kunne ikke opprette destinasjon",
|
||||||
"datadogDestEditTitle": "Rediger destinasjon",
|
"datadogDestEditTitle": "Rediger destinasjon",
|
||||||
"datadogDestAddTitle": "Legg til Datadog destinasjon",
|
"datadogDestAddTitle": "Legg til Datadog destinasjon",
|
||||||
"datadogDestEditDescription": "Oppdatere konfigurasjonen for denne Datadog-hendelsesstrømmingsdestinasjonen.",
|
"datadogDestEditDescription": "Oppdatere konfigurasjonen for denne Datadog-hendelsesstrømmingsdestinasjonen.",
|
||||||
@@ -3174,7 +3201,7 @@
|
|||||||
"publicIpEndpoint": "Endepunkt",
|
"publicIpEndpoint": "Endepunkt",
|
||||||
"lastTriggeredAt": "Siste utløste",
|
"lastTriggeredAt": "Siste utløste",
|
||||||
"reject": "Avvis",
|
"reject": "Avvis",
|
||||||
"uptimeDaysAgo": "{count} days ago",
|
"uptimeDaysAgo": "{count} dager siden",
|
||||||
"uptimeToday": "I dag",
|
"uptimeToday": "I dag",
|
||||||
"uptimeNoDataAvailable": "Ingen data tilgjengelig",
|
"uptimeNoDataAvailable": "Ingen data tilgjengelig",
|
||||||
"uptimeSuffix": "oppetid",
|
"uptimeSuffix": "oppetid",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Stuur gebeurtenissen rechtstreeks door naar je Datadog account. Binnenkort beschikbaar.",
|
"streamingDatadogDescription": "Stuur gebeurtenissen rechtstreeks door naar je Datadog account. Binnenkort beschikbaar.",
|
||||||
"streamingTypePickerDescription": "Kies een bestemmingstype om te beginnen.",
|
"streamingTypePickerDescription": "Kies een bestemmingstype om te beginnen.",
|
||||||
"streamingFailedToLoad": "Laden van bestemmingen mislukt",
|
"streamingLastSyncError": "Er is een fout opgetreden bij de laatste synchronisatie",
|
||||||
"streamingUnexpectedError": "Er is een onverwachte fout opgetreden.",
|
"streamingUnexpectedError": "Er is een onverwachte fout opgetreden.",
|
||||||
"streamingFailedToUpdate": "Bijwerken bestemming mislukt",
|
"streamingFailedToUpdate": "Bijwerken bestemming mislukt",
|
||||||
"streamingDeletedSuccess": "Bestemming succesvol verwijderd",
|
"streamingDeletedSuccess": "Bestemming succesvol verwijderd",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Bestemming bewerken",
|
"S3DestEditTitle": "Bestemming bewerken",
|
||||||
"S3DestAddTitle": "S3-bestemming toevoegen",
|
"S3DestAddTitle": "S3-bestemming toevoegen",
|
||||||
"S3DestEditDescription": "Werk de configuratie bij voor deze S3-gebeurtenisstreamingbestemming.",
|
"S3DestEditDescription": "Werk de configuratie bij voor deze S3-gebeurtenisstreamingbestemming.",
|
||||||
"S3DestAddDescription": "Configureer een nieuw S3-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.",
|
"S3DestAddDescription": "Configureer een nieuwe Amazon S3 (of S3-compatibele) bucket om de gebeurtenissen van uw organisatie te ontvangen.",
|
||||||
|
"s3DestTabSettings": "Instellingen",
|
||||||
|
"s3DestTabFormat": "Formaat",
|
||||||
|
"s3DestNameLabel": "Naam",
|
||||||
|
"s3DestNamePlaceholder": "Mijn S3-bestemming",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS-toegangssleutel-ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS Geheime Toegangssleutel",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Uw AWS geheime toegangssleutel",
|
||||||
|
"s3DestRegionLabel": "AWS-regio",
|
||||||
|
"s3DestBucketLabel": "Bucketnaam",
|
||||||
|
"s3DestPrefixLabel": "Sleutelvoorvoegsel (optioneel)",
|
||||||
|
"s3DestPrefixDescription": "Optioneel padvoorvoegsel dat aan elke object sleutel wordt toegevoegd. Objecten worden opgeslagen op {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Aangepast Eindpunt (optioneel)",
|
||||||
|
"s3DestEndpointDescription": "Overschrijf het S3-eindpunt voor S3-compatibele opslag zoals MinIO of Cloudflare R2. Laat leeg voor standaard AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Gzip-compressie",
|
||||||
|
"s3DestGzipDescription": "Comprimeer elk geüpload object met gzip. Verlaagt opslagkosten en uploadgrootte.",
|
||||||
|
"s3DestFormatTitle": "Bestandsformaat",
|
||||||
|
"s3DestFormatDescription": "Hoe gebeurtenissen binnen elk geüpload object worden geserialiseerd.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Elk object is een JSON-array van gebeurtenisrecords. Compatibel met de meeste analysetools.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Elk object bevat één JSON-record per regel (nieuwregel-gescheiden JSON). Compatibel met Athena, BigQuery en Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Elk object is een RFC-4180 CSV-bestand met een kopregel. Kolomnamen zijn afgeleid van de gebeurtenis gegevensvelden.",
|
||||||
|
"s3DestSaveChanges": "Wijzigingen opslaan",
|
||||||
|
"s3DestCreateDestination": "Bestemming maken",
|
||||||
|
"s3DestUpdatedSuccess": "Bestemming succesvol bijgewerkt",
|
||||||
|
"s3DestCreatedSuccess": "Bestemming succesvol gecreëerd",
|
||||||
|
"s3DestUpdateFailed": "Bijwerken bestemming mislukt",
|
||||||
|
"s3DestCreateFailed": "Aanmaken bestemming mislukt",
|
||||||
"datadogDestEditTitle": "Bestemming bewerken",
|
"datadogDestEditTitle": "Bestemming bewerken",
|
||||||
"datadogDestAddTitle": "Datadog-bestemming toevoegen",
|
"datadogDestAddTitle": "Datadog-bestemming toevoegen",
|
||||||
"datadogDestEditDescription": "Werk de configuratie bij voor deze Datadog-gebeurtenisstreamingbestemming.",
|
"datadogDestEditDescription": "Werk de configuratie bij voor deze Datadog-gebeurtenisstreamingbestemming.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Przekaż wydarzenia bezpośrednio do Twojego konta Datadog. Już wkrótce.",
|
"streamingDatadogDescription": "Przekaż wydarzenia bezpośrednio do Twojego konta Datadog. Już wkrótce.",
|
||||||
"streamingTypePickerDescription": "Wybierz typ docelowy, aby rozpocząć.",
|
"streamingTypePickerDescription": "Wybierz typ docelowy, aby rozpocząć.",
|
||||||
"streamingFailedToLoad": "Nie udało się załadować miejsc docelowych",
|
"streamingLastSyncError": "Wystąpił błąd podczas ostatniej synchronizacji",
|
||||||
"streamingUnexpectedError": "Wystąpił nieoczekiwany błąd.",
|
"streamingUnexpectedError": "Wystąpił nieoczekiwany błąd.",
|
||||||
"streamingFailedToUpdate": "Nie udało się zaktualizować miejsca docelowego",
|
"streamingFailedToUpdate": "Nie udało się zaktualizować miejsca docelowego",
|
||||||
"streamingDeletedSuccess": "Cel usunięty pomyślnie",
|
"streamingDeletedSuccess": "Cel usunięty pomyślnie",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Edytuj Miejsce Docelowe",
|
"S3DestEditTitle": "Edytuj Miejsce Docelowe",
|
||||||
"S3DestAddTitle": "Dodaj Miejsce Docelowe S3",
|
"S3DestAddTitle": "Dodaj Miejsce Docelowe S3",
|
||||||
"S3DestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń S3.",
|
"S3DestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń S3.",
|
||||||
"S3DestAddDescription": "Skonfiguruj nowy punkt końcowy S3, aby odbierać zdarzenia Twojej organizacji.",
|
"S3DestAddDescription": "Skonfiguruj nowy zasobnik Amazon S3 (lub zgodny z S3), aby otrzymywać zdarzenia twojej organizacji.",
|
||||||
|
"s3DestTabSettings": "Ustawienia",
|
||||||
|
"s3DestTabFormat": "Format",
|
||||||
|
"s3DestNameLabel": "Nazwa",
|
||||||
|
"s3DestNamePlaceholder": "Moje miejsce docelowe S3",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS Access Key ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS Secret Access Key",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Twój AWS Secret Access Key",
|
||||||
|
"s3DestRegionLabel": "Region AWS",
|
||||||
|
"s3DestBucketLabel": "Nazwa kubła",
|
||||||
|
"s3DestPrefixLabel": "Prefiks klucza (opcjonalnie)",
|
||||||
|
"s3DestPrefixDescription": "Opcjonalny prefiks ścieżki dołączony do każdego klucza obiektu. Obiekty są przechowywane w {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Niestandardowy punkt końcowy (opcjonalnie)",
|
||||||
|
"s3DestEndpointDescription": "Nadpisz punkt końcowy S3 dla zgodnego przechowywania danych, takiego jak MinIO lub Cloudflare R2. Pozostaw puste dla standardowego AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Kompresja Gzip",
|
||||||
|
"s3DestGzipDescription": "Skompresuj każdy przesłany obiekt za pomocą gzip. Zmniejsza koszty przechowywania i rozmiar przesyłu.",
|
||||||
|
"s3DestFormatTitle": "Format pliku",
|
||||||
|
"s3DestFormatDescription": "Jak zdarzenia są serializowane w każdym przesłanym obiekcie.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Każdy obiekt to tablica JSON z rekordami zdarzeń. Zgodne z większością narzędzi analitycznych.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Każdy obiekt zawiera jeden rekord JSON na linię (nowa linia-dzielone JSON). Zgodne z Athena, BigQuery i Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Każdy obiekt to plik CSV zgodny z RFC-4180 z wierszem nagłówka. Nazwy kolumn pochodzą z pól danych zdarzeń.",
|
||||||
|
"s3DestSaveChanges": "Zapisz zmiany",
|
||||||
|
"s3DestCreateDestination": "Utwórz miejsce docelowe",
|
||||||
|
"s3DestUpdatedSuccess": "Miejsce docelowe zaktualizowane pomyślnie",
|
||||||
|
"s3DestCreatedSuccess": "Miejsce docelowe utworzone pomyślnie",
|
||||||
|
"s3DestUpdateFailed": "Nie udało się zaktualizować miejsca docelowego",
|
||||||
|
"s3DestCreateFailed": "Nie udało się utworzyć miejsca docelowego",
|
||||||
"datadogDestEditTitle": "Edytuj Miejsce Docelowe",
|
"datadogDestEditTitle": "Edytuj Miejsce Docelowe",
|
||||||
"datadogDestAddTitle": "Dodaj Miejsce Docelowe Datadog",
|
"datadogDestAddTitle": "Dodaj Miejsce Docelowe Datadog",
|
||||||
"datadogDestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń Datadog.",
|
"datadogDestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń Datadog.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Encaminha eventos diretamente para a sua conta no Datadog. Em breve.",
|
"streamingDatadogDescription": "Encaminha eventos diretamente para a sua conta no Datadog. Em breve.",
|
||||||
"streamingTypePickerDescription": "Escolha um tipo de destino para começar.",
|
"streamingTypePickerDescription": "Escolha um tipo de destino para começar.",
|
||||||
"streamingFailedToLoad": "Falha ao carregar destinos",
|
"streamingLastSyncError": "Ocorreu um erro na última sincronização",
|
||||||
"streamingUnexpectedError": "Ocorreu um erro inesperado.",
|
"streamingUnexpectedError": "Ocorreu um erro inesperado.",
|
||||||
"streamingFailedToUpdate": "Falha ao atualizar destino",
|
"streamingFailedToUpdate": "Falha ao atualizar destino",
|
||||||
"streamingDeletedSuccess": "Destino apagado com sucesso",
|
"streamingDeletedSuccess": "Destino apagado com sucesso",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Editar Destino",
|
"S3DestEditTitle": "Editar Destino",
|
||||||
"S3DestAddTitle": "Adicionar Destino S3",
|
"S3DestAddTitle": "Adicionar Destino S3",
|
||||||
"S3DestEditDescription": "Atualize a configuração para este destino de streaming de eventos S3.",
|
"S3DestEditDescription": "Atualize a configuração para este destino de streaming de eventos S3.",
|
||||||
"S3DestAddDescription": "Configure um novo endpoint S3 para receber os eventos da sua organização.",
|
"S3DestAddDescription": "Configure um novo bucket Amazon S3 (ou compatível com S3) para receber os eventos da sua organização.",
|
||||||
|
"s3DestTabSettings": "Configurações",
|
||||||
|
"s3DestTabFormat": "Formato",
|
||||||
|
"s3DestNameLabel": "Nome",
|
||||||
|
"s3DestNamePlaceholder": "Meu destino S3",
|
||||||
|
"s3DestAccessKeyIdLabel": "ID da Chave de Acesso AWS",
|
||||||
|
"s3DestSecretAccessKeyLabel": "Chave de Acesso Secreta AWS",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Sua chave de acesso secreta AWS",
|
||||||
|
"s3DestRegionLabel": "Região AWS",
|
||||||
|
"s3DestBucketLabel": "Nome do Bucket",
|
||||||
|
"s3DestPrefixLabel": "Prefixo da Chave (opcional)",
|
||||||
|
"s3DestPrefixDescription": "Prefixo de caminho opcional adicionado a cada chave de objeto. Os objetos são armazenados em {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Endpoint Personalizado (opcional)",
|
||||||
|
"s3DestEndpointDescription": "Substitua o endpoint S3 por armazenamento compatível com S3, como MinIO ou Cloudflare R2. Deixe em branco para o padrão AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Compressão Gzip",
|
||||||
|
"s3DestGzipDescription": "Comprime cada objeto carregado com gzip. Reduz custos de armazenamento e tamanho de upload.",
|
||||||
|
"s3DestFormatTitle": "Formato de Arquivo",
|
||||||
|
"s3DestFormatDescription": "Como os eventos são serializados dentro de cada objeto carregado.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Cada objeto é um array JSON de registros de eventos. Compatível com a maioria das ferramentas de análise.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Cada objeto contém um registro JSON por linha (JSON delimitado por nova linha). Compatível com Athena, BigQuery e Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Cada objeto é um arquivo CSV RFC-4180 com uma linha de cabeçalho. Nomes de colunas são derivados dos campos de dados do evento.",
|
||||||
|
"s3DestSaveChanges": "Salvar Alterações",
|
||||||
|
"s3DestCreateDestination": "Criar Destino",
|
||||||
|
"s3DestUpdatedSuccess": "Destino atualizado com sucesso",
|
||||||
|
"s3DestCreatedSuccess": "Destino criado com sucesso",
|
||||||
|
"s3DestUpdateFailed": "Falha ao atualizar destino",
|
||||||
|
"s3DestCreateFailed": "Falha ao criar destino",
|
||||||
"datadogDestEditTitle": "Editar Destino",
|
"datadogDestEditTitle": "Editar Destino",
|
||||||
"datadogDestAddTitle": "Adicionar Destino Datadog",
|
"datadogDestAddTitle": "Adicionar Destino Datadog",
|
||||||
"datadogDestEditDescription": "Atualize a configuração para este destino de streaming de eventos Datadog.",
|
"datadogDestEditDescription": "Atualize a configuração para este destino de streaming de eventos Datadog.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Перенаправлять события непосредственно на ваш аккаунт в Datadog. Скоро будет доступно.",
|
"streamingDatadogDescription": "Перенаправлять события непосредственно на ваш аккаунт в Datadog. Скоро будет доступно.",
|
||||||
"streamingTypePickerDescription": "Выберите тип назначения, чтобы начать.",
|
"streamingTypePickerDescription": "Выберите тип назначения, чтобы начать.",
|
||||||
"streamingFailedToLoad": "Не удалось загрузить места назначения",
|
"streamingLastSyncError": "Во время последней синхронизации произошла ошибка",
|
||||||
"streamingUnexpectedError": "Произошла непредвиденная ошибка.",
|
"streamingUnexpectedError": "Произошла непредвиденная ошибка.",
|
||||||
"streamingFailedToUpdate": "Не удалось обновить место назначения",
|
"streamingFailedToUpdate": "Не удалось обновить место назначения",
|
||||||
"streamingDeletedSuccess": "Адрес назначения успешно удален",
|
"streamingDeletedSuccess": "Адрес назначения успешно удален",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Редактировать пункт назначения",
|
"S3DestEditTitle": "Редактировать пункт назначения",
|
||||||
"S3DestAddTitle": "Добавить S3 пункт назначения",
|
"S3DestAddTitle": "Добавить S3 пункт назначения",
|
||||||
"S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.",
|
"S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.",
|
||||||
"S3DestAddDescription": "Настройте новую S3 конечную точку для получения событий вашей организации.",
|
"S3DestAddDescription": "Настройте новый Amazon S3 (или совместимое S3) хранилище для получения событий вашей организации.",
|
||||||
|
"s3DestTabSettings": "Настройки",
|
||||||
|
"s3DestTabFormat": "Формат",
|
||||||
|
"s3DestNameLabel": "Имя",
|
||||||
|
"s3DestNamePlaceholder": "Моя S3 конечная точка",
|
||||||
|
"s3DestAccessKeyIdLabel": "Идентификатор ключа доступа AWS",
|
||||||
|
"s3DestSecretAccessKeyLabel": "Секретный ключ доступа AWS",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "Ваш секретный ключ доступа AWS",
|
||||||
|
"s3DestRegionLabel": "Регион AWS",
|
||||||
|
"s3DestBucketLabel": "Имя хранилища",
|
||||||
|
"s3DestPrefixLabel": "Префикс ключа (по желанию)",
|
||||||
|
"s3DestPrefixDescription": "Необязательный префикс пути, добавляется к каждому ключу объекта. Объекты хранятся в {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||||
|
"s3DestEndpointLabel": "Пользовательская конечная точка (по желанию)",
|
||||||
|
"s3DestEndpointDescription": "Переопределите конечную точку S3 для совместимого хранилища, такого как MinIO или Cloudflare R2. Оставьте пустым для стандартного AWS S3.",
|
||||||
|
"s3DestGzipLabel": "Сжатие Gzip",
|
||||||
|
"s3DestGzipDescription": "Сжимайте каждый загруженный объект с помощью gzip. Уменьшает стоимость хранения и размер загрузки.",
|
||||||
|
"s3DestFormatTitle": "Формат файла",
|
||||||
|
"s3DestFormatDescription": "Как события сериализуются внутри каждого загруженного объекта.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Каждый объект — это JSON массив записей событий. Совместим с большинством аналитических инструментов.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Каждый объект содержит одну запись JSON на строку (JSON, разделённый новой строкой). Совместим с Athena, BigQuery и Spark.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Каждый объект представляет собой CSV файл по стандарту RFC-4180 с заголовочной строкой. Имена столбцов выведены из полей данных событий.",
|
||||||
|
"s3DestSaveChanges": "Сохранить изменения",
|
||||||
|
"s3DestCreateDestination": "Создать конечную точку",
|
||||||
|
"s3DestUpdatedSuccess": "Конечная точка успешно обновлена",
|
||||||
|
"s3DestCreatedSuccess": "Конечная точка успешно создана",
|
||||||
|
"s3DestUpdateFailed": "Не удалось обновить конечную точку",
|
||||||
|
"s3DestCreateFailed": "Не удалось создать конечную точку",
|
||||||
"datadogDestEditTitle": "Редактировать пункт назначения",
|
"datadogDestEditTitle": "Редактировать пункт назначения",
|
||||||
"datadogDestAddTitle": "Добавить пункт назначения Datadog",
|
"datadogDestAddTitle": "Добавить пункт назначения Datadog",
|
||||||
"datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.",
|
"datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "Olayları doğrudan Datadog hesabınıza iletin. Yakında gelicek.",
|
"streamingDatadogDescription": "Olayları doğrudan Datadog hesabınıza iletin. Yakında gelicek.",
|
||||||
"streamingTypePickerDescription": "Başlamak için bir hedef türü seçin.",
|
"streamingTypePickerDescription": "Başlamak için bir hedef türü seçin.",
|
||||||
"streamingFailedToLoad": "Hedefler yüklenemedi",
|
"streamingLastSyncError": "Son senkronizasyonda bir hata oluştu",
|
||||||
"streamingUnexpectedError": "Beklenmeyen bir hata oluştu.",
|
"streamingUnexpectedError": "Beklenmeyen bir hata oluştu.",
|
||||||
"streamingFailedToUpdate": "Hedef güncellenemedi",
|
"streamingFailedToUpdate": "Hedef güncellenemedi",
|
||||||
"streamingDeletedSuccess": "Hedef başarıyla silindi",
|
"streamingDeletedSuccess": "Hedef başarıyla silindi",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "Hedefi Düzenle",
|
"S3DestEditTitle": "Hedefi Düzenle",
|
||||||
"S3DestAddTitle": "S3 Hedefi Ekle",
|
"S3DestAddTitle": "S3 Hedefi Ekle",
|
||||||
"S3DestEditDescription": "Bu S3 olay akışı hedefi için yapılandırmayı güncelleyin.",
|
"S3DestEditDescription": "Bu S3 olay akışı hedefi için yapılandırmayı güncelleyin.",
|
||||||
"S3DestAddDescription": "Kuruluşunuzun olaylarını almak için yeni bir S3 uç noktası yapılandırın.",
|
"S3DestAddDescription": "Kuruluşunuzun etkinliklerini almak için yeni bir Amazon S3 (veya S3-uyumlu) kovası yapılandırın.",
|
||||||
|
"s3DestTabSettings": "Ayarlar",
|
||||||
|
"s3DestTabFormat": "Biçim",
|
||||||
|
"s3DestNameLabel": "Ad",
|
||||||
|
"s3DestNamePlaceholder": "Benim S3 hedefim",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS Erişim Anahtar Kimliği",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS Gizli Erişim Anahtarı",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "AWS gizli erişim anahtarınız",
|
||||||
|
"s3DestRegionLabel": "AWS Bölgesi",
|
||||||
|
"s3DestBucketLabel": "Kova Adı",
|
||||||
|
"s3DestPrefixLabel": "Anahtar Ön Eki (isteğe bağlı)",
|
||||||
|
"s3DestPrefixDescription": "Her nesne anahtarının önüne eklenen isteğe bağlı yol öneki. Nesneler {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename} konumunda saklanır.",
|
||||||
|
"s3DestEndpointLabel": "Özel Uç Nokta (isteğe bağlı)",
|
||||||
|
"s3DestEndpointDescription": "MinIO veya Cloudflare R2 gibi S3-uyumlu depolama için S3 uç noktasını geçersiz kılın. Standart AWS S3 için boş bırakın.",
|
||||||
|
"s3DestGzipLabel": "Gzip sıkıştırması",
|
||||||
|
"s3DestGzipDescription": "Her yüklü nesneyi gzip ile sıkıştırın. Depolama maliyetlerini ve yükleme boyutunu azaltır.",
|
||||||
|
"s3DestFormatTitle": "Dosya Biçimi",
|
||||||
|
"s3DestFormatDescription": "Etkinliklerin her yüklendiği nesne içinde nasıl serileştirildiği.",
|
||||||
|
"s3DestFormatJsonArrayDescription": "Her nesne bir olay kayıtlarının JSON dizisidir. Çoğu analiz aracıyla uyumludur.",
|
||||||
|
"s3DestFormatNdjsonDescription": "Her nesne satır başına bir JSON kaydı içerir (yeni satır ile ayrılmış JSON). Athena, BigQuery ve Spark ile uyumludur.",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "Her nesne, bir başlık satırı ile birlikte RFC-4180 CSV dosyasıdır. Sütun isimleri olay verileri alanlarından türetilmiştir.",
|
||||||
|
"s3DestSaveChanges": "Değişiklikleri Kaydet",
|
||||||
|
"s3DestCreateDestination": "Hedef Oluştur",
|
||||||
|
"s3DestUpdatedSuccess": "Hedef başarıyla güncellendi",
|
||||||
|
"s3DestCreatedSuccess": "Hedef başarıyla oluşturuldu",
|
||||||
|
"s3DestUpdateFailed": "Hedef güncellenemedi",
|
||||||
|
"s3DestCreateFailed": "Hedef oluşturulamadı",
|
||||||
"datadogDestEditTitle": "Hedefi Düzenle",
|
"datadogDestEditTitle": "Hedefi Düzenle",
|
||||||
"datadogDestAddTitle": "Datadog Hedefi Ekle",
|
"datadogDestAddTitle": "Datadog Hedefi Ekle",
|
||||||
"datadogDestEditDescription": "Bu Datadog olay akışı hedefi için yapılandırmayı güncelleyin.",
|
"datadogDestEditDescription": "Bu Datadog olay akışı hedefi için yapılandırmayı güncelleyin.",
|
||||||
|
|||||||
@@ -3062,7 +3062,7 @@
|
|||||||
"streamingDatadogTitle": "Datadog",
|
"streamingDatadogTitle": "Datadog",
|
||||||
"streamingDatadogDescription": "直接转发事件到您的Datadog 帐户。即将推出。",
|
"streamingDatadogDescription": "直接转发事件到您的Datadog 帐户。即将推出。",
|
||||||
"streamingTypePickerDescription": "选择要开始的目标类型。",
|
"streamingTypePickerDescription": "选择要开始的目标类型。",
|
||||||
"streamingFailedToLoad": "加载目的地失败",
|
"streamingLastSyncError": "最后一次同步时发生错误",
|
||||||
"streamingUnexpectedError": "发生意外错误.",
|
"streamingUnexpectedError": "发生意外错误.",
|
||||||
"streamingFailedToUpdate": "更新目标失败",
|
"streamingFailedToUpdate": "更新目标失败",
|
||||||
"streamingDeletedSuccess": "目标删除成功",
|
"streamingDeletedSuccess": "目标删除成功",
|
||||||
@@ -3079,7 +3079,34 @@
|
|||||||
"S3DestEditTitle": "编辑目的地",
|
"S3DestEditTitle": "编辑目的地",
|
||||||
"S3DestAddTitle": "添加 S3 目的地",
|
"S3DestAddTitle": "添加 S3 目的地",
|
||||||
"S3DestEditDescription": "更新此 S3 事件流目的地的配置。",
|
"S3DestEditDescription": "更新此 S3 事件流目的地的配置。",
|
||||||
"S3DestAddDescription": "配置新的 S3 终端以接收您的组织事件。",
|
"S3DestAddDescription": "配置一个新的 Amazon S3(或兼容 S3 的)存储桶以接收您的组织事件。",
|
||||||
|
"s3DestTabSettings": "设置",
|
||||||
|
"s3DestTabFormat": "格式",
|
||||||
|
"s3DestNameLabel": "名称",
|
||||||
|
"s3DestNamePlaceholder": "我的 S3 目的地",
|
||||||
|
"s3DestAccessKeyIdLabel": "AWS 访问密钥 ID",
|
||||||
|
"s3DestSecretAccessKeyLabel": "AWS 秘密访问密钥",
|
||||||
|
"s3DestSecretAccessKeyPlaceholder": "您的 AWS 密钥",
|
||||||
|
"s3DestRegionLabel": "AWS 地区",
|
||||||
|
"s3DestBucketLabel": "存储桶名称",
|
||||||
|
"s3DestPrefixLabel": "密钥前缀(可选)",
|
||||||
|
"s3DestPrefixDescription": "每个对象密钥前加的可选路径前缀。对象存储在 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}。",
|
||||||
|
"s3DestEndpointLabel": "自定义端点(可选)",
|
||||||
|
"s3DestEndpointDescription": "替代 S3 端点用于 MinIO 或 Cloudflare R2 等兼容 S3 的存储。标准 AWS S3 留空。",
|
||||||
|
"s3DestGzipLabel": "Gzip 压缩",
|
||||||
|
"s3DestGzipDescription": "使用 gzip 压缩每个上传的对象。减少存储成本和上传大小。",
|
||||||
|
"s3DestFormatTitle": "文件格式",
|
||||||
|
"s3DestFormatDescription": "事件在每个上传对象内的序列化方式。",
|
||||||
|
"s3DestFormatJsonArrayDescription": "每个对象是事件记录的 JSON 数组。兼容大多数分析工具。",
|
||||||
|
"s3DestFormatNdjsonDescription": "每个对象每行包含一个 JSON 记录(换行分隔的 JSON)。兼容 Athena、BigQuery 和 Spark。",
|
||||||
|
"s3DestFormatCsvTitle": "CSV",
|
||||||
|
"s3DestFormatCsvDescription": "每个对象是带有标题行的 RFC-4180 CSV 文件。列名来自事件数据字段。",
|
||||||
|
"s3DestSaveChanges": "保存更改",
|
||||||
|
"s3DestCreateDestination": "创建目的地",
|
||||||
|
"s3DestUpdatedSuccess": "目的地更新成功",
|
||||||
|
"s3DestCreatedSuccess": "目的地创建成功",
|
||||||
|
"s3DestUpdateFailed": "更新目的地失败",
|
||||||
|
"s3DestCreateFailed": "创建目的地失败",
|
||||||
"datadogDestEditTitle": "编辑目的地",
|
"datadogDestEditTitle": "编辑目的地",
|
||||||
"datadogDestAddTitle": "添加 Datadog 目的地",
|
"datadogDestAddTitle": "添加 Datadog 目的地",
|
||||||
"datadogDestEditDescription": "更新此 Datadog 事件流目的地的配置。",
|
"datadogDestEditDescription": "更新此 Datadog 事件流目的地的配置。",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
export const primaryDb = db.$primary;
|
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export const connectionAuditLog = pgTable(
|
|||||||
clientId: integer("clientId").references(() => clients.clientId, {
|
clientId: integer("clientId").references(() => clients.clientId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
|
clientEndpoint: text("clientEndpoint"),
|
||||||
userId: text("userId").references(() => users.userId, {
|
userId: text("userId").references(() => users.userId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
@@ -439,6 +440,8 @@ export const eventStreamingDestinations = pgTable(
|
|||||||
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
|
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
|
||||||
config: text("config").notNull(), // JSON string with the configuration for the destination
|
config: text("config").notNull(), // JSON string with the configuration for the destination
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
lastError: text("lastError"), // last send error message, null if healthy
|
||||||
|
lastErrorAt: bigint("lastErrorAt", { mode: "number" }), // epoch ms of last error, null if healthy
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export const connectionAuditLog = sqliteTable(
|
|||||||
clientId: integer("clientId").references(() => clients.clientId, {
|
clientId: integer("clientId").references(() => clients.clientId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
|
clientEndpoint: text("clientEndpoint"),
|
||||||
userId: text("userId").references(() => users.userId, {
|
userId: text("userId").references(() => users.userId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
}),
|
||||||
@@ -445,6 +446,8 @@ export const eventStreamingDestinations = sqliteTable(
|
|||||||
enabled: integer("enabled", { mode: "boolean" })
|
enabled: integer("enabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
|
lastError: text("lastError"), // last send error message, null if healthy
|
||||||
|
lastErrorAt: integer("lastErrorAt"), // epoch ms of last error, null if healthy
|
||||||
createdAt: integer("createdAt").notNull(),
|
createdAt: integer("createdAt").notNull(),
|
||||||
updatedAt: integer("updatedAt").notNull()
|
updatedAt: integer("updatedAt").notNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ import { tierMatrix } from "./billing/tierMatrix";
|
|||||||
|
|
||||||
export async function calculateUserClientsForOrgs(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx?: Transaction
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const execute = async (transaction: Transaction) => {
|
const execute = async (transaction: Transaction | typeof db) => {
|
||||||
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
||||||
const adminRoleCache = new Map<
|
const adminRoleCache = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -437,7 +437,7 @@ export async function calculateUserClientsForOrgs(
|
|||||||
|
|
||||||
async function cleanupOrphanedClients(
|
async function cleanupOrphanedClients(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx: Transaction,
|
trx: Transaction | typeof db,
|
||||||
userOrgIds: string[] = []
|
userOrgIds: string[] = []
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Find all OLM clients for this user that should be deleted
|
// Find all OLM clients for this user that should be deleted
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function computeBuckets(
|
|||||||
let totalDowntime = 0;
|
let totalDowntime = 0;
|
||||||
|
|
||||||
for (let d = 0; d < days; d++) {
|
for (let d = 0; d < days; d++) {
|
||||||
const dayStartSec = todayMidnightSec - (days - d) * 86400;
|
const dayStartSec = todayMidnightSec - (days - 1 - d) * 86400;
|
||||||
const dayEndSec = dayStartSec + 86400;
|
const dayEndSec = dayStartSec + 86400;
|
||||||
|
|
||||||
const dayEvents = events.filter(
|
const dayEvents = events.filter(
|
||||||
|
|||||||
@@ -485,6 +485,133 @@ async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function storeCertForDomain(
|
||||||
|
domain: string,
|
||||||
|
certPem: string,
|
||||||
|
keyPem: string,
|
||||||
|
validatedX509: crypto.X509Certificate
|
||||||
|
): Promise<void> {
|
||||||
|
const wildcard = domain.startsWith("*.");
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let oldCertPem: string | null = null;
|
||||||
|
let oldKeyPem: string | null = null;
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
|
try {
|
||||||
|
const storedCertPem = decrypt(
|
||||||
|
existing[0].certFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||||
|
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oldCertPem = storedCertPem;
|
||||||
|
if (existing[0].keyFile) {
|
||||||
|
try {
|
||||||
|
oldKeyPem = decrypt(
|
||||||
|
existing[0].keyFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
} catch (keyErr) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
try {
|
||||||
|
expiresAt = Math.floor(
|
||||||
|
new Date(validatedX509.validTo).getTime() / 1000
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedCert = encrypt(
|
||||||
|
certPem,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const domainId = await findDomainId(domain);
|
||||||
|
if (domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard,
|
||||||
|
...(domainId !== null && { domainId })
|
||||||
|
})
|
||||||
|
.where(eq(certificates.domain, domain));
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
oldCertPem,
|
||||||
|
oldKeyPem
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function findAcmeJsonFiles(dirPath: string): string[] {
|
function findAcmeJsonFiles(dirPath: string): string[] {
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
@@ -575,18 +702,16 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const cert of allCerts) {
|
for (const cert of allCerts) {
|
||||||
const domain = cert?.domain?.main;
|
const mainDomain = cert?.domain?.main;
|
||||||
|
|
||||||
if (!domain || typeof domain !== "string") {
|
if (!mainDomain || typeof mainDomain !== "string") {
|
||||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
|
|
||||||
|
|
||||||
if (!cert.certificate || !cert.key) {
|
if (!cert.certificate || !cert.key) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -598,14 +723,14 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
|
`acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!certPem.trim() || !keyPem.trim()) {
|
if (!certPem.trim() || !keyPem.trim()) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -616,7 +741,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
const firstCertPemForValidation = extractFirstCert(certPem);
|
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||||
if (!firstCertPemForValidation) {
|
if (!firstCertPemForValidation) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
|
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -628,7 +753,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
|
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -638,139 +763,40 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
crypto.createPrivateKey(keyPem);
|
crypto.createPrivateKey(keyPem);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
|
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if cert already exists in DB
|
// Collect all domains covered by this cert: main + every SAN.
|
||||||
const existing = await db
|
// Each domain gets its own row in the certificates table so that
|
||||||
.select()
|
// lookups by any hostname on the cert succeed independently.
|
||||||
.from(certificates)
|
const allDomains = new Set<string>([mainDomain]);
|
||||||
.where(and(eq(certificates.domain, domain)))
|
if (Array.isArray(cert.domain?.sans)) {
|
||||||
.limit(1);
|
for (const san of cert.domain.sans) {
|
||||||
|
if (typeof san === "string" && san.trim()) {
|
||||||
let oldCertPem: string | null = null;
|
allDomains.add(san.trim());
|
||||||
let oldKeyPem: string | null = null;
|
|
||||||
|
|
||||||
if (existing.length > 0 && existing[0].certFile) {
|
|
||||||
try {
|
|
||||||
const storedCertPem = decrypt(
|
|
||||||
existing[0].certFile,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
|
||||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
|
||||||
// logger.debug(
|
|
||||||
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
|
|
||||||
// );
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
// Cert has changed; capture old values so we can send a correct
|
|
||||||
// update message to the newt after the DB write.
|
|
||||||
oldCertPem = storedCertPem;
|
|
||||||
if (existing[0].keyFile) {
|
|
||||||
try {
|
|
||||||
oldKeyPem = decrypt(
|
|
||||||
existing[0].keyFile,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
} catch (keyErr) {
|
|
||||||
logger.debug(
|
|
||||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Decryption failure means we should proceed with the update
|
|
||||||
logger.debug(
|
|
||||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse cert expiry from the validated X.509 certificate
|
logger.debug(
|
||||||
let expiresAt: number | null = null;
|
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||||
try {
|
|
||||||
expiresAt = Math.floor(
|
|
||||||
new Date(validatedX509.validTo).getTime() / 1000
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug(
|
|
||||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedCert = encrypt(
|
|
||||||
certPem,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
);
|
||||||
const encryptedKey = encrypt(
|
|
||||||
keyPem,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const domainId = await findDomainId(domain);
|
for (const domain of allDomains) {
|
||||||
if (domainId) {
|
try {
|
||||||
logger.debug(
|
await storeCertForDomain(
|
||||||
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
domain,
|
||||||
);
|
certPem,
|
||||||
} else {
|
keyPem,
|
||||||
logger.debug(
|
validatedX509
|
||||||
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
);
|
||||||
);
|
} catch (err) {
|
||||||
}
|
logger.error(
|
||||||
|
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
|
||||||
if (existing.length > 0) {
|
);
|
||||||
logger.debug(
|
}
|
||||||
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
|
||||||
);
|
|
||||||
await db
|
|
||||||
.update(certificates)
|
|
||||||
.set({
|
|
||||||
certFile: encryptedCert,
|
|
||||||
keyFile: encryptedKey,
|
|
||||||
status: "valid",
|
|
||||||
expiresAt,
|
|
||||||
updatedAt: now,
|
|
||||||
wildcard,
|
|
||||||
...(domainId !== null && { domainId })
|
|
||||||
})
|
|
||||||
.where(eq(certificates.domain, domain));
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
|
||||||
);
|
|
||||||
|
|
||||||
await pushCertUpdateToAffectedNewts(
|
|
||||||
domain,
|
|
||||||
domainId,
|
|
||||||
oldCertPem,
|
|
||||||
oldKeyPem
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
|
||||||
);
|
|
||||||
await db.insert(certificates).values({
|
|
||||||
domain,
|
|
||||||
domainId,
|
|
||||||
certFile: encryptedCert,
|
|
||||||
keyFile: encryptedKey,
|
|
||||||
status: "valid",
|
|
||||||
expiresAt,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
wildcard
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// For a brand-new cert, push to any SSL resources that were waiting for it
|
|
||||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface ConnectionLogRecord {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
clientId: number | null;
|
clientId: number | null;
|
||||||
|
clientEndpoint: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
sourceAddr: string;
|
sourceAddr: string;
|
||||||
destAddr: string;
|
destAddr: string;
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import {
|
|||||||
LOG_TYPES,
|
LOG_TYPES,
|
||||||
LogEvent,
|
LogEvent,
|
||||||
DestinationFailureState,
|
DestinationFailureState,
|
||||||
HttpConfig
|
HttpConfig,
|
||||||
|
S3Config
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { LogDestinationProvider } from "./providers/LogDestinationProvider";
|
import { LogDestinationProvider } from "./providers/LogDestinationProvider";
|
||||||
import { HttpLogDestination } from "./providers/HttpLogDestination";
|
import { HttpLogDestination } from "./providers/HttpLogDestination";
|
||||||
|
import { S3LogDestination } from "./providers/S3LogDestination";
|
||||||
import type { EventStreamingDestination } from "@server/db";
|
import type { EventStreamingDestination } from "@server/db";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -72,11 +74,11 @@ const MAX_CATCHUP_BATCHES = 20;
|
|||||||
* After the last entry the max value is re-used.
|
* After the last entry the max value is re-used.
|
||||||
*/
|
*/
|
||||||
const BACKOFF_SCHEDULE_MS = [
|
const BACKOFF_SCHEDULE_MS = [
|
||||||
60_000, // 1 min (failure 1)
|
60_000, // 1 min (failure 1)
|
||||||
2 * 60_000, // 2 min (failure 2)
|
2 * 60_000, // 2 min (failure 2)
|
||||||
5 * 60_000, // 5 min (failure 3)
|
5 * 60_000, // 5 min (failure 3)
|
||||||
10 * 60_000, // 10 min (failure 4)
|
10 * 60_000, // 10 min (failure 4)
|
||||||
30 * 60_000 // 30 min (failure 5+)
|
30 * 60_000 // 30 min (failure 5+)
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,7 +206,10 @@ export class LogStreamingManager {
|
|||||||
this.pollTimer = null;
|
this.pollTimer = null;
|
||||||
this.runPoll()
|
this.runPoll()
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
logger.error("LogStreamingManager: unexpected poll error", err)
|
logger.error(
|
||||||
|
"LogStreamingManager: unexpected poll error",
|
||||||
|
err
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
@@ -275,10 +280,13 @@ export class LogStreamingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt and parse config – skip destination if either step fails
|
// Decrypt and parse config – skip destination if either step fails
|
||||||
let configFromDb: HttpConfig;
|
let configFromDb: unknown;
|
||||||
try {
|
try {
|
||||||
const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!);
|
const decryptedConfig = decrypt(
|
||||||
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
|
dest.config,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
configFromDb = JSON.parse(decryptedConfig);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
|
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
|
||||||
@@ -305,6 +313,7 @@ export class LogStreamingManager {
|
|||||||
if (enabledTypes.length === 0) return;
|
if (enabledTypes.length === 0) return;
|
||||||
|
|
||||||
let anyFailure = false;
|
let anyFailure = false;
|
||||||
|
let firstError: string | null = null;
|
||||||
|
|
||||||
for (const logType of enabledTypes) {
|
for (const logType of enabledTypes) {
|
||||||
if (!this.isRunning) break;
|
if (!this.isRunning) break;
|
||||||
@@ -312,6 +321,10 @@ export class LogStreamingManager {
|
|||||||
await this.processLogType(dest, provider, logType);
|
await this.processLogType(dest, provider, logType);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
anyFailure = true;
|
anyFailure = true;
|
||||||
|
if (firstError === null) {
|
||||||
|
firstError =
|
||||||
|
err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
logger.error(
|
logger.error(
|
||||||
`LogStreamingManager: failed to process "${logType}" logs ` +
|
`LogStreamingManager: failed to process "${logType}" logs ` +
|
||||||
`for destination ${dest.destinationId}`,
|
`for destination ${dest.destinationId}`,
|
||||||
@@ -322,6 +335,10 @@ export class LogStreamingManager {
|
|||||||
|
|
||||||
if (anyFailure) {
|
if (anyFailure) {
|
||||||
this.recordFailure(dest.destinationId);
|
this.recordFailure(dest.destinationId);
|
||||||
|
await this.setDestinationError(
|
||||||
|
dest.destinationId,
|
||||||
|
firstError ?? "Unknown error"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Any success resets the failure/back-off state
|
// Any success resets the failure/back-off state
|
||||||
if (this.failures.has(dest.destinationId)) {
|
if (this.failures.has(dest.destinationId)) {
|
||||||
@@ -330,6 +347,7 @@ export class LogStreamingManager {
|
|||||||
`LogStreamingManager: destination ${dest.destinationId} recovered`
|
`LogStreamingManager: destination ${dest.destinationId} recovered`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.clearDestinationError(dest.destinationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +380,10 @@ export class LogStreamingManager {
|
|||||||
.from(eventStreamingCursors)
|
.from(eventStreamingCursors)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(eventStreamingCursors.destinationId, dest.destinationId),
|
eq(
|
||||||
|
eventStreamingCursors.destinationId,
|
||||||
|
dest.destinationId
|
||||||
|
),
|
||||||
eq(eventStreamingCursors.logType, logType)
|
eq(eventStreamingCursors.logType, logType)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -431,9 +452,7 @@ export class LogStreamingManager {
|
|||||||
|
|
||||||
if (rows.length === 0) break;
|
if (rows.length === 0) break;
|
||||||
|
|
||||||
const events = rows.map((row) =>
|
const events = rows.map((row) => this.rowToLogEvent(logType, row));
|
||||||
this.rowToLogEvent(logType, row)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Throws on failure – caught by the caller which applies back-off
|
// Throws on failure – caught by the caller which applies back-off
|
||||||
await provider.send(events);
|
await provider.send(events);
|
||||||
@@ -677,8 +696,7 @@ export class LogStreamingManager {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId =
|
const orgId = typeof row.orgId === "string" ? row.orgId : "";
|
||||||
typeof row.orgId === "string" ? row.orgId : "";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -708,6 +726,8 @@ export class LogStreamingManager {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "http":
|
case "http":
|
||||||
return new HttpLogDestination(config as HttpConfig);
|
return new HttpLogDestination(config as HttpConfig);
|
||||||
|
case "s3":
|
||||||
|
return new S3LogDestination(config as S3Config);
|
||||||
// Future providers:
|
// Future providers:
|
||||||
// case "datadog": return new DatadogLogDestination(config as DatadogConfig);
|
// case "datadog": return new DatadogLogDestination(config as DatadogConfig);
|
||||||
default:
|
default:
|
||||||
@@ -749,6 +769,45 @@ export class LogStreamingManager {
|
|||||||
// DB helpers
|
// DB helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async setDestinationError(
|
||||||
|
destinationId: number,
|
||||||
|
errorMessage: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Truncate to 1000 chars so it fits comfortably in the text column.
|
||||||
|
const truncated = errorMessage.slice(0, 1000);
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(eventStreamingDestinations)
|
||||||
|
.set({ lastError: truncated, lastErrorAt: Date.now() })
|
||||||
|
.where(
|
||||||
|
eq(eventStreamingDestinations.destinationId, destinationId)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`LogStreamingManager: could not persist error status for destination ${destinationId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearDestinationError(destinationId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Only update if there is actually an error stored, to avoid
|
||||||
|
// unnecessary writes on every successful poll cycle.
|
||||||
|
await db
|
||||||
|
.update(eventStreamingDestinations)
|
||||||
|
.set({ lastError: null, lastErrorAt: null })
|
||||||
|
.where(
|
||||||
|
eq(eventStreamingDestinations.destinationId, destinationId)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`LogStreamingManager: could not clear error status for destination ${destinationId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadEnabledDestinations(): Promise<
|
private async loadEnabledDestinations(): Promise<
|
||||||
EventStreamingDestination[]
|
EventStreamingDestination[]
|
||||||
> {
|
> {
|
||||||
|
|||||||
279
server/private/lib/logStreaming/providers/S3LogDestination.ts
Normal file
279
server/private/lib/logStreaming/providers/S3LogDestination.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { gzip as gzipCallback } from "zlib";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { LogEvent, S3Config, S3PayloadFormat } from "../types";
|
||||||
|
import { LogDestinationProvider } from "./LogDestinationProvider";
|
||||||
|
|
||||||
|
const gzipAsync = promisify(gzipCallback);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Maximum time (ms) to wait for a single S3 PutObject response. */
|
||||||
|
const REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/** Default payload format when none is specified in the config. */
|
||||||
|
const DEFAULT_FORMAT: S3PayloadFormat = "json_array";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// S3LogDestination
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwards a batch of log events to an S3-compatible object store by
|
||||||
|
* uploading a single object per `send()` call.
|
||||||
|
*
|
||||||
|
* **Object key layout**
|
||||||
|
* ```
|
||||||
|
* {prefix}/{logType}/{YYYY}/{MM}/{DD}/{HH}-{mm}-{ss}-{uuid}.{ext}[.gz]
|
||||||
|
* ```
|
||||||
|
* - `prefix` – from `config.prefix` (default: empty – key starts at logType)
|
||||||
|
* - `logType` – one of "request", "action", "access", "connection"
|
||||||
|
* - Date components are derived from the upload time (UTC)
|
||||||
|
* - `ext` – `json` | `ndjson` | `csv`
|
||||||
|
* - `.gz` – appended when `config.gzip` is true
|
||||||
|
*
|
||||||
|
* **Payload formats** (controlled by `config.format`):
|
||||||
|
* - `json_array` (default) – body is a JSON array of event objects.
|
||||||
|
* - `ndjson` – one JSON object per line (newline-delimited).
|
||||||
|
* - `csv` – RFC-4180 CSV with a header row; columns are the
|
||||||
|
* union of all field names in the batch's event data.
|
||||||
|
*
|
||||||
|
* **Compression**: when `config.gzip` is `true` the body is gzip-compressed
|
||||||
|
* before upload and `Content-Encoding: gzip` is set on the object.
|
||||||
|
*
|
||||||
|
* **Custom endpoint**: set `config.endpoint` to target any S3-compatible
|
||||||
|
* storage service (e.g. MinIO, Cloudflare R2).
|
||||||
|
*/
|
||||||
|
export class S3LogDestination implements LogDestinationProvider {
|
||||||
|
readonly type = "s3";
|
||||||
|
|
||||||
|
private readonly config: S3Config;
|
||||||
|
|
||||||
|
constructor(config: S3Config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// LogDestinationProvider implementation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async send(events: LogEvent[]): Promise<void> {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
|
||||||
|
const format = this.config.format ?? DEFAULT_FORMAT;
|
||||||
|
const useGzip = this.config.gzip ?? false;
|
||||||
|
const logType = events[0].logType;
|
||||||
|
|
||||||
|
const rawBody = this.serialize(events, format);
|
||||||
|
const bodyBuffer = Buffer.from(rawBody, "utf-8");
|
||||||
|
|
||||||
|
let uploadBody: Buffer;
|
||||||
|
let contentEncoding: string | undefined;
|
||||||
|
|
||||||
|
if (useGzip) {
|
||||||
|
uploadBody = (await gzipAsync(bodyBuffer)) as Buffer;
|
||||||
|
contentEncoding = "gzip";
|
||||||
|
} else {
|
||||||
|
uploadBody = bodyBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.buildObjectKey(logType, format, useGzip);
|
||||||
|
const contentType = this.contentType(format);
|
||||||
|
|
||||||
|
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
|
||||||
|
region: this.config.region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: this.config.accessKeyId,
|
||||||
|
secretAccessKey: this.config.secretAccessKey
|
||||||
|
},
|
||||||
|
requestHandler: {
|
||||||
|
requestTimeout: REQUEST_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.config.endpoint?.trim()) {
|
||||||
|
clientConfig.endpoint = this.config.endpoint.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new S3Client(clientConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: uploadBody,
|
||||||
|
ContentType: contentType,
|
||||||
|
...(contentEncoding
|
||||||
|
? { ContentEncoding: contentEncoding }
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
`S3LogDestination: failed to upload object "${key}" ` +
|
||||||
|
`to bucket "${this.config.bucket}" – ${msg}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a unique S3 object key for the given log type and format.
|
||||||
|
* Keys are partitioned by logType and date so they can be queried or
|
||||||
|
* lifecycle-managed independently.
|
||||||
|
*/
|
||||||
|
private buildObjectKey(
|
||||||
|
logType: string,
|
||||||
|
format: S3PayloadFormat,
|
||||||
|
gzip: boolean
|
||||||
|
): string {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getUTCFullYear();
|
||||||
|
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getUTCDate()).padStart(2, "0");
|
||||||
|
const hh = String(now.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(now.getUTCMinutes()).padStart(2, "0");
|
||||||
|
const ss = String(now.getUTCSeconds()).padStart(2, "0");
|
||||||
|
const uid = randomUUID();
|
||||||
|
|
||||||
|
const ext =
|
||||||
|
format === "csv" ? "csv" : format === "ndjson" ? "ndjson" : "json";
|
||||||
|
const fileName = `${hh}-${mm}-${ss}-${uid}.${ext}${gzip ? ".gz" : ""}`;
|
||||||
|
|
||||||
|
const rawPrefix = (this.config.prefix ?? "").trim().replace(/\/+$/, "");
|
||||||
|
const parts = [
|
||||||
|
rawPrefix,
|
||||||
|
logType,
|
||||||
|
`${year}/${month}/${day}`,
|
||||||
|
fileName
|
||||||
|
].filter((p) => p !== "");
|
||||||
|
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private contentType(format: S3PayloadFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case "csv":
|
||||||
|
return "text/csv; charset=utf-8";
|
||||||
|
case "ndjson":
|
||||||
|
return "application/x-ndjson";
|
||||||
|
default:
|
||||||
|
return "application/json";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(events: LogEvent[], format: S3PayloadFormat): string {
|
||||||
|
switch (format) {
|
||||||
|
case "json_array":
|
||||||
|
return JSON.stringify(events.map(toPayload));
|
||||||
|
case "ndjson":
|
||||||
|
return events
|
||||||
|
.map((e) => JSON.stringify(toPayload(e)))
|
||||||
|
.join("\n");
|
||||||
|
case "csv":
|
||||||
|
return toCsv(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Payload helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function toPayload(event: LogEvent): unknown {
|
||||||
|
return {
|
||||||
|
event: event.logType,
|
||||||
|
timestamp: new Date(event.timestamp * 1000).toISOString(),
|
||||||
|
data: event.data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a batch of events to RFC-4180 CSV.
|
||||||
|
*
|
||||||
|
* The column set is the union of `event`, `timestamp`, and all keys present in
|
||||||
|
* `event.data` across the batch, preserving insertion order. Values that
|
||||||
|
* contain commas, double-quotes, or newlines are quoted and escaped.
|
||||||
|
*/
|
||||||
|
function toCsv(events: LogEvent[]): string {
|
||||||
|
if (events.length === 0) return "";
|
||||||
|
|
||||||
|
// Collect all unique data keys in stable order
|
||||||
|
const keySet = new LinkedSet<string>();
|
||||||
|
keySet.add("event");
|
||||||
|
keySet.add("timestamp");
|
||||||
|
for (const e of events) {
|
||||||
|
for (const k of Object.keys(e.data)) {
|
||||||
|
keySet.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const headers = keySet.toArray();
|
||||||
|
|
||||||
|
const rows: string[] = [headers.map(csvEscape).join(",")];
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
const flat: Record<string, unknown> = {
|
||||||
|
event: e.logType,
|
||||||
|
timestamp: new Date(e.timestamp * 1000).toISOString(),
|
||||||
|
...e.data
|
||||||
|
};
|
||||||
|
rows.push(
|
||||||
|
headers.map((h) => csvEscape(flattenValue(flat[h]))).join(",")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten a value to a plain string suitable for a CSV cell. */
|
||||||
|
function flattenValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC-4180 CSV escaping. */
|
||||||
|
function csvEscape(value: string): string {
|
||||||
|
if (/[",\n\r]/.test(value)) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal ordered set (preserves insertion order, deduplicates)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LinkedSet<T> {
|
||||||
|
private readonly map = new Map<T, true>();
|
||||||
|
|
||||||
|
add(value: T): void {
|
||||||
|
this.map.set(value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray(): T[] {
|
||||||
|
return Array.from(this.map.keys());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,40 @@ export interface HttpConfig {
|
|||||||
bodyTemplate?: string;
|
bodyTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// S3 destination configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls how the batch of events is serialised into each S3 object.
|
||||||
|
*
|
||||||
|
* - `json_array` – `[{…}, {…}]` – default; each object is a JSON array.
|
||||||
|
* - `ndjson` – `{…}\n{…}` – newline-delimited JSON, one object per line.
|
||||||
|
* - `csv` – RFC-4180 CSV with a header row derived from the event fields.
|
||||||
|
*/
|
||||||
|
export type S3PayloadFormat = "json_array" | "ndjson" | "csv";
|
||||||
|
|
||||||
|
export interface S3Config {
|
||||||
|
/** Human-readable label for the destination */
|
||||||
|
name: string;
|
||||||
|
/** AWS Access Key ID */
|
||||||
|
accessKeyId: string;
|
||||||
|
/** AWS Secret Access Key */
|
||||||
|
secretAccessKey: string;
|
||||||
|
/** AWS region (e.g. "us-east-1") */
|
||||||
|
region: string;
|
||||||
|
/** Target S3 bucket name */
|
||||||
|
bucket: string;
|
||||||
|
/** Optional key prefix – appended before the auto-generated path */
|
||||||
|
prefix?: string;
|
||||||
|
/** Override the S3 endpoint for S3-compatible storage (e.g. MinIO, R2) */
|
||||||
|
endpoint?: string;
|
||||||
|
/** How events are serialised into each object. Defaults to "json_array". */
|
||||||
|
format: S3PayloadFormat;
|
||||||
|
/** Whether to gzip-compress the object before upload. */
|
||||||
|
gzip: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-destination per-log-type cursor (reflects the DB table)
|
// Per-destination per-log-type cursor (reflects the DB table)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -124,15 +124,11 @@ function getWhere(data: Q) {
|
|||||||
data.clientId
|
data.clientId
|
||||||
? eq(connectionAuditLog.clientId, data.clientId)
|
? eq(connectionAuditLog.clientId, data.clientId)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.siteId
|
data.siteId ? eq(connectionAuditLog.siteId, data.siteId) : undefined,
|
||||||
? eq(connectionAuditLog.siteId, data.siteId)
|
|
||||||
: undefined,
|
|
||||||
data.siteResourceId
|
data.siteResourceId
|
||||||
? eq(connectionAuditLog.siteResourceId, data.siteResourceId)
|
? eq(connectionAuditLog.siteResourceId, data.siteResourceId)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.userId
|
data.userId ? eq(connectionAuditLog.userId, data.userId) : undefined
|
||||||
? eq(connectionAuditLog.userId, data.userId)
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +140,7 @@ export function queryConnection(data: Q) {
|
|||||||
orgId: connectionAuditLog.orgId,
|
orgId: connectionAuditLog.orgId,
|
||||||
siteId: connectionAuditLog.siteId,
|
siteId: connectionAuditLog.siteId,
|
||||||
clientId: connectionAuditLog.clientId,
|
clientId: connectionAuditLog.clientId,
|
||||||
|
clientEndpoint: connectionAuditLog.clientEndpoint,
|
||||||
userId: connectionAuditLog.userId,
|
userId: connectionAuditLog.userId,
|
||||||
sourceAddr: connectionAuditLog.sourceAddr,
|
sourceAddr: connectionAuditLog.sourceAddr,
|
||||||
destAddr: connectionAuditLog.destAddr,
|
destAddr: connectionAuditLog.destAddr,
|
||||||
@@ -203,10 +200,7 @@ async function enrichWithDetails(
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Fetch resource details from main database
|
// Fetch resource details from main database
|
||||||
const resourceMap = new Map<
|
const resourceMap = new Map<number, { name: string; niceId: string }>();
|
||||||
number,
|
|
||||||
{ name: string; niceId: string }
|
|
||||||
>();
|
|
||||||
if (siteResourceIds.length > 0) {
|
if (siteResourceIds.length > 0) {
|
||||||
const resourceDetails = await primaryDb
|
const resourceDetails = await primaryDb
|
||||||
.select({
|
.select({
|
||||||
@@ -268,10 +262,7 @@ async function enrichWithDetails(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user details from main database
|
// Fetch user details from main database
|
||||||
const userMap = new Map<
|
const userMap = new Map<string, { email: string | null }>();
|
||||||
string,
|
|
||||||
{ email: string | null }
|
|
||||||
>();
|
|
||||||
if (userIds.length > 0) {
|
if (userIds.length > 0) {
|
||||||
const userDetails = await primaryDb
|
const userDetails = await primaryDb
|
||||||
.select({
|
.select({
|
||||||
@@ -290,29 +281,25 @@ async function enrichWithDetails(
|
|||||||
return logs.map((log) => ({
|
return logs.map((log) => ({
|
||||||
...log,
|
...log,
|
||||||
resourceName: log.siteResourceId
|
resourceName: log.siteResourceId
|
||||||
? resourceMap.get(log.siteResourceId)?.name ?? null
|
? (resourceMap.get(log.siteResourceId)?.name ?? null)
|
||||||
: null,
|
: null,
|
||||||
resourceNiceId: log.siteResourceId
|
resourceNiceId: log.siteResourceId
|
||||||
? resourceMap.get(log.siteResourceId)?.niceId ?? null
|
? (resourceMap.get(log.siteResourceId)?.niceId ?? null)
|
||||||
: null,
|
|
||||||
siteName: log.siteId
|
|
||||||
? siteMap.get(log.siteId)?.name ?? null
|
|
||||||
: null,
|
: null,
|
||||||
|
siteName: log.siteId ? (siteMap.get(log.siteId)?.name ?? null) : null,
|
||||||
siteNiceId: log.siteId
|
siteNiceId: log.siteId
|
||||||
? siteMap.get(log.siteId)?.niceId ?? null
|
? (siteMap.get(log.siteId)?.niceId ?? null)
|
||||||
: null,
|
: null,
|
||||||
clientName: log.clientId
|
clientName: log.clientId
|
||||||
? clientMap.get(log.clientId)?.name ?? null
|
? (clientMap.get(log.clientId)?.name ?? null)
|
||||||
: null,
|
: null,
|
||||||
clientNiceId: log.clientId
|
clientNiceId: log.clientId
|
||||||
? clientMap.get(log.clientId)?.niceId ?? null
|
? (clientMap.get(log.clientId)?.niceId ?? null)
|
||||||
: null,
|
: null,
|
||||||
clientType: log.clientId
|
clientType: log.clientId
|
||||||
? clientMap.get(log.clientId)?.type ?? null
|
? (clientMap.get(log.clientId)?.type ?? null)
|
||||||
: null,
|
: null,
|
||||||
userEmail: log.userId
|
userEmail: log.userId ? (userMap.get(log.userId)?.email ?? null) : null
|
||||||
? userMap.get(log.userId)?.email ?? null
|
|
||||||
: null
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export type ListEventStreamingDestinationsResponse = {
|
|||||||
type: string;
|
type: string;
|
||||||
config: string;
|
config: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
lastError: string | null;
|
||||||
|
lastErrorAt: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
sendConnectionLogs: boolean;
|
sendConnectionLogs: boolean;
|
||||||
@@ -79,7 +81,8 @@ async function query(orgId: string, limit: number, offset: number) {
|
|||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/org/{orgId}/event-streaming-destination",
|
path: "/org/{orgId}/event-streaming-destination",
|
||||||
description: "List all event streaming destinations for a specific organization.",
|
description:
|
||||||
|
"List all event streaming destinations for a specific organization.",
|
||||||
tags: [OpenAPITags.Org],
|
tags: [OpenAPITags.Org],
|
||||||
request: {
|
request: {
|
||||||
query: querySchema,
|
query: querySchema,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db } from "@server/db";
|
import { clientSitesAssociationsCache, db } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { sites, Newt, clients, orgs } from "@server/db";
|
import { sites, Newt, clients, orgs } from "@server/db";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
@@ -146,7 +146,11 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
// each unique sourceAddr + the org's CIDR suffix and do a targeted IN query.
|
// each unique sourceAddr + the org's CIDR suffix and do a targeted IN query.
|
||||||
const ipToClient = new Map<
|
const ipToClient = new Map<
|
||||||
string,
|
string,
|
||||||
{ clientId: number; userId: string | null }
|
{
|
||||||
|
clientId: number;
|
||||||
|
userId: string | null;
|
||||||
|
clientEndpoint: string | null;
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
if (cidrSuffix) {
|
if (cidrSuffix) {
|
||||||
@@ -172,9 +176,21 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
.select({
|
.select({
|
||||||
clientId: clients.clientId,
|
clientId: clients.clientId,
|
||||||
userId: clients.userId,
|
userId: clients.userId,
|
||||||
subnet: clients.subnet
|
subnet: clients.subnet,
|
||||||
|
clientEndpoint: clientSitesAssociationsCache.endpoint
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
|
.leftJoin(
|
||||||
|
// this should be one to one
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
clients.clientId,
|
||||||
|
clientSitesAssociationsCache.clientId
|
||||||
|
),
|
||||||
|
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||||
|
)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(clients.orgId, orgId),
|
eq(clients.orgId, orgId),
|
||||||
@@ -189,7 +205,8 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
);
|
);
|
||||||
ipToClient.set(ip, {
|
ipToClient.set(ip, {
|
||||||
clientId: c.clientId,
|
clientId: c.clientId,
|
||||||
userId: c.userId
|
userId: c.userId,
|
||||||
|
clientEndpoint: c.clientEndpoint
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +251,7 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
orgId,
|
orgId,
|
||||||
siteId: newt.siteId,
|
siteId: newt.siteId,
|
||||||
clientId: clientInfo?.clientId ?? null,
|
clientId: clientInfo?.clientId ?? null,
|
||||||
|
clientEndpoint: clientInfo?.clientEndpoint ?? null,
|
||||||
userId: clientInfo?.userId ?? null,
|
userId: clientInfo?.userId ?? null,
|
||||||
sourceAddr: session.sourceAddr,
|
sourceAddr: session.sourceAddr,
|
||||||
destAddr: session.destAddr,
|
destAddr: session.destAddr,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { clients, db } from "@server/db";
|
import { clients, db, primaryDb, Client } from "@server/db";
|
||||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -98,15 +98,6 @@ export async function addUserRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingUser[0].isOwner) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"Cannot change the role of the owner of the organization"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleExists = await db
|
const roleExists = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
@@ -122,8 +113,12 @@ export async function addUserRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let newUserRole: { userId: string; orgId: string; roleId: number } | null =
|
let newUserRole: {
|
||||||
null;
|
userId: string;
|
||||||
|
orgId: string;
|
||||||
|
roleId: number;
|
||||||
|
} | null = null;
|
||||||
|
let orgClientsToRebuild: Client[] = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const inserted = await trx
|
const inserted = await trx
|
||||||
.insert(userOrgRoles)
|
.insert(userOrgRoles)
|
||||||
@@ -149,11 +144,19 @@ export async function addUserRole(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const orgClient of orgClients) {
|
orgClientsToRebuild = orgClients;
|
||||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: newUserRole ?? { userId, orgId: role.orgId, roleId },
|
data: newUserRole ?? { userId, orgId: role.orgId, roleId },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { db } from "@server/db";
|
import { db, primaryDb, Client } from "@server/db";
|
||||||
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
|
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -98,11 +98,11 @@ export async function removeUserRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingUser.isOwner) {
|
if (existingUser.isOwner && role.isAdmin === true) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"Cannot change the roles of the owner of the organization"
|
"Cannot remove the administrator role from the organization owner"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,6 +129,7 @@ export async function removeUserRole(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orgClientsToRebuild: Client[] = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(userOrgRoles)
|
.delete(userOrgRoles)
|
||||||
@@ -150,11 +151,19 @@ export async function removeUserRole(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const orgClient of orgClients) {
|
orgClientsToRebuild = orgClients;
|
||||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { userId, orgId: role.orgId, roleId },
|
data: { userId, orgId: role.orgId, roleId },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { clients, db } from "@server/db";
|
import { clients, db, primaryDb, Client } from "@server/db";
|
||||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -87,17 +87,8 @@ export async function setUserOrgRoles(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingUser.isOwner) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"Cannot change the roles of the owner of the organization"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgRoles = await db
|
const orgRoles = await db
|
||||||
.select({ roleId: roles.roleId })
|
.select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -115,6 +106,19 @@ export async function setUserOrgRoles(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingUser.isOwner) {
|
||||||
|
const hasAdminRole = orgRoles.some((r) => r.isAdmin === true);
|
||||||
|
if (!hasAdminRole) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"The organization owner must retain an administrator role"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let orgClientsToRebuild: Client[] = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(userOrgRoles)
|
.delete(userOrgRoles)
|
||||||
@@ -142,11 +146,19 @@ export async function setUserOrgRoles(
|
|||||||
and(eq(clients.userId, userId), eq(clients.orgId, orgId))
|
and(eq(clients.userId, userId), eq(clients.orgId, orgId))
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const orgClient of orgClients) {
|
orgClientsToRebuild = orgClients;
|
||||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { userId, orgId, roleIds: uniqueRoleIds },
|
data: { userId, orgId, roleIds: uniqueRoleIds },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export type QueryConnectionAuditLogResponse = {
|
|||||||
orgId: string | null;
|
orgId: string | null;
|
||||||
siteId: number | null;
|
siteId: number | null;
|
||||||
clientId: number | null;
|
clientId: number | null;
|
||||||
|
clientEndpoint: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
sourceAddr: string;
|
sourceAddr: string;
|
||||||
destAddr: string;
|
destAddr: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, orgs, userOrgs, users } from "@server/db";
|
import { db, orgs, userOrgs, users, primaryDb } from "@server/db";
|
||||||
import { eq, and, inArray, not } from "drizzle-orm";
|
import { eq, and, inArray, not } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -218,13 +218,18 @@ export async function deleteMyAccount(
|
|||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx.delete(users).where(eq(users.userId, userId));
|
await trx.delete(users).where(eq(users.userId, userId));
|
||||||
await calculateUserClientsForOrgs(userId, trx);
|
|
||||||
// loop through the other orgs and decrement the count
|
// loop through the other orgs and decrement the count
|
||||||
for (const userOrg of otherOrgsTheUserWasIn) {
|
for (const userOrg of otherOrgsTheUserWasIn) {
|
||||||
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
|
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to calculate user clients after deleting account for user ${userId}: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invalidateSession(session.sessionId);
|
await invalidateSession(session.sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, primaryDb } from "@server/db";
|
||||||
import {
|
import {
|
||||||
roles,
|
roles,
|
||||||
Client,
|
Client,
|
||||||
@@ -92,7 +92,10 @@ export async function createClient(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
if (
|
||||||
|
req.user &&
|
||||||
|
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||||
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
@@ -198,7 +201,10 @@ export async function createClient(
|
|||||||
|
|
||||||
if (!randomExitNode) {
|
if (!randomExitNode) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`)
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,10 +262,18 @@ export async function createClient(
|
|||||||
clientId: newClient.clientId,
|
clientId: newClient.clientId,
|
||||||
dateCreated: moment().toISOString()
|
dateCreated: moment().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(newClient, trx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (newClient) {
|
||||||
|
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after creating client: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<CreateClientResponse>(res, {
|
return response<CreateClientResponse>(res, {
|
||||||
data: newClient,
|
data: newClient,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, primaryDb } from "@server/db";
|
||||||
import {
|
import {
|
||||||
roles,
|
roles,
|
||||||
Client,
|
Client,
|
||||||
@@ -237,10 +237,18 @@ export async function createUserClient(
|
|||||||
userId,
|
userId,
|
||||||
clientId: newClient.clientId
|
clientId: newClient.clientId
|
||||||
});
|
});
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(newClient, trx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (newClient) {
|
||||||
|
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after creating user client: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response<CreateClientAndOlmResponse>(res, {
|
return response<CreateClientAndOlmResponse>(res, {
|
||||||
data: newClient,
|
data: newClient,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms, primaryDb, Client, Olm } from "@server/db";
|
||||||
import { clients, clientSitesAssociationsCache } from "@server/db";
|
import { clients, clientSitesAssociationsCache } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -71,14 +71,17 @@ export async function deleteClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deletedClient: Client | undefined;
|
||||||
|
let olm: Olm | undefined;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Then delete the client itself
|
// Then delete the client itself
|
||||||
const [deletedClient] = await trx
|
[deletedClient] = await trx
|
||||||
.delete(clients)
|
.delete(clients)
|
||||||
.where(eq(clients.clientId, clientId))
|
.where(eq(clients.clientId, clientId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const [olm] = await trx
|
[olm] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(olms)
|
.from(olms)
|
||||||
.where(eq(olms.clientId, clientId))
|
.where(eq(olms.clientId, clientId))
|
||||||
@@ -88,14 +91,29 @@ export async function deleteClient(
|
|||||||
if (!client.userId && client.olmId) {
|
if (!client.userId && client.olmId) {
|
||||||
await trx.delete(olms).where(eq(olms.olmId, client.olmId));
|
await trx.delete(olms).where(eq(olms.olmId, client.olmId));
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
|
||||||
|
|
||||||
if (olm) {
|
|
||||||
await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (deletedClient) {
|
||||||
|
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (olm) {
|
||||||
|
sendTerminateClient(
|
||||||
|
deletedClient.clientId,
|
||||||
|
OlmErrorCodes.TERMINATED_DELETED,
|
||||||
|
olm.olmId
|
||||||
|
).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting client ${clientId}: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms, primaryDb } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -81,16 +81,19 @@ export async function createUserOlm(
|
|||||||
|
|
||||||
const secretHash = await hashPassword(secret);
|
const secretHash = await hashPassword(secret);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.insert(olms).values({
|
||||||
await trx.insert(olms).values({
|
olmId: olmId,
|
||||||
olmId: olmId,
|
userId,
|
||||||
userId,
|
name,
|
||||||
name,
|
secretHash,
|
||||||
secretHash,
|
dateCreated: moment().toISOString()
|
||||||
dateCreated: moment().toISOString()
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(userId, trx);
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"Error calculating user clients after creating olm:",
|
||||||
|
e
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response<CreateOlmResponse>(res, {
|
return response<CreateOlmResponse>(res, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { Client, db } from "@server/db";
|
import { Client, db, Olm, primaryDb } from "@server/db";
|
||||||
import { olms, clients, clientSitesAssociationsCache } from "@server/db";
|
import { olms, clients, clientSitesAssociationsCache } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -49,6 +49,7 @@ export async function deleteUserOlm(
|
|||||||
|
|
||||||
const { olmId } = parsedParams.data;
|
const { olmId } = parsedParams.data;
|
||||||
|
|
||||||
|
let deletedClient: Client | undefined;
|
||||||
// Delete associated clients and the OLM in a transaction
|
// Delete associated clients and the OLM in a transaction
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Find all clients associated with this OLM
|
// Find all clients associated with this OLM
|
||||||
@@ -57,7 +58,6 @@ export async function deleteUserOlm(
|
|||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.olmId, olmId));
|
.where(eq(clients.olmId, olmId));
|
||||||
|
|
||||||
let deletedClient: Client | null = null;
|
|
||||||
// Delete all associated clients
|
// Delete all associated clients
|
||||||
if (associatedClients.length > 0) {
|
if (associatedClients.length > 0) {
|
||||||
[deletedClient] = await trx
|
[deletedClient] = await trx
|
||||||
@@ -67,23 +67,28 @@ export async function deleteUserOlm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finally, delete the OLM itself
|
// Finally, delete the OLM itself
|
||||||
const [olm] = await trx
|
await trx.delete(olms).where(eq(olms.olmId, olmId)).returning();
|
||||||
.delete(olms)
|
|
||||||
.where(eq(olms.olmId, olmId))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (deletedClient) {
|
|
||||||
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
|
||||||
if (olm) {
|
|
||||||
await sendTerminateClient(
|
|
||||||
deletedClient.clientId,
|
|
||||||
OlmErrorCodes.TERMINATED_DELETED,
|
|
||||||
olm.olmId
|
|
||||||
); // the olmId needs to be provided because it cant look it up after deletion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (deletedClient) {
|
||||||
|
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
sendTerminateClient(
|
||||||
|
deletedClient.clientId,
|
||||||
|
OlmErrorCodes.TERMINATED_DELETED,
|
||||||
|
olmId
|
||||||
|
).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting OLM ${olmId}: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ import { canCompress } from "@server/lib/clientVersionChecks";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
logger.info("Handling register olm message!");
|
logger.info("[handleOlmRegisterMessage] Handling register olm message");
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
if (!olm) {
|
if (!olm) {
|
||||||
logger.warn("Olm not found");
|
logger.warn("[handleOlmRegisterMessage] Olm not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,16 +46,19 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
} = message.data;
|
} = message.data;
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm client ID not found");
|
logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Handling fingerprint insertion for olm register...", {
|
logger.debug(
|
||||||
olmId: olm.olmId,
|
"[handleOlmRegisterMessage] Handling fingerprint insertion for olm register...",
|
||||||
fingerprint,
|
{
|
||||||
postures
|
olmId: olm.olmId,
|
||||||
});
|
fingerprint,
|
||||||
|
postures
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
|
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
|
||||||
|
|
||||||
@@ -85,14 +88,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
logger.warn("Client ID not found");
|
logger.warn("[handleOlmRegisterMessage] Client not found", {
|
||||||
|
clientId: olm.clientId
|
||||||
|
});
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.blocked) {
|
if (client.blocked) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Client ${client.clientId} is blocked. Ignoring register.`
|
`[handleOlmRegisterMessage] Client ${client.clientId} is blocked. Ignoring register.`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -100,7 +106,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
if (client.approvalState == "pending") {
|
if (client.approvalState == "pending") {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Client ${client.clientId} approval is pending. Ignoring register.`
|
`[handleOlmRegisterMessage] Client ${client.clientId} approval is pending. Ignoring register.`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
|
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -128,14 +135,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
logger.warn("Org not found");
|
logger.warn("[handleOlmRegisterMessage] Org not found", {
|
||||||
|
orgId: client.orgId
|
||||||
|
});
|
||||||
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
if (!olm.userId) {
|
if (!olm.userId) {
|
||||||
logger.warn("Olm has no user ID");
|
logger.warn("[handleOlmRegisterMessage] Olm has no user ID", {
|
||||||
|
orgId: client.orgId
|
||||||
|
});
|
||||||
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
|
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,12 +154,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
const { session: userSession, user } =
|
const { session: userSession, user } =
|
||||||
await validateSessionToken(userToken);
|
await validateSessionToken(userToken);
|
||||||
if (!userSession || !user) {
|
if (!userSession || !user) {
|
||||||
logger.warn("Invalid user session for olm register");
|
logger.warn(
|
||||||
|
"[handleOlmRegisterMessage] Invalid user session for olm register",
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
|
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.userId !== olm.userId) {
|
if (user.userId !== olm.userId) {
|
||||||
logger.warn("User ID mismatch for olm register");
|
logger.warn(
|
||||||
|
"[handleOlmRegisterMessage] User ID mismatch for olm register",
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
|
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -163,11 +180,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
sessionId // this is the user token passed in the message
|
sessionId // this is the user token passed in the message
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug("Policy check result:", policyCheck);
|
logger.debug("[handleOlmRegisterMessage] Policy check result", {
|
||||||
|
orgId: client.orgId,
|
||||||
|
policyCheck
|
||||||
|
});
|
||||||
|
|
||||||
if (policyCheck?.error) {
|
if (policyCheck?.error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`
|
`[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -175,7 +196,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(
|
sendOlmError(
|
||||||
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
|
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
|
||||||
@@ -186,7 +208,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
policyCheck.policies?.maxSessionLength?.compliant === false
|
policyCheck.policies?.maxSessionLength?.compliant === false
|
||||||
) {
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant session length for org ${orgId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(
|
sendOlmError(
|
||||||
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
|
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
|
||||||
@@ -195,7 +218,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
} else if (policyCheck.policies?.requiredTwoFactor === false) {
|
} else if (policyCheck.policies?.requiredTwoFactor === false) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(
|
sendOlmError(
|
||||||
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
|
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
|
||||||
@@ -204,7 +228,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
} else if (!policyCheck.allowed) {
|
} else if (!policyCheck.allowed) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`
|
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
||||||
return;
|
return;
|
||||||
@@ -226,29 +251,39 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
||||||
|
|
||||||
// Prepare an array to store site configurations
|
// Prepare an array to store site configurations
|
||||||
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
logger.debug(
|
||||||
|
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
|
|
||||||
let jitMode = false;
|
let jitMode = false;
|
||||||
if (sitesCount > 250 && build == "saas") {
|
if (sitesCount > 250 && build == "saas") {
|
||||||
// THIS IS THE MAX ON THE BUSINESS TIER
|
// THIS IS THE MAX ON THE BUSINESS TIER
|
||||||
// we have too many sites
|
// we have too many sites
|
||||||
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
||||||
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount);
|
logger.info(
|
||||||
|
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
|
);
|
||||||
jitMode = true;
|
jitMode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
`[handleOlmRegisterMessage] Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
logger.warn("Public key not provided");
|
logger.warn("[handleOlmRegisterMessage] Public key not provided", {
|
||||||
|
orgId: client.orgId
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.pubKey !== publicKey || client.archived) {
|
if (client.pubKey !== publicKey || client.archived) {
|
||||||
logger.info(
|
logger.info(
|
||||||
"Public key mismatch. Updating public key and clearing session info..."
|
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
// Update the client's public key
|
// Update the client's public key
|
||||||
await db
|
await db
|
||||||
@@ -274,12 +309,13 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||||
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
`[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`,
|
||||||
|
{ orgId: client.orgId }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||||
client,
|
client,
|
||||||
publicKey,
|
publicKey,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
siteResources,
|
siteResources,
|
||||||
apiKeyOrg
|
apiKeyOrg,
|
||||||
|
primaryDb
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -220,8 +221,12 @@ export async function batchAddClientToSiteResources(
|
|||||||
siteResourceId: siteResource.siteResourceId
|
siteResourceId: siteResource.siteResourceId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(client, trx);
|
rebuildClientAssociationsFromClient(client, primaryDb).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations after batch adding site resources for client ${clientId}: ${e}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
SiteResource,
|
SiteResource,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
userSiteResources
|
userSiteResources,
|
||||||
|
primaryDb
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||||
import {
|
import {
|
||||||
@@ -519,12 +520,10 @@ export async function createSiteResource(
|
|||||||
// own transaction so it always executes on the primary — avoiding any
|
// own transaction so it always executes on the primary — avoiding any
|
||||||
// replica-lag issues while still allowing the HTTP response to return
|
// replica-lag issues while still allowing the HTTP response to return
|
||||||
// early.
|
// early.
|
||||||
db.transaction(async (trx) => {
|
rebuildClientAssociationsFromSiteResource(
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
newSiteResource!,
|
||||||
newSiteResource!,
|
primaryDb
|
||||||
trx
|
).catch((err) => {
|
||||||
);
|
|
||||||
}).catch((err) => {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
|
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
|
||||||
err
|
err
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, newts, sites } from "@server/db";
|
import { db, newts, primaryDb, sites } from "@server/db";
|
||||||
import { siteResources } from "@server/db";
|
import { siteResources } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -73,12 +73,10 @@ export async function deleteSiteResource(
|
|||||||
// own transaction so it always executes on the primary — avoiding any
|
// own transaction so it always executes on the primary — avoiding any
|
||||||
// replica-lag issues while still allowing the HTTP response to return
|
// replica-lag issues while still allowing the HTTP response to return
|
||||||
// early.
|
// early.
|
||||||
db.transaction(async (trx) => {
|
rebuildClientAssociationsFromSiteResource(
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
removedSiteResource,
|
||||||
removedSiteResource,
|
primaryDb
|
||||||
trx
|
).catch((err) => {
|
||||||
);
|
|
||||||
}).catch((err) => {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
|
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
|
||||||
err
|
err
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, orgs } from "@server/db";
|
import { db, orgs, primaryDb } from "@server/db";
|
||||||
import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
|
import {
|
||||||
|
roles,
|
||||||
|
userInviteRoles,
|
||||||
|
userInvites,
|
||||||
|
userOrgs,
|
||||||
|
users
|
||||||
|
} from "@server/db";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -146,9 +152,7 @@ export async function acceptInvite(
|
|||||||
.from(userInviteRoles)
|
.from(userInviteRoles)
|
||||||
.where(eq(userInviteRoles.inviteId, inviteId));
|
.where(eq(userInviteRoles.inviteId, inviteId));
|
||||||
|
|
||||||
const inviteRoleIds = [
|
const inviteRoleIds = [...new Set(inviteRoleRows.map((r) => r.roleId))];
|
||||||
...new Set(inviteRoleRows.map((r) => r.roleId))
|
|
||||||
];
|
|
||||||
if (inviteRoleIds.length === 0) {
|
if (inviteRoleIds.length === 0) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -193,13 +197,19 @@ export async function acceptInvite(
|
|||||||
.delete(userInvites)
|
.delete(userInvites)
|
||||||
.where(eq(userInvites.inviteId, inviteId));
|
.where(eq(userInvites.inviteId, inviteId));
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}`
|
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
calculateUserClientsForOrgs(existingUser[0].userId, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return response<AcceptInviteResponse>(res, {
|
return response<AcceptInviteResponse>(res, {
|
||||||
data: { accepted: true, orgId: existingInvite.orgId },
|
data: { accepted: true, orgId: existingInvite.orgId },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { clients, db } from "@server/db";
|
import { clients, db, primaryDb, Client } from "@server/db";
|
||||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -88,11 +88,11 @@ export async function addUserRoleLegacy(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingUser.isOwner) {
|
if (existingUser.isOwner && role.isAdmin !== true) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"Cannot change the role of the owner of the organization"
|
"The organization owner must retain an administrator role"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,6 +112,8 @@ export async function addUserRoleLegacy(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orgClientsToRebuild: Client[] = [];
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(userOrgRoles)
|
.delete(userOrgRoles)
|
||||||
@@ -138,11 +140,19 @@ export async function addUserRoleLegacy(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const orgClient of orgClients) {
|
orgClientsToRebuild = orgClients;
|
||||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { ...existingUser, roleId },
|
data: { ...existingUser, roleId },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, primaryDb } from "@server/db";
|
||||||
import { users } from "@server/db";
|
import { users } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -53,8 +53,12 @@ export async function adminRemoveUser(
|
|||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx.delete(users).where(eq(users.userId, userId));
|
await trx.delete(users).where(eq(users.userId, userId));
|
||||||
|
});
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(userId, trx);
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to calculate user clients after removing user ${userId}: ${e}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { db, orgs } from "@server/db";
|
import { db, orgs, primaryDb } from "@server/db";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
|
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
@@ -34,8 +34,7 @@ const bodySchema = z
|
|||||||
roleId: z.number().int().positive().optional()
|
roleId: z.number().int().positive().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(d) =>
|
(d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||||
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
|
||||||
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
||||||
)
|
)
|
||||||
.transform((data) => ({
|
.transform((data) => ({
|
||||||
@@ -100,8 +99,14 @@ export async function createOrgUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { username, email, name, type, idpId, roleIds: uniqueRoleIds } =
|
const {
|
||||||
parsedBody.data;
|
username,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
idpId,
|
||||||
|
roleIds: uniqueRoleIds
|
||||||
|
} = parsedBody.data;
|
||||||
|
|
||||||
if (build == "saas") {
|
if (build == "saas") {
|
||||||
const usage = await usageService.getUsage(orgId, FeatureId.USERS);
|
const usage = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||||
@@ -232,6 +237,7 @@ export async function createOrgUser(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let userIdForClients: string | undefined;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const [existingUser] = await trx
|
const [existingUser] = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -270,7 +276,7 @@ export async function createOrgUser(
|
|||||||
{
|
{
|
||||||
orgId,
|
orgId,
|
||||||
userId: existingUser.userId,
|
userId: existingUser.userId,
|
||||||
autoProvisioned: false,
|
autoProvisioned: false
|
||||||
},
|
},
|
||||||
uniqueRoleIds,
|
uniqueRoleIds,
|
||||||
trx
|
trx
|
||||||
@@ -292,20 +298,30 @@ export async function createOrgUser(
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await assignUserToOrg(
|
await assignUserToOrg(
|
||||||
org,
|
org,
|
||||||
{
|
{
|
||||||
orgId,
|
orgId,
|
||||||
userId: newUser.userId,
|
userId: newUser.userId,
|
||||||
autoProvisioned: false,
|
autoProvisioned: false
|
||||||
},
|
},
|
||||||
uniqueRoleIds,
|
uniqueRoleIds,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(userId, trx);
|
userIdForClients = userId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (userIdForClients) {
|
||||||
|
calculateUserClientsForOrgs(userIdForClients, primaryDb).catch(
|
||||||
|
(e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to calculate user clients after creating org user: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||||
|
|||||||
@@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) {
|
|||||||
.from(userOrgRoles)
|
.from(userOrgRoles)
|
||||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
|
||||||
eq(userOrgRoles.userId, userId),
|
|
||||||
eq(userOrgRoles.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAdmin = roleRows.some((r) => r.isAdmin);
|
const isAdmin = roleRows.some((r) => r.isAdmin);
|
||||||
@@ -61,7 +58,8 @@ export async function queryUser(orgId: string, userId: string) {
|
|||||||
roleIds: roleRows.map((r) => r.roleId),
|
roleIds: roleRows.map((r) => r.roleId),
|
||||||
roles: roleRows.map((r) => ({
|
roles: roleRows.map((r) => ({
|
||||||
roleId: r.roleId,
|
roleId: r.roleId,
|
||||||
name: r.roleName ?? ""
|
name: r.roleName ?? "",
|
||||||
|
isAdmin: r.isAdmin === true
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
UserOrg,
|
UserOrg,
|
||||||
userSiteResources
|
userSiteResources,
|
||||||
|
primaryDb
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { userOrgs, userResources, users, userSites } from "@server/db";
|
import { userOrgs, userResources, users, userSites } from "@server/db";
|
||||||
import { and, count, eq, exists, inArray } from "drizzle-orm";
|
import { and, count, eq, exists, inArray } from "drizzle-orm";
|
||||||
@@ -91,25 +92,12 @@ export async function removeUserOrg(
|
|||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await removeUserFromOrg(org, userId, trx);
|
await removeUserFromOrg(org, userId, trx);
|
||||||
|
});
|
||||||
|
|
||||||
// if (build === "saas") {
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
// const [rootUser] = await trx
|
logger.error(
|
||||||
// .select()
|
`Failed to calculate user clients after removing user ${userId} from org ${orgId}: ${e}`
|
||||||
// .from(users)
|
);
|
||||||
// .where(eq(users.userId, userId));
|
|
||||||
//
|
|
||||||
// const [leftInOrgs] = await trx
|
|
||||||
// .select({ count: count() })
|
|
||||||
// .from(userOrgs)
|
|
||||||
// .where(eq(userOrgs.userId, userId));
|
|
||||||
//
|
|
||||||
// // if the user is not an internal user and does not belong to any org, delete the entire user
|
|
||||||
// if (rootUser?.type !== UserType.Internal && !leftInOrgs.count) {
|
|
||||||
// await trx.delete(users).where(eq(users.userId, userId));
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
await calculateUserClientsForOrgs(userId, trx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default async function migration() {
|
|||||||
await db.execute(sql`BEGIN`);
|
await db.execute(sql`BEGIN`);
|
||||||
|
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
CREATE TABLE "trialNotifications" (
|
CREATE TABLE IF NOT EXISTS "trialNotifications" (
|
||||||
"notificationId" serial PRIMARY KEY NOT NULL,
|
"notificationId" serial PRIMARY KEY NOT NULL,
|
||||||
"subscriptionId" varchar(255) NOT NULL,
|
"subscriptionId" varchar(255) NOT NULL,
|
||||||
"notificationType" varchar(50) NOT NULL,
|
"notificationType" varchar(50) NOT NULL,
|
||||||
@@ -52,10 +52,6 @@ export default async function migration() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "trialNotifications" ADD CONSTRAINT "trialNotifications_subscriptionId_subscriptions_subscriptionId_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."subscriptions"("subscriptionId") ON DELETE cascade ON UPDATE no action;
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.execute(sql`COMMIT`);
|
await db.execute(sql`COMMIT`);
|
||||||
console.log("Migrated database");
|
console.log("Migrated database");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function migration() {
|
|||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
CREATE TABLE 'trialNotifications' (
|
CREATE TABLE IF NOT EXISTS 'trialNotifications' (
|
||||||
'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
'subscriptionId' text NOT NULL,
|
'subscriptionId' text NOT NULL,
|
||||||
'notificationType' text NOT NULL,
|
'notificationType' text NOT NULL,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -32,7 +34,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useActionState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -42,13 +44,15 @@ const accessControlsFormSchema = z.object({
|
|||||||
roles: z.array(
|
roles: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
text: z.string()
|
text: z.string(),
|
||||||
|
isAdmin: z.boolean().optional()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function AccessControlsPage() {
|
export default function AccessControlsPage() {
|
||||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||||
|
const { user: sessionUser } = useUserContext();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
@@ -72,7 +76,8 @@ export default function AccessControlsPage() {
|
|||||||
autoProvisioned: user.autoProvisioned || false,
|
autoProvisioned: user.autoProvisioned || false,
|
||||||
roles: (user.roles ?? []).map((r) => ({
|
roles: (user.roles ?? []).map((r) => ({
|
||||||
id: r.roleId.toString(),
|
id: r.roleId.toString(),
|
||||||
text: r.name
|
text: r.name,
|
||||||
|
isAdmin: r.isAdmin === true
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -84,7 +89,8 @@ export default function AccessControlsPage() {
|
|||||||
"roles",
|
"roles",
|
||||||
(user.roles ?? []).map((r) => ({
|
(user.roles ?? []).map((r) => ({
|
||||||
id: r.roleId.toString(),
|
id: r.roleId.toString(),
|
||||||
text: r.name
|
text: r.name,
|
||||||
|
isAdmin: r.isAdmin === true
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||||
@@ -95,11 +101,11 @@ export default function AccessControlsPage() {
|
|||||||
? t("singleRolePerUserPlanNotice")
|
? t("singleRolePerUserPlanNotice")
|
||||||
: t("singleRolePerUserEditionNotice");
|
: t("singleRolePerUserEditionNotice");
|
||||||
|
|
||||||
const [, action, isSubmitting] = useActionState(onSubmit, null);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
async function onSubmit() {
|
const [confirmRemoveOwnAdminOpen, setConfirmRemoveOwnAdminOpen] =
|
||||||
const isValid = await form.trigger();
|
useState(false);
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
|
async function executeSave() {
|
||||||
const values = form.getValues();
|
const values = form.getValues();
|
||||||
|
|
||||||
if (values.roles.length === 0) {
|
if (values.roles.length === 0) {
|
||||||
@@ -111,6 +117,7 @@ export default function AccessControlsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
const updateRoleRequest = supportsMultipleRolesPerUser
|
const updateRoleRequest = supportsMultipleRolesPerUser
|
||||||
@@ -130,7 +137,8 @@ export default function AccessControlsPage() {
|
|||||||
roleIds,
|
roleIds,
|
||||||
roles: values.roles.map((r) => ({
|
roles: values.roles.map((r) => ({
|
||||||
roleId: parseInt(r.id, 10),
|
roleId: parseInt(r.id, 10),
|
||||||
name: r.text
|
name: r.text,
|
||||||
|
isAdmin: r.isAdmin === true
|
||||||
})),
|
})),
|
||||||
autoProvisioned: values.autoProvisioned
|
autoProvisioned: values.autoProvisioned
|
||||||
});
|
});
|
||||||
@@ -149,11 +157,61 @@ export default function AccessControlsPage() {
|
|||||||
t("accessRoleErrorAddDescription")
|
t("accessRoleErrorAddDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAccessControlsSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
|
||||||
|
if (values.roles.length === 0) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("accessRoleErrorAdd"),
|
||||||
|
description: t("accessRoleSelectPlease")
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const willHaveAdminRole = values.roles.some(
|
||||||
|
(r) => r.isAdmin === true
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRemovingOwnAdmin =
|
||||||
|
sessionUser.userId === user.userId &&
|
||||||
|
user.isAdmin &&
|
||||||
|
!willHaveAdminRole;
|
||||||
|
|
||||||
|
if (isRemovingOwnAdmin) {
|
||||||
|
setConfirmRemoveOwnAdminOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeSave();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={confirmRemoveOwnAdminOpen}
|
||||||
|
setOpen={setConfirmRemoveOwnAdminOpen}
|
||||||
|
title={t("removeOwnAdminRoleConfirmTitle")}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>{t("removeOwnAdminRoleConfirmDescription")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText={t("removeOwnAdminRoleConfirmButton")}
|
||||||
|
string={t("removeOwnAdminRoleConfirmPhrase")}
|
||||||
|
onConfirm={executeSave}
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -168,7 +226,7 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
action={action}
|
onSubmit={(e) => void handleAccessControlsSubmit(e)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="access-controls-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
@@ -237,8 +295,8 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={isSubmitting}
|
loading={isSaving}
|
||||||
disabled={isSubmitting}
|
disabled={isSaving}
|
||||||
form="access-controls-form"
|
form="access-controls-form"
|
||||||
>
|
>
|
||||||
{t("accessControlsSubmit")}
|
{t("accessControlsSubmit")}
|
||||||
|
|||||||
@@ -645,6 +645,12 @@ export default function ConnectionLogsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>*/}
|
</div>*/}
|
||||||
|
<div>
|
||||||
|
<strong>Client Endpoint:</strong>{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{row.clientEndpoint ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Site:</strong> {row.siteName ?? "-"}
|
<strong>Site:</strong> {row.siteName ?? "-"}
|
||||||
{row.siteNiceId && (
|
{row.siteNiceId && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
@@ -22,7 +22,18 @@ import {
|
|||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { Globe, MoreHorizontal, Plus } from "lucide-react";
|
import {
|
||||||
|
Globe,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -38,7 +49,10 @@ import {
|
|||||||
HttpDestinationCredenza,
|
HttpDestinationCredenza,
|
||||||
parseHttpConfig
|
parseHttpConfig
|
||||||
} from "@app/components/HttpDestinationCredenza";
|
} from "@app/components/HttpDestinationCredenza";
|
||||||
import { S3DestinationCredenza } from "@app/components/S3DestinationCredenza";
|
import {
|
||||||
|
S3DestinationCredenza,
|
||||||
|
parseS3Config
|
||||||
|
} from "@app/components/S3DestinationCredenza";
|
||||||
import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza";
|
import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -64,6 +78,42 @@ interface DestinationCardProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDestinationDisplay(destination: Destination): {
|
||||||
|
name: string;
|
||||||
|
typeLabel: string;
|
||||||
|
detail: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
} {
|
||||||
|
if (destination.type === "s3") {
|
||||||
|
const cfg = parseS3Config(destination.config);
|
||||||
|
const detail = cfg.bucket
|
||||||
|
? `s3://${cfg.bucket}${cfg.prefix ? `/${cfg.prefix.replace(/^\/+/, "")}` : ""}`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
name: cfg.name,
|
||||||
|
typeLabel: "Amazon S3",
|
||||||
|
detail,
|
||||||
|
icon: (
|
||||||
|
<Image
|
||||||
|
src="/third-party/s3.png"
|
||||||
|
alt="Amazon S3"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="rounded-sm"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Default: HTTP
|
||||||
|
const cfg = parseHttpConfig(destination.config);
|
||||||
|
return {
|
||||||
|
name: cfg.name,
|
||||||
|
typeLabel: "HTTP",
|
||||||
|
detail: cfg.url,
|
||||||
|
icon: <Globe className="h-3.5 w-3.5 text-black" />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function DestinationCard({
|
function DestinationCard({
|
||||||
destination,
|
destination,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -73,25 +123,25 @@ function DestinationCard({
|
|||||||
disabled = false
|
disabled = false
|
||||||
}: DestinationCardProps) {
|
}: DestinationCardProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const cfg = parseHttpConfig(destination.config);
|
const { name, typeLabel, detail, icon } =
|
||||||
|
getDestinationDisplay(destination);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
|
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
|
||||||
{/* Top row: icon + name/type + toggle */}
|
{/* Top row: icon + name/type + toggle */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{/* Squirkle icon: gray outer → white inner → black globe */}
|
|
||||||
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
|
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
|
||||||
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
|
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
|
||||||
<Globe className="h-3.5 w-3.5 text-black" />
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-semibold text-sm leading-tight truncate">
|
<p className="font-semibold text-sm leading-tight truncate">
|
||||||
{cfg.name || t("streamingUnnamedDestination")}
|
{name || t("streamingUnnamedDestination")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
HTTP
|
{typeLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,15 +155,40 @@ function DestinationCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* URL preview */}
|
{/* Detail preview (URL for HTTP, s3:// path for S3) */}
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{cfg.url || (
|
{detail || (
|
||||||
<span className="italic">
|
<span className="italic">
|
||||||
{t("streamingNoUrlConfigured")}
|
{t("streamingNoUrlConfigured")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Error indicator */}
|
||||||
|
{destination.lastError && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1.5 text-left cursor-pointer rounded px-0 hover:opacity-75 transition-opacity"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0" />
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("streamingLastSyncError")}
|
||||||
|
</p>
|
||||||
|
<ChevronDown className="h-3 w-3 text-destructive shrink-0 ml-auto" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="bottom"
|
||||||
|
align="end"
|
||||||
|
className="w-80 text-xs break-words"
|
||||||
|
>
|
||||||
|
{destination.lastError}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer: edit button + three-dots menu */}
|
{/* Footer: edit button + three-dots menu */}
|
||||||
<div className="mt-auto pt-5 flex gap-2">
|
<div className="mt-auto pt-5 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -485,7 +560,7 @@ export default function StreamingDestinationsPage() {
|
|||||||
if (!v) setDeleteTarget(null);
|
if (!v) setDeleteTarget(null);
|
||||||
}}
|
}}
|
||||||
string={
|
string={
|
||||||
parseHttpConfig(deleteTarget.config).name ||
|
getDestinationDisplay(deleteTarget).name ||
|
||||||
t("streamingDeleteDialogThisDestination")
|
t("streamingDeleteDialogThisDestination")
|
||||||
}
|
}
|
||||||
title={t("streamingDeleteTitle")}
|
title={t("streamingDeleteTitle")}
|
||||||
@@ -493,7 +568,7 @@ export default function StreamingDestinationsPage() {
|
|||||||
<p>
|
<p>
|
||||||
{t("streamingDeleteDialogAreYouSure")}{" "}
|
{t("streamingDeleteDialogAreYouSure")}{" "}
|
||||||
<span>
|
<span>
|
||||||
{parseHttpConfig(deleteTarget.config).name ||
|
{getDestinationDisplay(deleteTarget).name ||
|
||||||
t("streamingDeleteDialogThisDestination")}
|
t("streamingDeleteDialogThisDestination")}
|
||||||
</span>
|
</span>
|
||||||
{t("streamingDeleteDialogPermanentlyRemoved")}
|
{t("streamingDeleteDialogPermanentlyRemoved")}
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export default async function ProxyResourcesPage(
|
|||||||
pagination = responseData.pagination;
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
|
const siteIdParam = parsePositiveInt(
|
||||||
|
searchParams.get("siteId") ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
let initialFilterSite: {
|
let initialFilterSite: {
|
||||||
siteId: number;
|
siteId: number;
|
||||||
@@ -122,6 +124,7 @@ export default async function ProxyResourcesPage(
|
|||||||
domainId: resource.domainId || undefined,
|
domainId: resource.domainId || undefined,
|
||||||
fullDomain: resource.fullDomain ?? null,
|
fullDomain: resource.fullDomain ?? null,
|
||||||
ssl: resource.ssl,
|
ssl: resource.ssl,
|
||||||
|
wildcard: resource.wildcard,
|
||||||
targets: resource.targets?.map((target) => ({
|
targets: resource.targets?.map((target) => ({
|
||||||
targetId: target.targetId,
|
targetId: target.targetId,
|
||||||
ip: target.ip,
|
ip: target.ip,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
|||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
import { Textarea } from "@app/components/ui/textarea";
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import { Plus, X } from "lucide-react";
|
import { Plus, X, AlertCircle } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
@@ -56,6 +57,8 @@ export interface Destination {
|
|||||||
sendActionLogs: boolean;
|
sendActionLogs: boolean;
|
||||||
sendConnectionLogs: boolean;
|
sendConnectionLogs: boolean;
|
||||||
sendRequestLogs: boolean;
|
sendRequestLogs: boolean;
|
||||||
|
lastError: string | null;
|
||||||
|
lastErrorAt: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
@@ -122,9 +125,7 @@ function HeadersEditor({ headers, onChange }: HeadersEditorProps) {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={h.value}
|
value={h.value}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateRow(i, "value", e.target.value)}
|
||||||
updateRow(i, "value", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={t("httpDestHeaderValuePlaceholder")}
|
placeholder={t("httpDestHeaderValuePlaceholder")}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -200,10 +201,7 @@ export function HttpDestinationCredenza({
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(raw);
|
const parsed = new URL(raw);
|
||||||
if (
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
parsed.protocol !== "http:" &&
|
|
||||||
parsed.protocol !== "https:"
|
|
||||||
) {
|
|
||||||
return t("httpDestUrlErrorHttpRequired");
|
return t("httpDestUrlErrorHttpRequired");
|
||||||
}
|
}
|
||||||
if (build === "saas" && parsed.protocol !== "https:") {
|
if (build === "saas" && parsed.protocol !== "https:") {
|
||||||
@@ -216,9 +214,7 @@ export function HttpDestinationCredenza({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const isValid =
|
const isValid =
|
||||||
cfg.name.trim() !== "" &&
|
cfg.name.trim() !== "" && cfg.url.trim() !== "" && urlError === null;
|
||||||
cfg.url.trim() !== "" &&
|
|
||||||
urlError === null;
|
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
@@ -253,10 +249,7 @@ export function HttpDestinationCredenza({
|
|||||||
title: editing
|
title: editing
|
||||||
? t("httpDestUpdateFailed")
|
? t("httpDestUpdateFailed")
|
||||||
: t("httpDestCreateFailed"),
|
: t("httpDestCreateFailed"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||||
e,
|
|
||||||
t("streamingUnexpectedError")
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -280,6 +273,14 @@ export function HttpDestinationCredenza({
|
|||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
|
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
|
{editing?.lastError && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="break-words">
|
||||||
|
{editing.lastError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<HorizontalTabs
|
<HorizontalTabs
|
||||||
clientSide
|
clientSide
|
||||||
items={[
|
items={[
|
||||||
@@ -357,7 +358,9 @@ export function HttpDestinationCredenza({
|
|||||||
{t("httpDestAuthNoneTitle")}
|
{t("httpDestAuthNoneTitle")}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestAuthNoneDescription")}
|
{t(
|
||||||
|
"httpDestAuthNoneDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,15 +378,21 @@ export function HttpDestinationCredenza({
|
|||||||
htmlFor="auth-bearer"
|
htmlFor="auth-bearer"
|
||||||
className="cursor-pointer font-medium"
|
className="cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{t("httpDestAuthBearerTitle")}
|
{t(
|
||||||
|
"httpDestAuthBearerTitle"
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestAuthBearerDescription")}
|
{t(
|
||||||
|
"httpDestAuthBearerDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{cfg.authType === "bearer" && (
|
{cfg.authType === "bearer" && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("httpDestAuthBearerPlaceholder")}
|
placeholder={t(
|
||||||
|
"httpDestAuthBearerPlaceholder"
|
||||||
|
)}
|
||||||
value={
|
value={
|
||||||
cfg.bearerToken ?? ""
|
cfg.bearerToken ?? ""
|
||||||
}
|
}
|
||||||
@@ -411,15 +420,21 @@ export function HttpDestinationCredenza({
|
|||||||
htmlFor="auth-basic"
|
htmlFor="auth-basic"
|
||||||
className="cursor-pointer font-medium"
|
className="cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{t("httpDestAuthBasicTitle")}
|
{t(
|
||||||
|
"httpDestAuthBasicTitle"
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestAuthBasicDescription")}
|
{t(
|
||||||
|
"httpDestAuthBasicDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{cfg.authType === "basic" && (
|
{cfg.authType === "basic" && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("httpDestAuthBasicPlaceholder")}
|
placeholder={t(
|
||||||
|
"httpDestAuthBasicPlaceholder"
|
||||||
|
)}
|
||||||
value={
|
value={
|
||||||
cfg.basicCredentials ??
|
cfg.basicCredentials ??
|
||||||
""
|
""
|
||||||
@@ -448,16 +463,22 @@ export function HttpDestinationCredenza({
|
|||||||
htmlFor="auth-custom"
|
htmlFor="auth-custom"
|
||||||
className="cursor-pointer font-medium"
|
className="cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{t("httpDestAuthCustomTitle")}
|
{t(
|
||||||
|
"httpDestAuthCustomTitle"
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestAuthCustomDescription")}
|
{t(
|
||||||
|
"httpDestAuthCustomDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{cfg.authType === "custom" && (
|
{cfg.authType === "custom" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("httpDestAuthCustomHeaderNamePlaceholder")}
|
placeholder={t(
|
||||||
|
"httpDestAuthCustomHeaderNamePlaceholder"
|
||||||
|
)}
|
||||||
value={
|
value={
|
||||||
cfg.customHeaderName ??
|
cfg.customHeaderName ??
|
||||||
""
|
""
|
||||||
@@ -472,7 +493,9 @@ export function HttpDestinationCredenza({
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("httpDestAuthCustomHeaderValuePlaceholder")}
|
placeholder={t(
|
||||||
|
"httpDestAuthCustomHeaderValuePlaceholder"
|
||||||
|
)}
|
||||||
value={
|
value={
|
||||||
cfg.customHeaderValue ??
|
cfg.customHeaderValue ??
|
||||||
""
|
""
|
||||||
@@ -593,10 +616,14 @@ export function HttpDestinationCredenza({
|
|||||||
htmlFor="fmt-json-array"
|
htmlFor="fmt-json-array"
|
||||||
className="cursor-pointer font-medium"
|
className="cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{t("httpDestFormatJsonArrayTitle")}
|
{t(
|
||||||
|
"httpDestFormatJsonArrayTitle"
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestFormatJsonArrayDescription")}
|
{t(
|
||||||
|
"httpDestFormatJsonArrayDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,7 +643,9 @@ export function HttpDestinationCredenza({
|
|||||||
{t("httpDestFormatNdjsonTitle")}
|
{t("httpDestFormatNdjsonTitle")}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestFormatNdjsonDescription")}
|
{t(
|
||||||
|
"httpDestFormatNdjsonDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -636,7 +665,9 @@ export function HttpDestinationCredenza({
|
|||||||
{t("httpDestFormatSingleTitle")}
|
{t("httpDestFormatSingleTitle")}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestFormatSingleDescription")}
|
{t(
|
||||||
|
"httpDestFormatSingleDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -717,7 +748,9 @@ export function HttpDestinationCredenza({
|
|||||||
{t("httpDestConnectionLogsTitle")}
|
{t("httpDestConnectionLogsTitle")}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestConnectionLogsDescription")}
|
{t(
|
||||||
|
"httpDestConnectionLogsDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -739,7 +772,9 @@ export function HttpDestinationCredenza({
|
|||||||
{t("httpDestRequestLogsTitle")}
|
{t("httpDestRequestLogsTitle")}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{t("httpDestRequestLogsDescription")}
|
{t(
|
||||||
|
"httpDestRequestLogsDescription"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -764,7 +799,9 @@ export function HttpDestinationCredenza({
|
|||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!isValid || saving}
|
disabled={!isValid || saving}
|
||||||
>
|
>
|
||||||
{editing ? t("httpDestSaveChanges") : t("httpDestCreateDestination")}
|
{editing
|
||||||
|
? t("httpDestSaveChanges")
|
||||||
|
: t("httpDestCreateDestination")}
|
||||||
</Button>
|
</Button>
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export type ResourceRow = {
|
|||||||
targets?: TargetHealth[];
|
targets?: TargetHealth[];
|
||||||
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
|
health?: "healthy" | "degraded" | "unhealthy" | "unknown";
|
||||||
sites: ResourceSiteRow[];
|
sites: ResourceSiteRow[];
|
||||||
|
wildcard?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusIcon({
|
function StatusIcon({
|
||||||
@@ -570,10 +571,14 @@ export default function ProxyResourcesTable({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="">
|
<div className="">
|
||||||
<CopyToClipboard
|
{!resourceRow.wildcard ? (
|
||||||
text={resourceRow.domain}
|
<CopyToClipboard
|
||||||
isLink={true}
|
text={resourceRow.domain}
|
||||||
/>
|
isLink={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{resourceRow.domain}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
@@ -12,13 +12,64 @@ import {
|
|||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { Label } from "@app/components/ui/label";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Destination } from "@app/components/HttpDestinationCredenza";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type S3PayloadFormat = "json_array" | "ndjson" | "csv";
|
||||||
|
|
||||||
|
export interface S3Config {
|
||||||
|
name: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
region: string;
|
||||||
|
bucket: string;
|
||||||
|
prefix: string;
|
||||||
|
endpoint: string;
|
||||||
|
format: S3PayloadFormat;
|
||||||
|
gzip: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const defaultS3Config = (): S3Config => ({
|
||||||
|
name: "",
|
||||||
|
accessKeyId: "",
|
||||||
|
secretAccessKey: "",
|
||||||
|
region: "us-east-1",
|
||||||
|
bucket: "",
|
||||||
|
prefix: "",
|
||||||
|
endpoint: "",
|
||||||
|
format: "json_array",
|
||||||
|
gzip: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export function parseS3Config(raw: string): S3Config {
|
||||||
|
try {
|
||||||
|
return { ...defaultS3Config(), ...JSON.parse(raw) };
|
||||||
|
} catch {
|
||||||
|
return defaultS3Config();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface S3DestinationCredenzaProps {
|
export interface S3DestinationCredenzaProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
editing: any;
|
editing: Destination | null;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,18 +79,84 @@ export function S3DestinationCredenza({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
editing,
|
editing,
|
||||||
orgId,
|
orgId,
|
||||||
onSaved,
|
onSaved
|
||||||
}: S3DestinationCredenzaProps) {
|
}: S3DestinationCredenzaProps) {
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [cfg, setCfg] = useState<S3Config>(defaultS3Config());
|
||||||
|
const [sendAccessLogs, setSendAccessLogs] = useState(false);
|
||||||
|
const [sendActionLogs, setSendActionLogs] = useState(false);
|
||||||
|
const [sendConnectionLogs, setSendConnectionLogs] = useState(false);
|
||||||
|
const [sendRequestLogs, setSendRequestLogs] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setCfg(editing ? parseS3Config(editing.config) : defaultS3Config());
|
||||||
|
setSendAccessLogs(editing?.sendAccessLogs ?? false);
|
||||||
|
setSendActionLogs(editing?.sendActionLogs ?? false);
|
||||||
|
setSendConnectionLogs(editing?.sendConnectionLogs ?? false);
|
||||||
|
setSendRequestLogs(editing?.sendRequestLogs ?? false);
|
||||||
|
}
|
||||||
|
}, [open, editing]);
|
||||||
|
|
||||||
|
const update = (patch: Partial<S3Config>) =>
|
||||||
|
setCfg((prev) => ({ ...prev, ...patch }));
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
cfg.name.trim() !== "" &&
|
||||||
|
cfg.accessKeyId.trim() !== "" &&
|
||||||
|
cfg.secretAccessKey.trim() !== "" &&
|
||||||
|
cfg.region.trim() !== "" &&
|
||||||
|
cfg.bucket.trim() !== "";
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!isValid) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
type: "s3",
|
||||||
|
config: JSON.stringify(cfg),
|
||||||
|
sendAccessLogs,
|
||||||
|
sendActionLogs,
|
||||||
|
sendConnectionLogs,
|
||||||
|
sendRequestLogs
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await api.post(
|
||||||
|
`/org/${orgId}/event-streaming-destination/${editing.destinationId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
toast({ title: t("s3DestUpdatedSuccess") });
|
||||||
|
} else {
|
||||||
|
await api.put(
|
||||||
|
`/org/${orgId}/event-streaming-destination`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
toast({ title: t("s3DestCreatedSuccess") });
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: editing
|
||||||
|
? t("s3DestUpdateFailed")
|
||||||
|
: t("s3DestCreateFailed"),
|
||||||
|
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
<CredenzaContent className="sm:max-w-2xl">
|
<CredenzaContent className="sm:max-w-2xl">
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>
|
<CredenzaTitle>
|
||||||
{editing
|
{editing ? t("S3DestEditTitle") : t("S3DestAddTitle")}
|
||||||
? t("S3DestEditTitle")
|
|
||||||
: t("S3DestAddTitle")}
|
|
||||||
</CredenzaTitle>
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{editing
|
{editing
|
||||||
@@ -49,13 +166,375 @@ export function S3DestinationCredenza({
|
|||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
|
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<ContactSalesBanner />
|
{editing?.lastError && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="break-words">
|
||||||
|
{editing.lastError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<HorizontalTabs
|
||||||
|
clientSide
|
||||||
|
items={[
|
||||||
|
{ title: t("s3DestTabSettings"), href: "" },
|
||||||
|
{ title: t("s3DestTabFormat"), href: "" },
|
||||||
|
{ title: t("httpDestTabLogs"), href: "" }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* ── Settings tab ────────────────────────────── */}
|
||||||
|
<div className="space-y-6 mt-4 p-1">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="s3-name">
|
||||||
|
{t("s3DestNameLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="s3-name"
|
||||||
|
placeholder={t("s3DestNamePlaceholder")}
|
||||||
|
value={cfg.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Access Key ID */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="s3-access-key-id">
|
||||||
|
{t("s3DestAccessKeyIdLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="s3-access-key-id"
|
||||||
|
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||||||
|
value={cfg.accessKeyId}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({
|
||||||
|
accessKeyId: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Secret Access Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="s3-secret-key">
|
||||||
|
{t("s3DestSecretAccessKeyLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="s3-secret-key"
|
||||||
|
type="password"
|
||||||
|
placeholder={t(
|
||||||
|
"s3DestSecretAccessKeyPlaceholder"
|
||||||
|
)}
|
||||||
|
value={cfg.secretAccessKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({
|
||||||
|
secretAccessKey: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Region */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="s3-region">
|
||||||
|
{t("s3DestRegionLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="s3-region"
|
||||||
|
placeholder="us-east-1"
|
||||||
|
value={cfg.region}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ region: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bucket */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="s3-bucket">
|
||||||
|
{t("s3DestBucketLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="s3-bucket"
|
||||||
|
placeholder="my-logs-bucket"
|
||||||
|
value={cfg.bucket}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ bucket: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prefix */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="s3-prefix">
|
||||||
|
{t("s3DestPrefixLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="s3-prefix"
|
||||||
|
placeholder="pangolin/logs"
|
||||||
|
value={cfg.prefix}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ prefix: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("s3DestPrefixDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom endpoint (optional – for S3-compatible storage) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="s3-endpoint">
|
||||||
|
{t("s3DestEndpointLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="s3-endpoint"
|
||||||
|
placeholder="https://s3.example.com"
|
||||||
|
value={cfg.endpoint}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ endpoint: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("s3DestEndpointDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Format tab ───────────────────────────────── */}
|
||||||
|
<div className="space-y-6 mt-4 p-1">
|
||||||
|
{/* Gzip compression toggle */}
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<Switch
|
||||||
|
id="s3-gzip"
|
||||||
|
checked={cfg.gzip}
|
||||||
|
onCheckedChange={(v) => update({ gzip: v })}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="s3-gzip"
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("s3DestGzipLabel")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("s3DestGzipDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payload format selector */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="font-medium block">
|
||||||
|
{t("s3DestFormatTitle")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{t("s3DestFormatDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={cfg.format}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
update({
|
||||||
|
format: v as S3PayloadFormat
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{/* JSON Array */}
|
||||||
|
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="json_array"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{t(
|
||||||
|
"httpDestFormatJsonArrayTitle"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t(
|
||||||
|
"s3DestFormatJsonArrayDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* NDJSON */}
|
||||||
|
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="ndjson"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{t("httpDestFormatNdjsonTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t(
|
||||||
|
"s3DestFormatNdjsonDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* CSV */}
|
||||||
|
<label className="flex items-start gap-3 rounded-md border p-3 cursor-pointer has-[:checked]:border-primary has-[:checked]:bg-primary/5">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="csv"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{t("s3DestFormatCsvTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t(
|
||||||
|
"s3DestFormatCsvDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Logs tab ──────────────────────────────────── */}
|
||||||
|
<div className="space-y-6 mt-4 p-1">
|
||||||
|
<div>
|
||||||
|
<label className="font-medium block">
|
||||||
|
{t("httpDestLogTypesTitle")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestLogTypesDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="s3-log-access"
|
||||||
|
checked={sendAccessLogs}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSendAccessLogs(v === true)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="s3-log-access"
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestAccessLogsTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestAccessLogsDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="s3-log-action"
|
||||||
|
checked={sendActionLogs}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSendActionLogs(v === true)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="s3-log-action"
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestActionLogsTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestActionLogsDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="s3-log-connection"
|
||||||
|
checked={sendConnectionLogs}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSendConnectionLogs(v === true)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="s3-log-connection"
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestConnectionLogsTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t(
|
||||||
|
"httpDestConnectionLogsDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="s3-log-request"
|
||||||
|
checked={sendRequestLogs}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSendRequestLogs(v === true)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="s3-log-request"
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestRequestLogsTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t(
|
||||||
|
"httpDestRequestLogsDescription"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HorizontalTabs>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button variant="outline">{t("cancel")}</Button>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!isValid || saving}
|
||||||
|
>
|
||||||
|
{editing
|
||||||
|
? t("s3DestSaveChanges")
|
||||||
|
: t("s3DestCreateDestination")}
|
||||||
|
</Button>
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ export default function UsersTable({
|
|||||||
];
|
];
|
||||||
}, [searchParams.toString()]);
|
}, [searchParams.toString()]);
|
||||||
|
|
||||||
|
const isRemovingSelf = useMemo(() => {
|
||||||
|
if (!selectedUser || !user) return false;
|
||||||
|
return (
|
||||||
|
`${selectedUser.username}-${selectedUser.idpId}` ===
|
||||||
|
`${user.username}-${user.idpId}`
|
||||||
|
);
|
||||||
|
}, [selectedUser, user]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
value: string | undefined | null
|
||||||
@@ -223,10 +231,7 @@ export default function UsersTable({
|
|||||||
header: () => <span className="p-3"></span>,
|
header: () => <span className="p-3"></span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const userRow = row.original;
|
const userRow = row.original;
|
||||||
const isCurrentUser =
|
const canRemoveFromOrg = !userRow.isOwner;
|
||||||
`${userRow.username}-${userRow.idpId}` ===
|
|
||||||
`${user?.username}-${user?.idpId}`;
|
|
||||||
const isDisabled = userRow.isOwner || isCurrentUser;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<div>
|
<div>
|
||||||
@@ -235,7 +240,6 @@ export default function UsersTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{t("openMenu")}
|
{t("openMenu")}
|
||||||
@@ -247,16 +251,12 @@ export default function UsersTable({
|
|||||||
<Link
|
<Link
|
||||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||||
className="block w-full"
|
className="block w-full"
|
||||||
aria-disabled={isDisabled}
|
|
||||||
onClick={(e) =>
|
|
||||||
isDisabled && e.preventDefault()
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<DropdownMenuItem disabled={isDisabled}>
|
<DropdownMenuItem>
|
||||||
{t("accessUserManage")}
|
{t("accessUserManage")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
{!isDisabled && (
|
{canRemoveFromOrg && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -271,25 +271,14 @@ export default function UsersTable({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
{isDisabled ? (
|
<Link
|
||||||
<Button
|
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||||
variant={"outline"}
|
>
|
||||||
className="ml-2"
|
<Button variant={"outline"} className="ml-2">
|
||||||
disabled
|
|
||||||
>
|
|
||||||
{t("manage")}
|
{t("manage")}
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
</Link>
|
||||||
<Link
|
|
||||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
|
||||||
>
|
|
||||||
<Button variant={"outline"} className="ml-2">
|
|
||||||
{t("manage")}
|
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -359,22 +348,45 @@ export default function UsersTable({
|
|||||||
}}
|
}}
|
||||||
dialog={
|
dialog={
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p>{t("userQuestionOrgRemove")}</p>
|
<p>
|
||||||
<p>{t("userMessageOrgRemove")}</p>
|
{t(
|
||||||
|
isRemovingSelf
|
||||||
|
? "userQuestionOrgRemoveSelf"
|
||||||
|
: "userQuestionOrgRemove"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
isRemovingSelf
|
||||||
|
? "userMessageOrgRemoveSelf"
|
||||||
|
: "userMessageOrgRemove"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText={t("userRemoveOrgConfirm")}
|
buttonText={t(
|
||||||
|
isRemovingSelf
|
||||||
|
? "userRemoveOrgConfirmSelf"
|
||||||
|
: "userRemoveOrgConfirm"
|
||||||
|
)}
|
||||||
|
warningText={
|
||||||
|
isRemovingSelf ? t("userRemoveOrgSelfWarning") : undefined
|
||||||
|
}
|
||||||
onConfirm={async () => startTransition(removeUser)}
|
onConfirm={async () => startTransition(removeUser)}
|
||||||
string={
|
string={
|
||||||
selectedUser
|
isRemovingSelf
|
||||||
? getUserDisplayName({
|
? t("userRemoveOrgConfirmPhraseSelf")
|
||||||
email: selectedUser.email,
|
: selectedUser
|
||||||
name: selectedUser.name,
|
? getUserDisplayName({
|
||||||
username: selectedUser.username
|
email: selectedUser.email,
|
||||||
})
|
name: selectedUser.name,
|
||||||
: ""
|
username: selectedUser.username
|
||||||
|
})
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
title={t("userRemoveOrg")}
|
title={t(
|
||||||
|
isRemovingSelf ? "userRemoveOrgSelf" : "userRemoveOrg"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ControlledDataTable
|
<ControlledDataTable
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import { CheckIcon } from "lucide-react";
|
import { CheckIcon } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export type TagValue = { text: string; id: string };
|
export type TagValue = { text: string; id: string; isAdmin?: boolean };
|
||||||
|
|
||||||
export type MultiSelectTagsProps<T extends TagValue> = {
|
export type MultiSelectTagsProps<T extends TagValue> = {
|
||||||
emptyPlaceholder?: string;
|
emptyPlaceholder?: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useDebounce } from "use-debounce";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||||
|
|
||||||
export type SelectedRole = { id: string; text: string };
|
export type SelectedRole = { id: string; text: string; isAdmin?: boolean };
|
||||||
|
|
||||||
export type RolesSelectorProps = {
|
export type RolesSelectorProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user