mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'v143/support' into release/v1.43.0
This commit is contained in:
commit
5dab12472f
177 files changed
+4435
-408
No files matched your search
@@ -15,7 +15,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: HealthLog version
|
||||
description: Output of `/api/version` or the tag of the GHCR image you're running.
|
||||
description: Settings, About shows it, as does `/api/version`, or the tag of the GHCR image you're running.
|
||||
placeholder: "1.4.19"
|
||||
validations:
|
||||
required: true
|
||||
@@ -55,11 +55,27 @@ body:
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: error-id
|
||||
attributes:
|
||||
label: Error ID
|
||||
description: If the failure showed an error ID (eight characters, e.g. `3f9a2c1b`), paste it here. The operator finds the event with it under Admin, App logs.
|
||||
placeholder: "3f9a2c1b"
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: Support info / support report
|
||||
description: |
|
||||
Everyone: Settings, About, "Copy support info".
|
||||
Operators: Admin, System status, "Support report", then Copy.
|
||||
Both are redacted (no names, emails, values or tokens); read them before you paste.
|
||||
See https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/support.md
|
||||
render: text
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs / screenshots
|
||||
description: Paste any relevant log output (redact secrets) or attach screenshots. Wide-event logs from `/admin/app-logs` are useful.
|
||||
label: Screenshots / other logs
|
||||
description: Attach screenshots, or paste other log output if you have it. Redact secrets and personal data first.
|
||||
render: shell
|
||||
- type: input
|
||||
id: browser
|
||||
|
||||
+4
-1
@@ -33,7 +33,10 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
three access levels, the eight sections a grant can be narrowed to,
|
||||
managed profiles for somebody with no login and the guardians who look
|
||||
after them, what revocation does and does not undo, and which trust
|
||||
properties of the instance are unchanged by any of it.
|
||||
properties of the instance are unchanged by any of it. And
|
||||
[`self-hosting/support.md`](./self-hosting/support.md) — reporting a
|
||||
problem: error ids, the support info every account can copy, the
|
||||
operator's redacted support report, and finding an error in the admin log.
|
||||
- [`using/`](./using/) — notes on using the app that sit close to the
|
||||
code. [`using/search.md`](./using/search.md) covers the command palette's
|
||||
record search: opening it, what it finds, how matching works, and its
|
||||
|
||||
@@ -218,6 +218,11 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Where to look.** For a bug report, copy the support report (Admin,
|
||||
System status) and the support info (Settings, About), and note the error
|
||||
id the failure showed. Admin, App logs finds the event behind an error id.
|
||||
What each contains, and what it never contains, is in
|
||||
[`support.md`](./support.md).
|
||||
- **`app` container exits with a Prisma migration error.** The
|
||||
Postgres healthcheck races the migration on slow disks. Run
|
||||
`docker compose restart app` once the database has stabilised —
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Reporting a problem
|
||||
|
||||
HealthLog does not send anything home, and nobody but the operator can read
|
||||
an instance's logs. So a bug report has to carry what is needed to find the
|
||||
fault. Two texts do that, and both are redacted before you see them.
|
||||
|
||||
| Who | Where | What it is |
|
||||
| -------- | ---------------------------------------- | ------------------------------------------ |
|
||||
| Everyone | Settings, About, **Copy support info** | Your app version, settings and connections |
|
||||
| Operator | Admin, System status, **Support report** | The whole instance, accounts as numbers |
|
||||
|
||||
Add the **error id** of the failure if you have one, and paste both texts into
|
||||
the [bug report](https://github.com/MBombeck/HealthLog/issues/new?template=bug_report.yml).
|
||||
Read them before you post. They are built to contain nothing personal, but
|
||||
you are the one publishing them.
|
||||
|
||||
## Error ids
|
||||
|
||||
When the server fails (a 5xx answer), the message shows **Error ID** and
|
||||
eight characters, for example `3f9a2c1b`, with a copy button that copies the
|
||||
full id. A page that fails to render shows one too when the server has one
|
||||
to give: the request id of the failed call, or the digest the server printed
|
||||
beside the stack in its log.
|
||||
|
||||
The id is the request id of the failing call. Every error answer of the API
|
||||
carries it in `meta.requestId` and in the `x-request-id` header. An incoming
|
||||
`x-request-id` is only adopted when it consists of letters, digits and hyphens
|
||||
(8 to 64 characters); anything else is replaced with a fresh id.
|
||||
|
||||
The operator finds the event under **Admin, App logs** by typing the id (or
|
||||
its first 8 characters) into **Error ID**. A search by id reads the second log
|
||||
buffer, which keeps the last 1,000 warnings and errors of the process. Ordinary
|
||||
traffic cannot push them out, so an error stays findable for much longer than
|
||||
in the main buffer of 500 events. Both buffers live in memory: a restart
|
||||
empties them, and a split `web` / `worker` deployment has one per process.
|
||||
|
||||
The same search works on the API:
|
||||
|
||||
```
|
||||
GET /api/admin/app-logs?requestId=3f9a2c1b&ring=problems
|
||||
```
|
||||
|
||||
A 4xx answer (a refused input, a missing permission) shows no id. Its message
|
||||
says what to change.
|
||||
|
||||
## Support info (everyone)
|
||||
|
||||
**Settings, About, Copy support info** puts together, in your browser:
|
||||
|
||||
- server version and build, and your browser;
|
||||
- language, time zone and the modules you have switched on;
|
||||
- the AI state: whether a provider is set up, your usage level, and which AI
|
||||
features are available and why not;
|
||||
- for each connected source (Withings, Oura, and so on), its health state,
|
||||
reason, last success, newest data and the last recorded sync attempts;
|
||||
- the last five error ids of this browser session.
|
||||
|
||||
It never contains your username, email address, name, server address,
|
||||
tokens, recorded values or record ids. Nothing is sent anywhere; the text
|
||||
goes to your clipboard.
|
||||
|
||||
## Support report (operator)
|
||||
|
||||
**Admin, System status, Support report** creates a text with **Copy** and
|
||||
**Download**. It is created when you press a button, not on page load, and
|
||||
shown above the buttons so you can read it first. It contains:
|
||||
|
||||
- **Build:** version, build SHA, build time, Node, process type, heap limit,
|
||||
uptime.
|
||||
- **Database:** PostgreSQL version, server time zone, size, last applied
|
||||
migration, unfinished migrations.
|
||||
- **Configuration**, as yes or no only: secure cookie flag, OIDC, OIDC only,
|
||||
Loki, GlitchTip, off-host backup, APNs, Web Push, SMTP, GeoLite2, the number
|
||||
of `NOTIFICATION_PRIVATE_ORIGINS` entries, operator AI key, central Codex,
|
||||
the AI provider types accounts chose, demo mode.
|
||||
- **Jobs:** whether the worker runs in this process and when it last
|
||||
reported, the newest finished job, and every queue with failures in the
|
||||
last 72 hours with an error class (`timeout`, `TypeError`, `P2002`,
|
||||
`ECONNREFUSED`, an HTTP status), never the message.
|
||||
- **Connections:** per source, how many accounts are in each health state and
|
||||
reason, which accounts are not `ok`, and open watchdog incidents.
|
||||
- **AI:** the usage level default and ceiling, accounts per usage level, and
|
||||
the provider ledger per provider type (working, failing, credential
|
||||
refused, paused).
|
||||
- **Notifications:** per channel, delivered, failed and skipped in the last 7
|
||||
days, with the most frequent failure reasons.
|
||||
- **Backups:** newest in-database copy, the last weekly run, off-host
|
||||
freshness, and the last restore drill.
|
||||
- **Recent warnings and errors:** the 20 most frequent action names in the
|
||||
warning and error buffer of this process, with count, last seen and error
|
||||
class.
|
||||
|
||||
Accounts appear as `Account 1`, `Account 2` and so on, numbered by creation.
|
||||
The report never contains an email address, a username, an account id, a
|
||||
recorded value, a token, or the host name of another service. As a second
|
||||
layer, the finished text is scrubbed for the instance's own usernames,
|
||||
emails, names and ids, for tokens, URLs, email addresses, ids, IP addresses
|
||||
and host names before it is shown.
|
||||
|
||||
## Logs beyond the report
|
||||
|
||||
`docker compose logs app` prints every wide event as one JSON line;
|
||||
`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events
|
||||
go to Loki, and GlitchTip (Admin, General) receives unexpected server errors.
|
||||
Each line carries `request_id`, so an error id finds its line there too.
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"durationMinutes": "{minutes} Min.",
|
||||
"durationClockMinutes": "{time} Min.",
|
||||
"durationClockHours": "{time} Std."
|
||||
"durationClockHours": "{time} Std.",
|
||||
"errorIdLabel": "Fehler-ID",
|
||||
"errorIdCopy": "Fehler-ID kopieren",
|
||||
"errorIdCopied": "Fehler-ID kopiert"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Dokumentation",
|
||||
"credits": "Credits & Datenquellen",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"description": "Eine Zusammenfassung für eine Fehlermeldung, ohne Namen, E-Mail oder Gesundheitswerte.",
|
||||
"copy": "Support-Info kopieren",
|
||||
"copied": "Kopiert",
|
||||
"copyFailed": "Die Support-Info konnte nicht zusammengestellt werden."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Event-Details",
|
||||
"closeDetails": "Details schließen",
|
||||
"processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
|
||||
"cardTitle": "Letzte Ereignisse"
|
||||
"cardTitle": "Letzte Ereignisse",
|
||||
"filterErrorId": "Fehler-ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Anmeldeereignisse",
|
||||
"userColumn": "Nutzer"
|
||||
"userColumn": "Nutzer",
|
||||
"supportReport": {
|
||||
"title": "Support-Bericht",
|
||||
"description": "Eine Zusammenfassung dieser Instanz für eine Fehlermeldung, ohne E-Mails, IDs oder Werte.",
|
||||
"body": "Konten erscheinen als „Account 1“, „Account 2“ und so weiter. Lies den Bericht, bevor du ihn teilst.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"download": "Herunterladen",
|
||||
"loadError": "Der Support-Bericht konnte nicht erstellt werden."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours}h {minutes}m",
|
||||
"durationMinutes": "{minutes}m",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "Error ID",
|
||||
"errorIdCopy": "Copy error ID",
|
||||
"errorIdCopied": "Error ID copied"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentation",
|
||||
"credits": "Credits & data attributions",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"description": "A summary for a bug report, without your name, email or health values.",
|
||||
"copy": "Copy support info",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "The support info could not be put together."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Event details",
|
||||
"closeDetails": "Close details",
|
||||
"processDetail": "Buffer is volatile and capped at 500 entries.",
|
||||
"cardTitle": "Recent events"
|
||||
"cardTitle": "Recent events",
|
||||
"filterErrorId": "Error ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Sign-in events",
|
||||
"userColumn": "User"
|
||||
"userColumn": "User",
|
||||
"supportReport": {
|
||||
"title": "Support report",
|
||||
"description": "A summary of this instance for a bug report, without emails, IDs or values.",
|
||||
"body": "Accounts appear as “Account 1”, “Account 2” and so on. Read the report before you share it.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"download": "Download",
|
||||
"loadError": "The support report could not be created."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID de error",
|
||||
"errorIdCopy": "Copiar ID de error",
|
||||
"errorIdCopied": "ID de error copiado"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentación",
|
||||
"credits": "Créditos y atribuciones de datos",
|
||||
"linksHeading": "Fuentes y documentación",
|
||||
"newerAvailable": "Nueva versión: {tag}"
|
||||
"newerAvailable": "Nueva versión: {tag}",
|
||||
"support": {
|
||||
"title": "Soporte",
|
||||
"description": "Un resumen para un informe de error, sin tu nombre, correo ni valores de salud.",
|
||||
"copy": "Copiar información de soporte",
|
||||
"copied": "Copiado",
|
||||
"copyFailed": "No se pudo reunir la información de soporte."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Probar conexión",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Detalles del evento",
|
||||
"closeDetails": "Cerrar detalles",
|
||||
"processDetail": "El búfer es volátil y está limitado a 500 entradas.",
|
||||
"cardTitle": "Eventos recientes"
|
||||
"cardTitle": "Eventos recientes",
|
||||
"filterErrorId": "ID de error"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Copias de seguridad",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Eventos de inicio de sesión",
|
||||
"userColumn": "Usuario"
|
||||
"userColumn": "Usuario",
|
||||
"supportReport": {
|
||||
"title": "Informe de soporte",
|
||||
"description": "Un resumen de esta instancia para un informe de error, sin correos, ID ni valores.",
|
||||
"body": "Las cuentas aparecen como «Account 1», «Account 2», etc. Lee el informe antes de compartirlo.",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"download": "Descargar",
|
||||
"loadError": "No se pudo crear el informe de soporte."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Logros",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID d'erreur",
|
||||
"errorIdCopy": "Copier l'ID d'erreur",
|
||||
"errorIdCopied": "ID d'erreur copié"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentation",
|
||||
"credits": "Crédits et attributions de données",
|
||||
"linksHeading": "Sources et documentation",
|
||||
"newerAvailable": "Nouvelle version : {tag}"
|
||||
"newerAvailable": "Nouvelle version : {tag}",
|
||||
"support": {
|
||||
"title": "Assistance",
|
||||
"description": "Un résumé pour un signalement de bug, sans votre nom, votre e-mail ni vos valeurs de santé.",
|
||||
"copy": "Copier les infos d'assistance",
|
||||
"copied": "Copié",
|
||||
"copyFailed": "Les infos d'assistance n'ont pas pu être rassemblées."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Tester la connexion",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Détails de l'événement",
|
||||
"closeDetails": "Fermer les détails",
|
||||
"processDetail": "Le tampon est volatil et limité à 500 entrées.",
|
||||
"cardTitle": "Événements récents"
|
||||
"cardTitle": "Événements récents",
|
||||
"filterErrorId": "ID d'erreur"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Sauvegardes",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Événements de connexion",
|
||||
"userColumn": "Utilisateur"
|
||||
"userColumn": "Utilisateur",
|
||||
"supportReport": {
|
||||
"title": "Rapport d'assistance",
|
||||
"description": "Un résumé de cette instance pour un signalement de bug, sans e-mails, identifiants ni valeurs.",
|
||||
"body": "Les comptes apparaissent comme « Account 1 », « Account 2 », etc. Lisez le rapport avant de le partager.",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"download": "Télécharger",
|
||||
"loadError": "Le rapport d'assistance n'a pas pu être créé."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Réussites",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID errore",
|
||||
"errorIdCopy": "Copia ID errore",
|
||||
"errorIdCopied": "ID errore copiato"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Documentazione",
|
||||
"credits": "Crediti e attribuzioni dei dati",
|
||||
"linksHeading": "Fonti e documentazione",
|
||||
"newerAvailable": "Nuova versione: {tag}"
|
||||
"newerAvailable": "Nuova versione: {tag}",
|
||||
"support": {
|
||||
"title": "Assistenza",
|
||||
"description": "Un riepilogo per una segnalazione di errore, senza nome, email o valori di salute.",
|
||||
"copy": "Copia le info di assistenza",
|
||||
"copied": "Copiato",
|
||||
"copyFailed": "Non è stato possibile raccogliere le info di assistenza."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Prova connessione",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Dettagli dell'evento",
|
||||
"closeDetails": "Chiudi dettagli",
|
||||
"processDetail": "Il buffer è volatile e limitato a 500 voci.",
|
||||
"cardTitle": "Eventi recenti"
|
||||
"cardTitle": "Eventi recenti",
|
||||
"filterErrorId": "ID errore"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backup",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Eventi di accesso",
|
||||
"userColumn": "Utente"
|
||||
"userColumn": "Utente",
|
||||
"supportReport": {
|
||||
"title": "Rapporto di assistenza",
|
||||
"description": "Un riepilogo di questa istanza per una segnalazione di errore, senza email, ID o valori.",
|
||||
"body": "Gli account compaiono come “Account 1”, “Account 2” e così via. Leggi il rapporto prima di condividerlo.",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiato",
|
||||
"download": "Scarica",
|
||||
"loadError": "Non è stato possibile creare il rapporto di assistenza."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Obiettivi",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours}시간 {minutes}분",
|
||||
"durationMinutes": "{minutes}분",
|
||||
"durationClockMinutes": "{time}분",
|
||||
"durationClockHours": "{time}시간"
|
||||
"durationClockHours": "{time}시간",
|
||||
"errorIdLabel": "오류 ID",
|
||||
"errorIdCopy": "오류 ID 복사",
|
||||
"errorIdCopied": "오류 ID 복사됨"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "문서",
|
||||
"credits": "크레딧 및 데이터 출처",
|
||||
"linksHeading": "출처 및 문서",
|
||||
"newerAvailable": "새 버전: {tag}"
|
||||
"newerAvailable": "새 버전: {tag}",
|
||||
"support": {
|
||||
"title": "지원",
|
||||
"description": "버그 신고용 요약이며, 이름, 이메일, 건강 수치는 포함되지 않습니다.",
|
||||
"copy": "지원 정보 복사",
|
||||
"copied": "복사됨",
|
||||
"copyFailed": "지원 정보를 만들 수 없습니다."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "연결 테스트",
|
||||
@@ -7729,7 +7739,8 @@
|
||||
"eventDetails": "이벤트 상세",
|
||||
"closeDetails": "상세 닫기",
|
||||
"processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요.",
|
||||
"cardTitle": "최근 이벤트"
|
||||
"cardTitle": "최근 이벤트",
|
||||
"filterErrorId": "오류 ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "백업",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다."
|
||||
},
|
||||
"loginOverviewCardTitle": "로그인 이벤트",
|
||||
"userColumn": "사용자"
|
||||
"userColumn": "사용자",
|
||||
"supportReport": {
|
||||
"title": "지원 보고서",
|
||||
"description": "버그 신고용 이 인스턴스의 요약이며, 이메일, ID, 수치는 포함되지 않습니다.",
|
||||
"body": "계정은 “Account 1”, “Account 2”처럼 표시됩니다. 공유하기 전에 보고서를 확인하세요.",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"download": "다운로드",
|
||||
"loadError": "지원 보고서를 만들 수 없습니다."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "업적",
|
||||
|
||||
+24
-4
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} godz. {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} godz."
|
||||
"durationClockHours": "{time} godz.",
|
||||
"errorIdLabel": "ID błędu",
|
||||
"errorIdCopy": "Kopiuj ID błędu",
|
||||
"errorIdCopied": "Skopiowano ID błędu"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -6646,7 +6649,14 @@
|
||||
"docs": "Dokumentacja",
|
||||
"credits": "Autorzy i źródła danych",
|
||||
"linksHeading": "Źródła i dokumentacja",
|
||||
"newerAvailable": "Nowa wersja: {tag}"
|
||||
"newerAvailable": "Nowa wersja: {tag}",
|
||||
"support": {
|
||||
"title": "Pomoc",
|
||||
"description": "Podsumowanie do zgłoszenia błędu, bez imienia, adresu e-mail i wartości zdrowotnych.",
|
||||
"copy": "Kopiuj informacje dla pomocy",
|
||||
"copied": "Skopiowano",
|
||||
"copyFailed": "Nie udało się zebrać informacji dla pomocy."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Testuj połączenie",
|
||||
@@ -7753,7 +7763,8 @@
|
||||
"eventDetails": "Szczegóły zdarzenia",
|
||||
"closeDetails": "Zamknij szczegóły",
|
||||
"processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów.",
|
||||
"cardTitle": "Ostatnie zdarzenia"
|
||||
"cardTitle": "Ostatnie zdarzenia",
|
||||
"filterErrorId": "ID błędu"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Kopie zapasowe",
|
||||
@@ -8120,7 +8131,16 @@
|
||||
"codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Zdarzenia logowania",
|
||||
"userColumn": "Użytkownik"
|
||||
"userColumn": "Użytkownik",
|
||||
"supportReport": {
|
||||
"title": "Raport dla pomocy",
|
||||
"description": "Podsumowanie tej instancji do zgłoszenia błędu, bez adresów e-mail, identyfikatorów i wartości.",
|
||||
"body": "Konta pojawiają się jako „Account 1”, „Account 2” itd. Przeczytaj raport, zanim go udostępnisz.",
|
||||
"copy": "Kopiuj",
|
||||
"copied": "Skopiowano",
|
||||
"download": "Pobierz",
|
||||
"loadError": "Nie udało się utworzyć raportu dla pomocy."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Osiągnięcia",
|
||||
|
||||
@@ -28,7 +28,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
"admin.pulse_day_mean.get",
|
||||
"admin.pulse_day_mean.post",
|
||||
],
|
||||
"admin/support-report/route.ts": ["admin.support_report.get"],
|
||||
"anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"],
|
||||
"anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"],
|
||||
"anamnesis/life-circumstances/ai-sections/route.ts": [
|
||||
@@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
|
||||
};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 24;
|
||||
const REGISTER_SIZE = 23;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Every error display goes through the one helper that shows an error id.
|
||||
*
|
||||
* A self-hoster reporting a failure needs the id the operator finds it by. The
|
||||
* web shows it on a server failure with a copy button, through two doors only:
|
||||
*
|
||||
* - toasts: `showErrorToast` (`src/lib/errors/error-toast.tsx`), which adds
|
||||
* the id when its `cause` is a 5xx and is otherwise exactly `toast.error`;
|
||||
* - error boundaries: `ErrorDetails` / `ErrorIdLine`, or `errorIdOf` for the
|
||||
* root boundary that renders without any provider.
|
||||
*
|
||||
* A raw `toast.error(` anywhere else is a toast that can never carry the id,
|
||||
* and a route boundary that skips the helper is a crash screen without it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const HELPER = "lib/errors/error-toast.tsx";
|
||||
|
||||
/** Whitespace-tolerant: `toast.error(`, `toast .error (`, `toast\n.error(`. */
|
||||
const RAW_TOAST_ERROR = /\btoast\s*\.\s*error\s*\(/g;
|
||||
const HELPER_CALL = /\bshowErrorToast\s*\(/g;
|
||||
|
||||
/**
|
||||
* Route boundaries that deliberately show no error id, with the reason. Each
|
||||
* entry is a decision, not a convenience.
|
||||
*/
|
||||
const BOUNDARY_EXEMPT: Readonly<Record<string, string>> = {
|
||||
// The clinician share view is opened by someone outside the instance, with
|
||||
// no account and no way to reach its operator; it shows a retry and nothing
|
||||
// a probe could learn from, by design (see the file's header).
|
||||
"app/c/error.tsx": "public share view, no operator relationship",
|
||||
};
|
||||
|
||||
const files = walkSourceFiles(SRC, { floor: 2500 }).filter(
|
||||
(rel) =>
|
||||
!rel.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(rel) &&
|
||||
!rel.startsWith("generated/"),
|
||||
);
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("error display helper guard", () => {
|
||||
it("finds the helper's own toast.error, so the matcher works", () => {
|
||||
const own = read(HELPER).match(RAW_TOAST_ERROR) ?? [];
|
||||
expect(own.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("allows no toast.error outside the helper", () => {
|
||||
const offenders = files
|
||||
.filter((rel) => rel !== HELPER)
|
||||
.filter((rel) => (read(rel).match(RAW_TOAST_ERROR) ?? []).length > 0)
|
||||
.sort();
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes the app's error toasts through showErrorToast", () => {
|
||||
const calls = files.reduce(
|
||||
(n, rel) => n + (read(rel).match(HELPER_CALL) ?? []).length,
|
||||
0,
|
||||
);
|
||||
// 200 call sites when the helper landed; a count far below it means the
|
||||
// matcher or the walk stopped seeing them.
|
||||
expect(calls).toBeGreaterThan(180);
|
||||
});
|
||||
|
||||
it("renders every route error boundary through the error id helper", () => {
|
||||
const boundaries = files.filter((rel) =>
|
||||
/^app\/(.*\/)?(error|global-error)\.tsx$/.test(rel),
|
||||
);
|
||||
expect(boundaries.length).toBeGreaterThanOrEqual(3);
|
||||
const missing = boundaries
|
||||
.filter((rel) => !(rel in BOUNDARY_EXEMPT))
|
||||
.filter((rel) => {
|
||||
const source = read(rel);
|
||||
return !(
|
||||
/<ErrorDetails\b/.test(source) ||
|
||||
/<ErrorIdLine\b/.test(source) ||
|
||||
/\berrorIdOf\s*\(/.test(source)
|
||||
);
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
for (const rel of Object.keys(BOUNDARY_EXEMPT)) {
|
||||
expect(boundaries, `${rel} is exempt but no longer exists`).toContain(
|
||||
rel,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the id in the shared boundary panel", () => {
|
||||
const source = read("components/error-details.tsx");
|
||||
expect(source).toMatch(/<ErrorIdLine\b/);
|
||||
expect(source).toMatch(/\berrorIdOf\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Error-envelope helpers for tests that compare whole response bodies.
|
||||
*
|
||||
* `apiHandler` stamps every error envelope with `meta.requestId`, a fresh id
|
||||
* per request. A test that proves two refusals are byte-identical (an
|
||||
* enumeration oracle check) compares everything else: the id is random, the
|
||||
* same for no two requests, and says nothing about the account.
|
||||
*/
|
||||
|
||||
/** The body text with `meta.requestId` removed, `meta` dropped when empty. */
|
||||
export function withoutRequestId(text: string): string {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
return text;
|
||||
}
|
||||
const record = { ...(body as Record<string, unknown>) };
|
||||
const meta = record.meta;
|
||||
if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
|
||||
const rest = { ...(meta as Record<string, unknown>) };
|
||||
delete rest.requestId;
|
||||
if (Object.keys(rest).length === 0) delete record.meta;
|
||||
else record.meta = rest;
|
||||
}
|
||||
return JSON.stringify(record);
|
||||
}
|
||||
|
||||
/** A response's body text with `meta.requestId` removed. */
|
||||
export async function bodyWithoutRequestId(res: Response): Promise<string> {
|
||||
return withoutRequestId(await res.text());
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
|
||||
import { RemindersSection } from "@/components/admin/reminders-section";
|
||||
import { ServicesSection } from "@/components/admin/services-section";
|
||||
import { SystemStatusSection } from "@/components/admin/system-status-section";
|
||||
import { SupportReportCard } from "@/components/admin/support-report-card";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
|
||||
@@ -69,6 +70,8 @@ export function AdminSectionRenderer({
|
||||
>
|
||||
<SectionFrame>
|
||||
<SystemStatusSection />
|
||||
{/* v1.43 — the redacted report for a bug report. */}
|
||||
<SupportReportCard />
|
||||
{/* v1.42 — the table maintenance after the tombstone purge. */}
|
||||
<MeasurementMaintenanceCard />
|
||||
</SectionFrame>
|
||||
|
||||
@@ -136,4 +136,38 @@ describe("GET /api/admin/app-logs", () => {
|
||||
expect(json.error).toBe("Validation failed");
|
||||
expect(json.details.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("finds an error by the first 8 characters of its id in the problems ring", async () => {
|
||||
appendLogEvent(
|
||||
makeEvent({ level: "error", request_id: "3f9a2c1b-1111-4000-8000-0000" }),
|
||||
);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
appendLogEvent(makeEvent({ level: "info" }));
|
||||
}
|
||||
const gone = await GET(req("requestId=3f9a2c1b"));
|
||||
const goneJson = (await gone.json()) as { data: { events: WideEvent[] } };
|
||||
expect(goneJson.data.events).toHaveLength(0);
|
||||
|
||||
const res = await GET(req("requestId=3f9a2c1b&ring=problems"));
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
events: WideEvent[];
|
||||
meta: { problemsBufferMax: number; ring: string };
|
||||
};
|
||||
};
|
||||
expect(json.data.events.map((e) => e.request_id)).toEqual([
|
||||
"3f9a2c1b-1111-4000-8000-0000",
|
||||
]);
|
||||
expect(json.data.meta.problemsBufferMax).toBe(1000);
|
||||
expect(json.data.meta.ring).toBe("problems");
|
||||
});
|
||||
|
||||
it("refuses a request id shorter than 8 characters or outside the id alphabet", async () => {
|
||||
for (const bad of ["3f9a2c1", "3f9a2c1b%20OR%201", "a".repeat(65)]) {
|
||||
const res = await GET(req(`requestId=${bad}`));
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
const ring = await GET(req("ring=everything"));
|
||||
expect(ring.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
|
||||
import {
|
||||
readLogBuffer,
|
||||
LOG_BUFFER_MAX,
|
||||
PROBLEMS_BUFFER_MAX,
|
||||
} from "@/lib/logging/in-memory-buffer";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -10,17 +14,31 @@ import { z } from "zod/v4";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const querySchema = z.object({
|
||||
// The error id a person reads out is the first 8 characters of the request
|
||||
// id; the full id (up to 64) matches as well. Same alphabet as an accepted
|
||||
// incoming `x-request-id`.
|
||||
requestId: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z0-9-]{8,64}$/)
|
||||
.optional(),
|
||||
ring: z.enum(["all", "problems"]).default("all"),
|
||||
traceId: z.string().trim().min(1).max(200).optional(),
|
||||
level: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||
action: z.string().trim().min(1).max(200).optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
until: z.string().datetime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
limit: z.coerce.number().int().min(1).max(PROBLEMS_BUFFER_MAX).default(200),
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin endpoint: tail of the per-process in-memory wide-event ring buffer.
|
||||
*
|
||||
* `ring=problems` reads the second ring, which keeps the last 1,000 `warn` and
|
||||
* `error` events only, so a failure a person reported an hour ago is still
|
||||
* there after the main ring has turned over. `requestId` finds the event
|
||||
* behind an error id by prefix.
|
||||
*
|
||||
* The buffer is populated by `transports.emitEvent()` so the result mirrors
|
||||
* what would be shipped to Loki (sampler-gated). Per-process and volatile —
|
||||
* see `src/lib/logging/in-memory-buffer.ts` for the limitations.
|
||||
@@ -36,6 +54,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const result = querySchema.safeParse({
|
||||
requestId: searchParams.get("requestId") ?? undefined,
|
||||
ring: searchParams.get("ring") ?? undefined,
|
||||
traceId: searchParams.get("traceId") ?? undefined,
|
||||
level: searchParams.get("level") ?? undefined,
|
||||
action: searchParams.get("action") ?? undefined,
|
||||
@@ -47,6 +67,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = result.data;
|
||||
|
||||
const events = readLogBuffer({
|
||||
ring: parsed.ring,
|
||||
requestId: parsed.requestId,
|
||||
traceId: parsed.traceId,
|
||||
level: parsed.level,
|
||||
action: parsed.action,
|
||||
@@ -62,6 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
total: redacted.length,
|
||||
bufferMax: LOG_BUFFER_MAX,
|
||||
problemsBufferMax: PROBLEMS_BUFFER_MAX,
|
||||
ring: parsed.ring,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* `GET /api/admin/support-report` end to end over a mocked database: the
|
||||
* collector, the renderer and the redaction together. Accounts are seeded
|
||||
* with distinctive emails, ids, usernames and names, and the failures carry
|
||||
* a token, a URL and a host in their messages; none of it may reach the text.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const USERS = [
|
||||
{
|
||||
id: "cmownerabcdefghijklmnopq",
|
||||
username: "ownerperson",
|
||||
email: "owner.person@example.org",
|
||||
fullName: "Olive Owner",
|
||||
aiUsageLevel: null,
|
||||
},
|
||||
{
|
||||
id: "cmsecondabcdefghijklmnop",
|
||||
username: "secondperson",
|
||||
email: "second.person@mailbox.org",
|
||||
fullName: "Sam Second",
|
||||
aiUsageLevel: "FULL",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findMany: vi.fn(async () => USERS),
|
||||
groupBy: vi.fn(async () => [
|
||||
{ aiProvider: "LOCAL", _count: { _all: 1 } },
|
||||
{ aiProvider: null, _count: { _all: 1 } },
|
||||
]),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://key@glitchtip.example.net/1",
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
adminAiKeyEncrypted: "v1:ciphertext",
|
||||
adminCodexConnectionStatus: "disconnected",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiUsageMaxLevel: "FULL",
|
||||
})),
|
||||
},
|
||||
$queryRaw: vi.fn(async (strings: TemplateStringsArray) => {
|
||||
const sql = strings.join("?");
|
||||
if (sql.includes("server_version")) {
|
||||
return [
|
||||
{ version: "16.4", timezone: "Etc/UTC", size: BigInt(52428800) },
|
||||
];
|
||||
}
|
||||
if (sql.includes("migration_name")) {
|
||||
return [
|
||||
{
|
||||
migration_name: "0410_relation_links",
|
||||
finished_at: new Date("2026-10-15T08:00:00Z"),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes("finished_at IS NULL")) return [{ count: 0 }];
|
||||
if (sql.includes("pgboss.job")) {
|
||||
return [{ at: new Date("2026-10-15T09:59:00Z") }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
integrationWatch: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
userId: "cmsecondabcdefghijklmnop",
|
||||
integration: "nightscout",
|
||||
incidentReason: "unexpected_silence",
|
||||
incidentOpenedAt: new Date("2026-10-14T00:00:00Z"),
|
||||
alertedAt: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
providerHealth: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
providerType: "LOCAL",
|
||||
lastResult: "hard_failed",
|
||||
lastStatus: 502,
|
||||
lastFailureAt: new Date("2026-10-15T07:00:00Z"),
|
||||
nextRetryAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
]),
|
||||
},
|
||||
pushAttempt: {
|
||||
groupBy: vi.fn(async (args: { by: string[] }) =>
|
||||
args.by.includes("result")
|
||||
? [
|
||||
{ channel: "NTFY", result: "ok", _count: { _all: 9 } },
|
||||
{ channel: "NTFY", result: "error", _count: { _all: 2 } },
|
||||
]
|
||||
: [{ channel: "NTFY", reason: "http_500", _count: { _all: 2 } }],
|
||||
),
|
||||
},
|
||||
dataBackup: {
|
||||
aggregate: vi.fn(async () => ({
|
||||
_max: { createdAt: new Date("2026-10-12T03:00:00Z") },
|
||||
})),
|
||||
count: vi.fn(async () => 2),
|
||||
},
|
||||
offhostBackupState: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ lastSuccessAt: new Date("2026-10-15T02:00:00Z") },
|
||||
{ lastSuccessAt: null },
|
||||
]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/job-failures", () => ({
|
||||
JOB_FAILURE_WINDOW_HOURS: 72,
|
||||
readFailingQueues: vi.fn(async () => [
|
||||
{
|
||||
queue: "withings-sync",
|
||||
failures: 4,
|
||||
lastFailedAt: "2026-10-15T09:00:00.000Z",
|
||||
lastError:
|
||||
"TypeError: fetch https://wbsapi.withings.net/v2?access_token=abc failed for owner.person@example.org",
|
||||
},
|
||||
]),
|
||||
readLastQueueRun: vi.fn(async (queue: string) =>
|
||||
queue === "data-restore-drill"
|
||||
? {
|
||||
at: "2026-10-01T04:11:00.000Z",
|
||||
state: "failed",
|
||||
error: "connect ECONNREFUSED 10.0.0.12:9000 (s3.nas.lan)",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/connection-health-load", () => ({
|
||||
loadConnectionHealth: vi.fn(async (userId: string) => {
|
||||
const empty = {
|
||||
withings: null,
|
||||
whoop: null,
|
||||
fitbit: null,
|
||||
"google-health": null,
|
||||
polar: null,
|
||||
oura: null,
|
||||
strava: null,
|
||||
nightscout: null,
|
||||
"apple-health": null,
|
||||
};
|
||||
if (userId === "cmownerabcdefghijklmnopq") {
|
||||
return {
|
||||
...empty,
|
||||
withings: { state: "ok", reason: "data_arriving" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...empty,
|
||||
withings: { state: "action_required", reason: "reauth_required" },
|
||||
nightscout: { state: "degraded", reason: "unexpected_silence" },
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => false }));
|
||||
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
|
||||
import { GET } from "../route";
|
||||
|
||||
const FORBIDDEN = [
|
||||
...USERS.flatMap((u) => [u.id, u.username, u.email, u.fullName]),
|
||||
"wbsapi.withings.net",
|
||||
"access_token",
|
||||
"glitchtip.example.net",
|
||||
"10.0.0.12",
|
||||
"s3.nas.lan",
|
||||
"5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc",
|
||||
"hlk_leaked_token",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(requireAdmin).mockReset();
|
||||
clearLogBuffer();
|
||||
});
|
||||
|
||||
async function report(): Promise<{ generatedAt: string; text: string }> {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
describe("GET /api/admin/support-report", () => {
|
||||
it("refuses a non-admin before reading anything", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(GET()).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("answers the redacted report with every section", async () => {
|
||||
appendLogEvent({
|
||||
timestamp: "2026-10-15T09:40:00.000Z",
|
||||
duration_ms: 20,
|
||||
request_id: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc",
|
||||
trace_id: "t",
|
||||
level: "error",
|
||||
kind: "http",
|
||||
service: "healthlog",
|
||||
environment: "test",
|
||||
action: { name: "measurement.batch.ingest" },
|
||||
auth: { user_id: "cmownerabcdefghijklmnopq" },
|
||||
error: {
|
||||
type: "TypeError",
|
||||
message: "Bearer hlk_leaked_token for owner.person@example.org",
|
||||
},
|
||||
});
|
||||
|
||||
const { generatedAt, text } = await report();
|
||||
expect(Date.parse(generatedAt)).not.toBeNaN();
|
||||
for (const heading of [
|
||||
"## Build",
|
||||
"## Database",
|
||||
"## Configuration",
|
||||
"## Jobs",
|
||||
"## Connections",
|
||||
"## AI",
|
||||
"## Notifications (7 days)",
|
||||
"## Backups",
|
||||
"## Recent warnings and errors (this process)",
|
||||
]) {
|
||||
expect(text).toContain(heading);
|
||||
}
|
||||
expect(text).toContain("- PostgreSQL: 16.4");
|
||||
expect(text).toContain("0410_relation_links");
|
||||
expect(text).toContain("- GlitchTip: yes");
|
||||
expect(text).toContain("withings-sync: 4 failed");
|
||||
expect(text).toContain("(TypeError)");
|
||||
expect(text).toContain(
|
||||
"- withings: action_required / reauth_required: 1 (Account 2)",
|
||||
);
|
||||
expect(text).toContain("- withings: ok / data_arriving: 1");
|
||||
expect(text).toContain("nightscout: unexpected_silence since");
|
||||
expect(text).toContain("Account 2");
|
||||
expect(text).toContain(
|
||||
"- Accounts by usage level: FULL 1, instance default 1",
|
||||
);
|
||||
expect(text).toContain(
|
||||
"LOCAL: ok 0, failing 1, credential refused 0, paused 1",
|
||||
);
|
||||
expect(text).toContain("- NTFY: ok 9, failed 2, skipped 0; http_500 2");
|
||||
expect(text).toContain(
|
||||
"Last restore drill: failed at 2026-10-01T04:11:00.000Z (ECONNREFUSED)",
|
||||
);
|
||||
expect(text).toContain("- error measurement.batch.ingest: 1x");
|
||||
|
||||
for (const value of FORBIDDEN) {
|
||||
expect(text.toLowerCase(), value).not.toContain(value.toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,32 @@
|
||||
* backups and the most frequent recent error actions. Never an email address,
|
||||
* a user id, a value, a token or a foreign host name; accounts appear as
|
||||
* "Account 1..n". Admin only, cookie session only.
|
||||
*
|
||||
* What goes in and how it is redacted lives in `src/lib/support/report.ts`;
|
||||
* the redaction guard is `src/lib/support/__tests__/support-report-redaction.test.ts`.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
buildSupportReportText,
|
||||
collectSupportReportFacts,
|
||||
} from "@/lib/support/report";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
return notImplemented("admin.support_report.get");
|
||||
annotate({ action: { name: "admin.support_report.get" } });
|
||||
|
||||
const { facts, context } = await collectSupportReportFacts();
|
||||
const text = buildSupportReportText(facts, context);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
support_report_accounts: facts.accounts.total,
|
||||
support_report_bytes: text.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess({ generatedAt: facts.generatedAt, text });
|
||||
});
|
||||
@@ -80,6 +80,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPasswordOrDummy } from "@/lib/auth/password";
|
||||
import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope";
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/login", {
|
||||
@@ -159,7 +160,7 @@ describe("POST /api/auth/login — constant work across the refusals", () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never);
|
||||
const res = await POST(makeRequest());
|
||||
statuses.push(res.status);
|
||||
bodies.push(await res.text());
|
||||
bodies.push(await bodyWithoutRequestId(res));
|
||||
}
|
||||
expect(new Set(statuses)).toEqual(new Set([401]));
|
||||
expect(new Set(bodies).size).toBe(1);
|
||||
|
||||
@@ -421,6 +421,7 @@ describe("POST — consent / budget gating", () => {
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "consent.ai.required",
|
||||
capability: "documentAi",
|
||||
reason: "consent_required",
|
||||
|
||||
@@ -371,6 +371,7 @@ describe("POST /api/documents/inbound/[id]/extract — the documentAi capability
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "documentAi",
|
||||
reason: "operator_disabled",
|
||||
|
||||
@@ -137,6 +137,7 @@ describe("POST /api/documents/inbound/reindex — the documentAi capability", ()
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "assistant.disabled.documentAi",
|
||||
capability: "documentAi",
|
||||
reason: "operator_disabled",
|
||||
|
||||
@@ -344,6 +344,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
data: null,
|
||||
error: "Medication intake import already in progress",
|
||||
meta: { requestId: expect.any(String) },
|
||||
});
|
||||
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
|
||||
expect(getGlobalBoss).not.toHaveBeenCalled();
|
||||
|
||||
@@ -232,6 +232,7 @@ describe("POST /api/medications/intake/dose-history-import", () => {
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "missing_required_columns",
|
||||
detail: "Medication, Status",
|
||||
});
|
||||
|
||||
@@ -86,7 +86,11 @@ describe("POST /api/settings/email/test", () => {
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: failureCode, smtpCode });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: failureCode,
|
||||
smtpCode,
|
||||
});
|
||||
expect(json.error).toContain(sentence);
|
||||
},
|
||||
);
|
||||
@@ -118,7 +122,7 @@ describe("POST /api/settings/email/test", () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
expect(json.meta).toEqual({ requestId: expect.any(String) });
|
||||
});
|
||||
|
||||
it("answers a delivered test with 200", async () => {
|
||||
|
||||
@@ -140,7 +140,10 @@ describe("PUT /api/settings/webhook — private targets", () => {
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.data).toBeNull();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -155,7 +158,10 @@ describe("PUT /api/settings/webhook — private targets", () => {
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -181,7 +187,7 @@ describe("PUT /api/settings/webhook — private targets", () => {
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe("Invalid data");
|
||||
expect(json.meta).toBeUndefined();
|
||||
expect(json.meta).toEqual({ requestId: expect.any(String) });
|
||||
});
|
||||
|
||||
it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
|
||||
@@ -223,7 +229,10 @@ describe("PUT /api/settings/webhook — private targets", () => {
|
||||
const response = await put("webhook", { url, enabled: true });
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_grantable",
|
||||
});
|
||||
expect(json.error).toMatch(/no operator grant can open/);
|
||||
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
expect(json.details.issues.length).toBeGreaterThan(0);
|
||||
@@ -262,7 +271,10 @@ describe("PUT /api/settings/ntfy — private servers", () => {
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -291,7 +303,10 @@ describe("PUT /api/settings/ntfy — private servers", () => {
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,7 +340,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_approved",
|
||||
});
|
||||
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
},
|
||||
);
|
||||
@@ -347,7 +365,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "private_origin_not_grantable",
|
||||
});
|
||||
expect(json.error).toMatch(/no operator grant can open/);
|
||||
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
},
|
||||
@@ -372,6 +393,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: "upstream_rejected",
|
||||
upstreamStatus: 400,
|
||||
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
||||
@@ -408,7 +430,11 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: code, upstreamStatus: status });
|
||||
expect(json.meta).toEqual({
|
||||
requestId: expect.any(String),
|
||||
errorCode: code,
|
||||
upstreamStatus: status,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -450,7 +476,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toBeUndefined();
|
||||
expect(json.meta).toEqual({ requestId: expect.any(String) });
|
||||
expect(emitIfSampledMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error" }),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { errorIdOf, recordErrorId, shortErrorId } from "@/lib/errors/error-id";
|
||||
import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload";
|
||||
|
||||
/**
|
||||
@@ -22,12 +23,29 @@ export default function GlobalError({
|
||||
reloadOnceForChunkError(error);
|
||||
}, [error]);
|
||||
|
||||
// The id the operator finds this failure by. No i18n provider exists at
|
||||
// this level, so the line is the same static bilingual pair as the heading.
|
||||
const errorId = errorIdOf(error, { unexpected: true });
|
||||
useEffect(() => {
|
||||
if (errorId) recordErrorId(errorId);
|
||||
}, [errorId]);
|
||||
|
||||
async function handleCopyId() {
|
||||
if (!errorId) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorId);
|
||||
} catch {
|
||||
window.prompt("Error ID", errorId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
// Strip query string — OAuth callback routes carry sensitive tokens.
|
||||
const loc = typeof window !== "undefined" ? window.location : null;
|
||||
const payload = {
|
||||
message: error.message,
|
||||
digest: error.digest,
|
||||
errorId,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
|
||||
urlPath: loc?.pathname ?? null,
|
||||
@@ -91,6 +109,39 @@ export default function GlobalError({
|
||||
>
|
||||
{error.message || "Unknown error"}
|
||||
</pre>
|
||||
{errorId && (
|
||||
<p
|
||||
data-slot="error-id"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: "#d0d0d0",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<span>Fehler-ID / Error ID</span>
|
||||
<code style={{ color: "#f8f8f2" }}>{shortErrorId(errorId)}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyId}
|
||||
aria-label="Copy error ID"
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#f8f8f2",
|
||||
border: "1px solid #44475a",
|
||||
borderRadius: 6,
|
||||
minHeight: 44,
|
||||
padding: "6px 12px",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={reset}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo } from "react";
|
||||
import { StatTile } from "@/components/admin/_stat-tile";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -297,10 +298,11 @@ export function useUpdateSettings() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -89,8 +90,9 @@ export function AiServerKeySection() {
|
||||
toast.success(t("admin.aiServerKey.saved"));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ interface AppLogsResponse {
|
||||
meta: {
|
||||
total: number;
|
||||
bufferMax: number;
|
||||
problemsBufferMax?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +95,7 @@ function levelIcon(level: LogLevel) {
|
||||
export function AppLogPreviewSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [requestId, setRequestId] = useState("");
|
||||
const [traceId, setTraceId] = useState("");
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
const [level, setLevel] = useState<LogLevel | "__all__">("__all__");
|
||||
@@ -103,16 +105,32 @@ export function AppLogPreviewSection() {
|
||||
|
||||
const params = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
// An error id is the first characters of a request id. The ids people
|
||||
// read out come from 5xx answers, which are logged at `error`, so a
|
||||
// search by id, like a filter on warn or error, reads the problems ring:
|
||||
// it keeps the last 1,000 warnings and errors, where the main ring keeps
|
||||
// 500 events of every kind and turns over in minutes on a busy host.
|
||||
const id = requestId.trim();
|
||||
if (id.length >= 8) p.set("requestId", id);
|
||||
if (id.length >= 8 || level === "warn" || level === "error") {
|
||||
p.set("ring", "problems");
|
||||
}
|
||||
if (traceId.trim()) p.set("traceId", traceId.trim());
|
||||
if (actionFilter.trim()) p.set("action", actionFilter.trim());
|
||||
if (level !== "__all__") p.set("level", level);
|
||||
const since = rangeToSince(range);
|
||||
if (since) p.set("since", since);
|
||||
return p;
|
||||
}, [traceId, actionFilter, level, range]);
|
||||
}, [requestId, traceId, actionFilter, level, range]);
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
|
||||
queryKey: queryKeys.adminAppLogs(
|
||||
traceId,
|
||||
actionFilter,
|
||||
level,
|
||||
range,
|
||||
requestId.trim(),
|
||||
),
|
||||
queryFn: async () => {
|
||||
return apiGet<AppLogsResponse>(
|
||||
`/api/admin/app-logs?${params.toString()}`,
|
||||
@@ -144,7 +162,19 @@ export function AppLogPreviewSection() {
|
||||
{/* Filters and the refresh share one toolbar row above the table; the
|
||||
header status slot carries status, not actions (design standards
|
||||
§12). */}
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[repeat(4,minmax(0,1fr))_auto]">
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.app-logs.filterErrorId")}
|
||||
value={requestId}
|
||||
maxLength={64}
|
||||
onChange={(e) => {
|
||||
setRequestId(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label={t("admin.section.app-logs.filterErrorId")}
|
||||
data-slot="app-logs-error-id-filter"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.app-logs.filterTraceId")}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -104,10 +105,11 @@ function useUpdateAssistantFlags() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
client.invalidateQueries({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -864,10 +865,11 @@ export function BackupsSection() {
|
||||
}, 2000);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.runFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -923,10 +925,11 @@ export function BackupsSection() {
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.uploadFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -983,7 +986,7 @@ export function BackupsSection() {
|
||||
void queryClient.invalidateQueries();
|
||||
} else if (job.status === "failed") {
|
||||
setRefusedSections(missingSectionsOf(job.failure));
|
||||
toast.error(t("admin.section.backups.restoreFailed"));
|
||||
showErrorToast(t("admin.section.backups.restoreFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1061,17 +1064,20 @@ export function BackupsSection() {
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "backup.restore.active"
|
||||
) {
|
||||
toast.error(t("admin.section.backups.restoreActive"));
|
||||
showErrorToast(t("admin.section.backups.restoreActive"), {
|
||||
cause: err,
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminBackupRestores(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRefusedSections([]);
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.restoreFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1109,10 +1115,11 @@ export function BackupsSection() {
|
||||
toast.success(t("admin.section.backups.downloadStarted"));
|
||||
} catch (err) {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.backups.downloadFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
@@ -75,8 +76,9 @@ export function CentralCodexSection() {
|
||||
intervalSeconds: json.data.intervalSeconds,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
setDevicePolling(false);
|
||||
}
|
||||
@@ -110,8 +112,9 @@ export function CentralCodexSection() {
|
||||
if (!cancelled) setTimeout(tick, intervalMs);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
setDeviceCode(null);
|
||||
setDevicePolling(false);
|
||||
@@ -142,8 +145,9 @@ export function CentralCodexSection() {
|
||||
});
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setDisconnecting(false);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -125,7 +126,10 @@ export function EncryptionSection() {
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t("admin.section.encryption.rotateFailed"));
|
||||
showErrorToast(
|
||||
err.message || t("admin.section.encryption.rotateFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -111,10 +112,11 @@ export function GlitchtipSection() {
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminSettings(),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Ban, Check, Copy, Loader2, Plus, Ticket, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -221,8 +222,8 @@ export function InviteTokensSection() {
|
||||
toast.success(t("admin.invites.createdToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("admin.invites.createError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("admin.invites.createError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -234,8 +235,8 @@ export function InviteTokensSection() {
|
||||
toast.success(t("admin.invites.revokedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("admin.invites.revokeError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("admin.invites.revokeError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -245,8 +246,8 @@ export function InviteTokensSection() {
|
||||
setCopied(true);
|
||||
toast.success(t("admin.invites.copiedToast"));
|
||||
window.setTimeout(() => setCopied(false), 2_000);
|
||||
} catch {
|
||||
toast.error(t("admin.invites.copyError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("admin.invites.copyError"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -106,11 +107,13 @@ export function KeyBackupCard() {
|
||||
},
|
||||
onError: async (err: Error) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.error(t("admin.keyBackup.stale"));
|
||||
showErrorToast(t("admin.keyBackup.stale"), { cause: err });
|
||||
await invalidate();
|
||||
return;
|
||||
}
|
||||
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
|
||||
showErrorToast(err.message || t("admin.keyBackup.confirmFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,7 +136,9 @@ export function KeyBackupCard() {
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setCheckResult(null);
|
||||
toast.error(err.message || t("admin.keyBackup.checkFailed"));
|
||||
showErrorToast(err.message || t("admin.keyBackup.checkFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
@@ -112,8 +113,8 @@ export function LoginOverviewSection() {
|
||||
window.setTimeout(() => {
|
||||
setCopiedIp((current) => (current === id ? null : current));
|
||||
}, 2_000);
|
||||
} catch {
|
||||
toast.error(t("admin.copyFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("admin.copyFailed"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Loader2, Wrench } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { StatTile } from "@/components/admin/_stat-tile";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -92,7 +93,10 @@ export function MeasurementMaintenanceCard() {
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t("admin.section.maintenance.startFailed"));
|
||||
showErrorToast(
|
||||
err.message || t("admin.section.maintenance.startFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ToggleLeft } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -74,10 +75,11 @@ function useUpdateModuleAvailability() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.modules.saveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
client.invalidateQueries({
|
||||
queryKey: queryKeys.adminModuleAvailability(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -144,7 +145,7 @@ export function RemindersSection() {
|
||||
onSuccess: (data) => {
|
||||
const failures = describeChannelTestFailures(data?.results ?? [], t);
|
||||
if (failures.length > 0) {
|
||||
toast.error(data?.message ?? t("admin.notificationTestFailed"), {
|
||||
showErrorToast(data?.message ?? t("admin.notificationTestFailed"), {
|
||||
description: (
|
||||
<ul className="space-y-0.5">
|
||||
{failures.map((line) => (
|
||||
@@ -158,10 +159,11 @@ export function RemindersSection() {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.notificationTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -196,8 +198,9 @@ export function RemindersSection() {
|
||||
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Copy, Download, LifeBuoy, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface SupportReport {
|
||||
generatedAt: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin, System status: the redacted support report, with copy and download.
|
||||
*
|
||||
* The report is made on request, not on page load: it reads every account's
|
||||
* connection health, which is work an operator only wants when filing a bug.
|
||||
* Once made it is shown in full above the actions, so the operator reads what
|
||||
* they are about to share.
|
||||
*/
|
||||
export function SupportReportCard() {
|
||||
const { t } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const report = useQuery({
|
||||
queryKey: queryKeys.adminSupportReport(),
|
||||
queryFn: async () => apiGet<SupportReport>("/api/admin/support-report"),
|
||||
enabled: false,
|
||||
staleTime: 0,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
async function load(): Promise<SupportReport | null> {
|
||||
const result = await report.refetch();
|
||||
if (result.error || !result.data) {
|
||||
showErrorToast(t("admin.supportReport.loadError"), {
|
||||
cause: result.error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
const data = await load();
|
||||
if (!data) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
window.prompt(t("admin.supportReport.copy"), data.text);
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const data = await load();
|
||||
if (!data) return;
|
||||
const blob = new Blob([data.text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `healthlog-support-report-${data.generatedAt.slice(0, 10)}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
const busy = report.isFetching;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={LifeBuoy}
|
||||
title={t("admin.supportReport.title")}
|
||||
description={t("admin.supportReport.description")}
|
||||
/>
|
||||
<p className="text-sm">{t("admin.supportReport.body")}</p>
|
||||
{report.data && (
|
||||
<pre
|
||||
data-slot="support-report-text"
|
||||
className="bg-muted/50 max-h-80 overflow-auto rounded-lg border p-3 font-mono text-xs whitespace-pre-wrap"
|
||||
>
|
||||
{report.data.text}
|
||||
</pre>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => void copy()}
|
||||
disabled={busy}
|
||||
aria-live="polite"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{copied
|
||||
? t("admin.supportReport.copied")
|
||||
: t("admin.supportReport.copy")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => void download()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Download className="size-4" aria-hidden="true" />
|
||||
{t("admin.supportReport.download")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -50,10 +51,11 @@ export function UmamiSection() {
|
||||
toast.success(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
{ cause: error },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -99,10 +100,11 @@ export function UserManagementSection() {
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -144,10 +146,11 @@ export function UserManagementSection() {
|
||||
setLogoutTarget(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.forceLogoutFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { BellRing, KeyRound, Loader2 } from "lucide-react";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -96,7 +97,7 @@ export function WebPushVapidSection() {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(t("admin.webPushVapidGenerateFailed"));
|
||||
showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: res });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,8 +114,8 @@ export function WebPushVapidSection() {
|
||||
queryKey: queryKeys.adminSettings(),
|
||||
});
|
||||
toast.success(t("admin.webPushVapidGenerated"));
|
||||
} catch {
|
||||
toast.error(t("admin.webPushVapidGenerateFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: failure });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { Gauge, ListOrdered, Pencil, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -162,7 +163,8 @@ export function CustomMetricDetail({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() });
|
||||
router.push("/measurements");
|
||||
},
|
||||
onError: () => toast.error(t("customMetrics.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("customMetrics.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const description = metric?.description?.trim()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton, SortableHead } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -96,8 +97,8 @@ export function CustomMetricHistoryList({
|
||||
});
|
||||
invalidate();
|
||||
toast.success(t("customMetrics.entry.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("customMetrics.entry.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("customMetrics.entry.restoreError"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +114,8 @@ export function CustomMetricHistoryList({
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t("customMetrics.entry.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("customMetrics.entry.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
@@ -233,7 +233,8 @@ export function useDeleteCustomSymptom() {
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
// v1.16.4 — a failed delete used to fail silently (the sheet clears the
|
||||
// chip optimistically), so surface the rejection.
|
||||
onError: () => toast.error(t("cycle.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.deleteError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,7 +356,8 @@ export function useStartPeriod() {
|
||||
},
|
||||
// v1.16.4 — period boundaries had no failure signal at all (the sheet
|
||||
// simply stayed open); a toast names the rejection.
|
||||
onError: () => toast.error(t("cycle.saveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.saveError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,7 +377,8 @@ export function useEndPeriod() {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.periodEndSaved"));
|
||||
},
|
||||
onError: () => toast.error(t("cycle.saveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.saveError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,7 +394,8 @@ export function useDeleteDayLog() {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.deleteSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("cycle.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("cycle.deleteError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -567,11 +568,11 @@ export function DocumentDetailSheet({
|
||||
? error.meta.reason
|
||||
: "";
|
||||
if (reason === "purged") {
|
||||
toast.error(t("documents.error.purged"));
|
||||
showErrorToast(t("documents.error.purged"), { cause: error });
|
||||
} else if (reason === "duplicateExists") {
|
||||
toast.error(t("documents.error.duplicateExists"));
|
||||
showErrorToast(t("documents.error.duplicateExists"), { cause: error });
|
||||
} else {
|
||||
toast.error(t("documents.toast.restoreFailed"));
|
||||
showErrorToast(t("documents.toast.restoreFailed"), { cause: error });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -681,16 +682,17 @@ export function DocumentDetailSheet({
|
||||
onSuccess: (result) => {
|
||||
if ("summary" in result) {
|
||||
if (result.persistence === "withheld") {
|
||||
toast.error(result.summary);
|
||||
showErrorToast(result.summary);
|
||||
} else if (result.persistence === "failed") {
|
||||
toast.error(t("documents.detail.saveError"));
|
||||
showErrorToast(t("documents.detail.saveError"));
|
||||
}
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocument(documentId),
|
||||
});
|
||||
},
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
onError: (error) =>
|
||||
showErrorToast(aiErrorText(error), { cause: error }),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -709,7 +711,8 @@ export function DocumentDetailSheet({
|
||||
? tCount("documents.ai.readStaged", result.labFactsStaged)
|
||||
: t("documents.ai.readDone"),
|
||||
),
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
onError: (error) =>
|
||||
showErrorToast(aiErrorText(error), { cause: error }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { useState } from "react";
|
||||
import { FlaskConical, Loader2, ScanSearch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -635,7 +636,8 @@ export function DocumentFactsSection({
|
||||
toast.info(t("documents.review.extractEmpty"));
|
||||
}
|
||||
},
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
onError: (error) =>
|
||||
showErrorToast(aiErrorText(error), { cause: error }),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useDocumentDate } from "@/components/documents/use-document-date";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -92,10 +92,11 @@ export function DocumentLinkPicker({
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
if (data.results.some((r) => !r.ok)) {
|
||||
toast.error(t("documents.bulk.failed"));
|
||||
showErrorToast(t("documents.bulk.failed"));
|
||||
}
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.bulk.failed"), { cause: failure }),
|
||||
onSettled: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -488,7 +489,10 @@ export function DocumentsView() {
|
||||
})
|
||||
: t("documents.contentIndex.indexAllNothing"),
|
||||
),
|
||||
onError: () => toast.error(t("documents.contentIndex.indexAllError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.contentIndex.indexAllError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
});
|
||||
}, [reindexAll, t]);
|
||||
|
||||
@@ -665,7 +669,7 @@ export function DocumentsView() {
|
||||
(results: DocumentBulkResultDto[], successMessage: string) => {
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("documents.bulk.partialFailure", {
|
||||
failed,
|
||||
total: results.length,
|
||||
@@ -700,7 +704,8 @@ export function DocumentsView() {
|
||||
);
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.bulk.failed"), { cause: failure }),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -719,7 +724,10 @@ export function DocumentsView() {
|
||||
count: results.filter((r) => r.ok).length,
|
||||
}),
|
||||
),
|
||||
onError: () => toast.error(t("documents.toast.restoreFailed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.toast.restoreFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -735,7 +743,7 @@ export function DocumentsView() {
|
||||
const okIds = results.filter((r) => r.ok).map((r) => r.id);
|
||||
const failed = results.length - okIds.length;
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("documents.bulk.partialFailure", {
|
||||
failed,
|
||||
total: results.length,
|
||||
@@ -756,7 +764,8 @@ export function DocumentsView() {
|
||||
}
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("documents.bulk.failed"), { cause: failure }),
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -794,7 +803,7 @@ export function DocumentsView() {
|
||||
t("documents.card.untitled"),
|
||||
);
|
||||
if (resolved.overCap) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -130,7 +130,7 @@ export function EncounterSheet({
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("encounters.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
showErrorToast(message, { cause: err });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,7 +205,10 @@ export function EncounterSheet({
|
||||
onClick={() => {
|
||||
remove.mutate(encounter.id, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: () => toast.error(t("encounters.deleteFailed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("encounters.deleteFailed"), {
|
||||
cause: failure,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, CheckCircle2, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorIdLine } from "@/components/errors/error-id-line";
|
||||
import { errorIdOf } from "@/lib/errors/error-id";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface ErrorDetailsProps {
|
||||
@@ -29,6 +31,9 @@ export function ErrorDetails({
|
||||
}: ErrorDetailsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
// The id the operator finds this failure by: the request id of an API
|
||||
// failure that reached the boundary, else the server render's digest.
|
||||
const errorId = requestId ?? errorIdOf(error, { unexpected: true });
|
||||
|
||||
async function handleCopy() {
|
||||
// Only capture the pathname — some flows carry OAuth codes / state tokens
|
||||
@@ -47,7 +52,7 @@ export function ErrorDetails({
|
||||
digest: error.digest,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
|
||||
requestId: requestId ?? null,
|
||||
requestId: errorId,
|
||||
urlPath,
|
||||
searchParamCount,
|
||||
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
@@ -81,6 +86,7 @@ export function ErrorDetails({
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-md border p-3 font-mono text-xs break-all">
|
||||
{error.message || t("common.unknownError")}
|
||||
</div>
|
||||
{errorId && <ErrorIdLine id={errorId} />}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{reset && (
|
||||
<Button onClick={reset} variant="outline">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { recordErrorId, shortErrorId } from "@/lib/errors/error-id";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "Error ID 3f9a2c1b" with a copy button: the one way the web shows the id of
|
||||
* a failure, in toasts (`showErrorToast`) and in error boundaries. The first 8
|
||||
* characters are shown; the button copies the whole id, which the operator's
|
||||
* log search matches as well as the short one.
|
||||
*
|
||||
* Showing an id also records it for the session's support info.
|
||||
*/
|
||||
export function ErrorIdLine({
|
||||
id,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
recordErrorId(id);
|
||||
}, [id]);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
window.prompt(t("common.errorIdCopy"), id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="error-id"
|
||||
className={cn("inline-flex items-center gap-1.5 text-xs", className)}
|
||||
>
|
||||
<span className="text-muted-foreground">{t("common.errorIdLabel")}</span>
|
||||
<code className="text-foreground font-mono">{shortErrorId(id)}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-8"
|
||||
onClick={(event) => {
|
||||
// Inside a toast, a click must not count as a dismiss swipe.
|
||||
event.stopPropagation();
|
||||
void copy();
|
||||
}}
|
||||
aria-label={
|
||||
copied ? t("common.errorIdCopied") : t("common.errorIdCopy")
|
||||
}
|
||||
title={copied ? t("common.errorIdCopied") : t("common.errorIdCopy")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useState } from "react";
|
||||
import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -69,7 +70,8 @@ export function EpisodeMenu({
|
||||
label: t("common.undo"),
|
||||
onClick: () => {
|
||||
restore.mutate(episode.id, {
|
||||
onError: () => toast.error(t("illness.restoreError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("illness.restoreError"), { cause: failure }),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Paperclip, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -335,8 +335,10 @@ export function CoachConversation({
|
||||
onSuccess: (next) => {
|
||||
queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("insights.coach.guided.dismissError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("insights.coach.guided.dismissError"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -451,7 +453,7 @@ export function CoachConversation({
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!invalidToastKey) return;
|
||||
toast.error(t("insights.coach.attach.notFoundError"));
|
||||
showErrorToast(t("insights.coach.attach.notFoundError"));
|
||||
}, [invalidToastKey, t]);
|
||||
|
||||
// The pills row above the composer. On a fresh chat every pill is a staged id;
|
||||
@@ -559,8 +561,10 @@ export function CoachConversation({
|
||||
headers: { "Idempotency-Key": randomId() },
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("insights.coach.attach.uploadError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.coach.attach.uploadError"), {
|
||||
cause: failure,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const parsed = parseUploadResponse(
|
||||
@@ -568,7 +572,9 @@ export function CoachConversation({
|
||||
await response.text(),
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
toast.error(t("insights.coach.attach.uploadError"));
|
||||
showErrorToast(t("insights.coach.attach.uploadError"), {
|
||||
cause: response,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const doc = parsed.document;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -61,8 +61,8 @@ export function CopyTableButton({ grid, caption }: CopyTableButtonProps) {
|
||||
setCopied(false);
|
||||
timer.current = null;
|
||||
}, COPY_CONFIRM_MS);
|
||||
} catch {
|
||||
toast.error(t(COACH_RESULT_UI_KEYS.copyFailed));
|
||||
} catch (failure) {
|
||||
showErrorToast(t(COACH_RESULT_UI_KEYS.copyFailed), { cause: failure });
|
||||
}
|
||||
},
|
||||
[grid, caption, t],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* no line behind.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -83,7 +83,7 @@ export function CoachMemoryNote({
|
||||
const facts = useCoachFacts({ enabled: saved && !liveFact && !undone });
|
||||
const forget = useForgetCoachFact({
|
||||
onSuccess: () => setUndone(true),
|
||||
onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
|
||||
onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")),
|
||||
});
|
||||
if (!saved || undone || !note.factId) return null;
|
||||
const fact =
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
BookmarkCheck,
|
||||
BookmarkPlus,
|
||||
@@ -139,8 +140,8 @@ function CopyMessageButton({
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t("insights.coach.copyMessageError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.coach.copyMessageError"), { cause: failure });
|
||||
}
|
||||
}, [content, strip, t]);
|
||||
// Absent where the Clipboard API is (plain-HTTP self-hosts) rather than
|
||||
@@ -309,8 +310,10 @@ function RememberMessageButton({ messageId }: { messageId: string }) {
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("insights.coach.rememberMessage.failed"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("insights.coach.rememberMessage.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { BellPlus, Check, Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -60,8 +60,8 @@ export function ReminderSuggestionCard({
|
||||
: "dismissed",
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("coach.reminderSuggestion.failed"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("coach.reminderSuggestion.failed"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, Loader2, Target } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -98,7 +98,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
|
||||
onSettled: () => setPending(null),
|
||||
// v1.16.4 — a failed save used to revert the switch silently once the
|
||||
// spinner cleared; a toast names the rejection.
|
||||
onError: () => toast.error(t("insights.coach.prefsSaveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("insights.coach.prefsSaveError"), { cause: failure }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Check, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -53,7 +53,8 @@ export function SuggestedActionCard({
|
||||
}
|
||||
setSettled("applied");
|
||||
},
|
||||
onError: () => toast.error(t("coach.suggestedAction.failed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("coach.suggestedAction.failed"), { cause: failure }),
|
||||
});
|
||||
|
||||
if (settled) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import type {
|
||||
CoachActivity,
|
||||
@@ -521,7 +521,7 @@ export function useDeleteCoachConversationWithUndo() {
|
||||
const failedMessage = t("insights.coach.historyDeleteFailed");
|
||||
commitRef.current = (id) =>
|
||||
commitCoachConversationDelete(queryClient, id, owner, () =>
|
||||
toast.error(failedMessage),
|
||||
showErrorToast(failedMessage),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -596,7 +596,7 @@ export function useRenameCoachConversation() {
|
||||
}),
|
||||
onError: (_error, _input, snapshot) => {
|
||||
if (snapshot) restoreCoachConversationRename(queryClient, snapshot);
|
||||
toast.error(t("insights.coach.rename.error"));
|
||||
showErrorToast(t("insights.coach.rename.error"), { cause: _error });
|
||||
},
|
||||
onSettled: (_data, _error, input) => {
|
||||
void invalidateCoachConversationRename(queryClient, input.id);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -195,7 +196,7 @@ export function InsightsEditMode({
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("insights.editMode.saveError"));
|
||||
showErrorToast(t("insights.editMode.saveError"), { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,7 +213,8 @@ export function InsightsEditMode({
|
||||
setDraftState(seedArrangeDraft(saved.sections));
|
||||
toast.success(t("insights.editMode.resetSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("insights.editMode.saveError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("insights.editMode.saveError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const busy = saveMutation.isPending || resetMutation.isPending;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
@@ -724,7 +725,7 @@ function InsightsTabStripImpl({
|
||||
const decision = regenerateToastKind(regenerateOutcome);
|
||||
if (decision) {
|
||||
if (decision.kind === "error") {
|
||||
toast.error(t(decision.messageKey));
|
||||
showErrorToast(t(decision.messageKey));
|
||||
} else {
|
||||
toast.success(t(decision.messageKey));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { QueryClientContext, type QueryClient } from "@tanstack/react-query";
|
||||
import { EyeOff, RotateCcw, X } from "lucide-react";
|
||||
import { useContext, useReducer, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
@@ -138,8 +138,8 @@ export function PatternDismissButton({
|
||||
{ dismissed: true },
|
||||
);
|
||||
await applyPatternDecisionSuccess(queryClient, onDismissed, onSettled);
|
||||
} catch {
|
||||
toast.error(t("insights.pattern.dismissError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.pattern.dismissError"), { cause: failure });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -184,8 +184,8 @@ export function PatternDismissedNotice({
|
||||
{ dismissed: false },
|
||||
);
|
||||
await applyPatternDecisionSuccess(queryClient, onRestored, onSettled);
|
||||
} catch {
|
||||
toast.error(t("insights.pattern.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("insights.pattern.restoreError"), { cause: failure });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,7 +62,8 @@ export function BiomarkerManager() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const hiddenMutation = useMutation({
|
||||
@@ -75,7 +77,8 @@ export function BiomarkerManager() {
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.hideError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.biomarker.hideError"), { cause: failure }),
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
@@ -283,7 +284,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
router.push("/labs");
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
// v1.25.1 — the per-marker description leads the page, mirroring the metric
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { DeleteButton, SortableHead } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -156,8 +157,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
await apiPost("/api/labs/restore", { ids: [id] });
|
||||
invalidate();
|
||||
toast.success(t("labs.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("labs.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("labs.restoreError"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +173,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t("labs.deleteError")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.deleteError"), { cause: failure }),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-c
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -233,21 +233,22 @@ export function OcrReviewDialog({
|
||||
onSuccess: (data) => {
|
||||
const seeded = seedReviewRows(data.rows, data.reportDate);
|
||||
if (seeded.length === 0) {
|
||||
toast.error(t("labs.ocr.noRows"));
|
||||
showErrorToast(t("labs.ocr.noRows"));
|
||||
return;
|
||||
}
|
||||
setRows(seeded);
|
||||
setPageCoverage(data.pageCoverage ?? null);
|
||||
setStage("review");
|
||||
},
|
||||
onError: (err) => toast.error(extractErrorMessage(err, t)),
|
||||
onError: (err) =>
|
||||
showErrorToast(extractErrorMessage(err, t), { cause: err }),
|
||||
});
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
const plan = planSave(rows);
|
||||
if (plan.kind === "nothing-selected") {
|
||||
toast.error(t("labs.ocr.nothingToSave"));
|
||||
showErrorToast(t("labs.ocr.nothingToSave"));
|
||||
return;
|
||||
}
|
||||
if (plan.kind === "blocked") {
|
||||
@@ -296,7 +297,8 @@ export function OcrReviewDialog({
|
||||
onCommitted();
|
||||
handleClose(false);
|
||||
},
|
||||
onError: () => toast.error(t("labs.ocr.saveFailed")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("labs.ocr.saveFailed"), { cause: failure }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { DayLink, DayLinkAt } from "@/components/day/day-link";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -687,8 +688,8 @@ export function MeasurementList({
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("measurements.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("measurements.restoreError"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("measurements.restoreError"), { cause: failure });
|
||||
}
|
||||
},
|
||||
[queryClient, t],
|
||||
@@ -711,8 +712,8 @@ export function MeasurementList({
|
||||
// v1.16.4 — a failed row delete used to fail silently (no onError
|
||||
// handler); the edit-dialog path surfaced its own inline error but
|
||||
// the row-level confirm had no feedback at all.
|
||||
onError: () => {
|
||||
toast.error(t("measurements.deleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("measurements.deleteError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -744,8 +745,8 @@ export function MeasurementList({
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("measurements.bulkDeleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("measurements.bulkDeleteError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Activity, TrendingUp } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
|
||||
@@ -434,12 +434,14 @@ function RetargetControl({
|
||||
await apiPut(`/api/medications/${medicationId}/efficacy/target`, body);
|
||||
onChanged();
|
||||
if (clear) setValue("");
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
// v1.30 UI audit (M9) — the save previously failed silently: the
|
||||
// buttons re-enabled with no toast, no inline alert, and the
|
||||
// rejection escaped as an unhandled promise. The user could not
|
||||
// tell the override was not saved.
|
||||
toast.error(t("medications.efficacy.retarget.error"));
|
||||
showErrorToast(t("medications.efficacy.retarget.error"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -202,8 +203,11 @@ export function LedgerAddDialog({
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
setNudgeOpen(false);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medicationName }));
|
||||
} catch (failure) {
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medicationName }),
|
||||
{ cause: failure },
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -307,10 +308,11 @@ export function DoseHistoryLedger({
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
if (prev) queryClient.setQueryData(queryKey, prev);
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medicationName }),
|
||||
{ cause: failure },
|
||||
);
|
||||
} finally {
|
||||
setMarking(null);
|
||||
@@ -359,8 +361,10 @@ export function DoseHistoryLedger({
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.edit.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
},
|
||||
[medicationId, queryClient, queryKey, t],
|
||||
@@ -393,8 +397,10 @@ export function DoseHistoryLedger({
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.edit.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
},
|
||||
[medicationId, queryClient, queryKey, t],
|
||||
@@ -412,8 +418,10 @@ export function DoseHistoryLedger({
|
||||
// reads (Today hero + dashboard) are unmounted behind the detail
|
||||
// page, so force their inactive refetch too (v1.32.19).
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.deleteRow.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/components/medications/course-fields";
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
@@ -321,7 +321,9 @@ export function Glp1MedicationCard({
|
||||
injectionSite: site,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(t("medications.logInjectionSiteSaveFailed"));
|
||||
showErrorToast(t("medications.logInjectionSiteSaveFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -135,8 +136,10 @@ function IntakeEditDialogBody({
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.intake.edit.savedToast"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.edit.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.edit.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -102,8 +103,10 @@ export function IntakeHistoryEditable({
|
||||
toast.success(t("medications.detail.intake.bulkDelete.toast"));
|
||||
setBulkConfirmOpen(false);
|
||||
clearSelection();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.bulkDelete.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.bulkDelete.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
@@ -126,8 +129,10 @@ export function IntakeHistoryEditable({
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("medications.detail.intake.deleteRow.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setRowDeleteBusy(false);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -195,7 +195,7 @@ export function LogIntakeDialog({
|
||||
if (slot !== NO_SLOT) {
|
||||
const iso = slotInstantIso(takenAt, slot);
|
||||
if (!iso) {
|
||||
toast.error(t("medications.logIntake.slotError"));
|
||||
showErrorToast(t("medications.logIntake.slotError"));
|
||||
return;
|
||||
}
|
||||
scheduledFor = iso;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/components/medications/course-fields";
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
@@ -243,7 +243,9 @@ export function MedicationCard({
|
||||
injectionSite: site,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(t("medications.logInjectionSiteSaveFailed"));
|
||||
showErrorToast(t("medications.logInjectionSiteSaveFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -55,8 +56,10 @@ function CategoryRow({
|
||||
try {
|
||||
await update.mutateAsync({ key: category.key, label: trimmed });
|
||||
setEditing(false);
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.category.custom.saveFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +69,10 @@ function CategoryRow({
|
||||
key: category.key,
|
||||
isActive: !category.isActive,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.category.custom.saveFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +226,10 @@ export function MedicationCategoriesSheet({
|
||||
count: String(result.movedCount),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.category.custom.saveFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -143,8 +144,10 @@ export function ScheduleHistoryTimeline({
|
||||
queryKey: queryKeys.medicationDetail(medicationId),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("medications.detail.zeitplan.history.deleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("medications.detail.zeitplan.history.deleteError"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -468,11 +471,12 @@ function EraDialog({
|
||||
reset();
|
||||
onSaved();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
onError: (failure) => {
|
||||
showErrorToast(
|
||||
target
|
||||
? t("medications.detail.zeitplan.history.updateError")
|
||||
: t("medications.detail.zeitplan.history.createError"),
|
||||
{ cause: failure },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -170,8 +171,10 @@ export function ScheduleTimesEditor({
|
||||
await apiPut(`/api/medications/${medicationId}`, body);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.zeitplan.saved"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zeitplan.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zeitplan.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { ChevronDown, ChevronUp, Copy, KeyRound } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -142,8 +143,10 @@ export function ApiTokensRow({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(label);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.api.copyFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.api.copyFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +181,11 @@ export function ApiTokensRow({
|
||||
: (status?.activeTokenCount ?? 0) + 1,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("medications.detail.api.mintFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -55,9 +56,11 @@ export function AutoMarkTakenBody({
|
||||
? t("medications.autoMarkTaken.enabledToast")
|
||||
: t("medications.autoMarkTaken.disabledToast"),
|
||||
);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setLocalOn(previous);
|
||||
toast.error(t("medications.autoMarkTaken.toggleFailed"));
|
||||
showErrorToast(t("medications.autoMarkTaken.toggleFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -222,8 +222,8 @@ export function CoursesRow({
|
||||
await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
setDeleting(null);
|
||||
} catch {
|
||||
toast.error(t("medications.course.error.generic"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.course.error.generic"), { cause: failure });
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Archive, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -92,9 +93,11 @@ export function LifecycleManageBody({
|
||||
: t("medications.detail.zone.pause.resumedToast"),
|
||||
);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setPaused(previous);
|
||||
toast.error(t("medications.detail.zone.pause.failed"));
|
||||
showErrorToast(t("medications.detail.zone.pause.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setTier1Busy(false);
|
||||
}
|
||||
@@ -110,8 +113,10 @@ export function LifecycleManageBody({
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.end.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.end.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setTier2Busy(false);
|
||||
}
|
||||
@@ -246,8 +251,10 @@ export function DangerZoneBody({
|
||||
toast.success(t("medications.detail.zone.purge.toast"));
|
||||
setPurgeDialogOpen(false);
|
||||
onAfterAction?.();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.purge.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.purge.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setTier3aBusy(false);
|
||||
}
|
||||
@@ -278,8 +285,10 @@ export function DangerZoneBody({
|
||||
// analytics / compliance / inline chart). Not awaited: navigation
|
||||
// already happened and a transient refetch must not block it.
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} catch {
|
||||
toast.error(t("medications.detail.zone.delete.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.zone.delete.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
setTier3bBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -55,9 +56,11 @@ export function IntakeTrackingBody({
|
||||
? t("medications.trackIntake.enabledToast")
|
||||
: t("medications.trackIntake.disabledToast"),
|
||||
);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setLocalTracked(previous);
|
||||
toast.error(t("medications.trackIntake.toggleFailed"));
|
||||
showErrorToast(t("medications.trackIntake.toggleFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -145,8 +146,10 @@ export function AddInventoryDialog({
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.addSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.addFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.addFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -399,8 +402,10 @@ export function AdjustInventoryDialog({
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.adjustSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.adjustFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.adjustFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -573,8 +578,10 @@ export function PackagingDialog({
|
||||
]);
|
||||
toast.success(t("medications.detail.bestand.packagingSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.packagingFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.packagingFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -129,8 +130,10 @@ export function InventorySection({
|
||||
await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`);
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.deleteSuccess"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.deleteFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.bestand.deleteFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -137,9 +138,11 @@ export function NotificationsBody({
|
||||
? t("medications.detail.notifications.enabledToast")
|
||||
: t("medications.detail.notifications.disabledToast"),
|
||||
);
|
||||
} catch {
|
||||
} catch (failure) {
|
||||
setLocalEnabled(previous);
|
||||
toast.error(t("medications.detail.notifications.toggleFailed"));
|
||||
showErrorToast(t("medications.detail.notifications.toggleFailed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -227,10 +228,11 @@ function PhaseConfigForm({
|
||||
toast.success(t("medications.phaseSaved"));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
showErrorToast(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("medications.detail.phases.saveFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -124,8 +125,10 @@ export function GraceRow({
|
||||
await apiPut(`/api/medications/${medicationId}`, body);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.settings.grace.saved"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.settings.grace.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.settings.grace.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setGraceBusy(false);
|
||||
}
|
||||
@@ -207,8 +210,10 @@ export function DrugCodingRow({
|
||||
});
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.settings.codes.saved"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.settings.codes.failed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.detail.settings.codes.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -215,8 +215,10 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
},
|
||||
// v1.16.4 — a failed delete used to fail silently, leaving the row
|
||||
// in place with no signal that the request was rejected.
|
||||
onError: () => {
|
||||
toast.error(t("medications.sideEffects.deleteError"));
|
||||
onError: (failure) => {
|
||||
showErrorToast(t("medications.sideEffects.deleteError"), {
|
||||
cause: failure,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { intakeToastOptions } from "@/components/medications/use-medication-intake";
|
||||
@@ -286,9 +287,9 @@ export async function runTakeAllDue(deps: {
|
||||
toast.success(message);
|
||||
}
|
||||
} else if (taken > 0) {
|
||||
toast.error(t("medications.takeAllDue.partialToast", { taken, failed }));
|
||||
showErrorToast(t("medications.takeAllDue.partialToast", { taken, failed }));
|
||||
} else {
|
||||
toast.error(t("medications.takeAllDue.failedToast"));
|
||||
showErrorToast(t("medications.takeAllDue.failedToast"));
|
||||
}
|
||||
|
||||
return { taken, failed };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
@@ -203,8 +204,11 @@ export async function runRecordIntake(deps: {
|
||||
);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
onRecorded?.(eventId, skipped);
|
||||
} catch {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
|
||||
} catch (failure) {
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medication.name }),
|
||||
{ cause: failure },
|
||||
);
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
}
|
||||
@@ -300,8 +304,11 @@ export async function runLogIntake(deps: {
|
||||
toast.success(message);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
|
||||
} catch (failure) {
|
||||
showErrorToast(
|
||||
t("medications.intakeToastFailed", { name: medication.name }),
|
||||
{ cause: failure },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -321,8 +328,8 @@ export async function runUndoIntake(deps: {
|
||||
await apiDelete(`/api/medications/${medication.id}/intake/${eventId}`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.intakeUndone"));
|
||||
} catch {
|
||||
toast.error(t("medications.intakeUndoFailed"));
|
||||
} catch (failure) {
|
||||
showErrorToast(t("medications.intakeUndoFailed"), { cause: failure });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,9 +193,11 @@ describe("MoodList — level A on the edit path (v1.37)", () => {
|
||||
|
||||
describe("MoodList — delete failure toast (v1.11.5)", () => {
|
||||
it("surfaces a toast on a rejected delete instead of failing silently", () => {
|
||||
expect(listSrc).toContain('import { toast } from "sonner"');
|
||||
expect(listSrc).toContain(
|
||||
'import { showErrorToast } from "@/lib/errors/error-toast"',
|
||||
);
|
||||
expect(listSrc).toMatch(
|
||||
/deleteMutation = useMutation\(\{[\s\S]*onError: \(\) => \{[\s\S]*toast\.error\(t\("mood\.deleteError"\)\)/,
|
||||
/deleteMutation = useMutation\(\{[\s\S]*onError: \((\w+)\) => \{[\s\S]*showErrorToast\(t\("mood\.deleteError"\), \{ cause: \1 \}\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ArchiveRestore, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -70,9 +71,12 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
|
||||
} catch (err) {
|
||||
rollback();
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
toast.error(t("mood.manage.tagLimitReached"));
|
||||
showErrorToast(t("mood.manage.tagLimitReached"), { cause: err });
|
||||
} else {
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
@@ -89,7 +93,10 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) {
|
||||
toast.success(t("mood.manage.purged"));
|
||||
} catch (err) {
|
||||
rollback();
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -141,9 +142,12 @@ export function TagEditorSheet({
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
toast.error(t("mood.manage.tagLimitReached"));
|
||||
showErrorToast(t("mood.manage.tagLimitReached"), { cause: err });
|
||||
} else {
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
showErrorToast(
|
||||
err instanceof ApiError ? err.message : t("common.error"),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user