From e6848170c833a32edf05b64c2cfc6f003aa72c8e Mon Sep 17 00:00:00 2001 From: Juanfran Date: Tue, 19 May 2026 15:07:02 +0200 Subject: [PATCH] :tada: Show dedicated screen when Nitrate is unavailable --- backend/src/app/http/errors.clj | 9 ++++ backend/src/app/nitrate.clj | 51 ++++++++++++++----- .../assets/logo-nitrate-unavailable.svg | 4 ++ frontend/src/app/main/errors.cljs | 1 + frontend/src/app/main/repo.cljs | 4 ++ .../ui/ds/foundations/assets/raw_svg.cljs | 1 + frontend/src/app/main/ui/static.cljs | 13 +++++ frontend/src/app/main/ui/static.scss | 45 ++++++++++++++++ frontend/translations/en.po | 6 +++ frontend/translations/es.po | 6 +++ 10 files changed, 126 insertions(+), 14 deletions(-) create mode 100644 frontend/resources/images/assets/logo-nitrate-unavailable.svg diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index bcf6df27f6..bf6066c66f 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -144,6 +144,15 @@ {::yres/status 404 ::yres/body (ex-data err)}) +(defmethod handle-error :nitrate-unavailable + [err request _] + (binding [l/*context* (request->context request)] + (l/warn :hint "nitrate is unreachable; blocking request" :cause err) + ;; Do not leak Nitrate's internal URL/status to the client; the + ;; full context is already logged above for operators. + {::yres/status 503 + ::yres/body {:type :nitrate-unavailable}})) + (defmethod handle-error :internal [error request parent-cause] (binding [l/*context* (request->context request)] diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index cd4b78a5f8..1a77e6af2b 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -65,12 +65,26 @@ (cond (nil? status) (do - (l/error :hint "could't do the nitrate request, it is probably down" + (l/error :hint "couldn't do the nitrate request, it is probably down" :uri uri) - ;; TODO decide what to do when Nitrate is inaccesible - nil) + (ex/raise :type :nitrate-unavailable + :hint (str "nitrate is unreachable at " uri))) + + (>= status 500) + ;; Nitrate is up enough to answer (or the proxy is) but the + ;; service itself is failing; treat as unavailable so callers + ;; surface the static error page. + (do + (l/error :hint "nitrate request failed with server error status" + :uri uri + :status status + :body (:body response)) + (ex/raise :type :nitrate-unavailable + :status status + :hint (str "nitrate is unavailable, HTTP " status " at " uri))) + (>= status 400) - ;; For error status codes (4xx, 5xx), fail immediately without validation + ;; For client error status codes (4xx), fail immediately without validation (do (when (not= status 404) ;; Don't need to log 404 (l/error :hint "nitrate request failed with error status" @@ -437,21 +451,27 @@ (defn add-nitrate-licence-to-profile "Enriches a profile map with subscription information from Nitrate. Adds a :subscription field containing the user's license details. - Returns the original profile unchanged if the request fails." + Returns the original profile unchanged if the request fails for a reason + other than Nitrate being unreachable. When Nitrate is unreachable the + `:nitrate-unavailable` exception propagates so the request is rejected." [cfg profile] (try (let [subscription (call cfg :get-subscription {:profile-id (:id profile)})] (assoc profile :subscription subscription)) (catch Throwable cause - (l/error :hint "failed to get nitrate licence" - :profile-id (:id profile) - :cause cause) - profile))) + (if (= :nitrate-unavailable (-> cause ex-data :type)) + (throw cause) + (do + (l/error :hint "failed to get nitrate licence" + :profile-id (:id profile) + :cause cause) + profile))))) (defn add-org-info-to-team "Enriches a team map with organization information from Nitrate. Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. - Returns the original team unchanged if the request fails or org data is nil." + Returns the original team unchanged if the request fails or org data is nil. + Propagates `:nitrate-unavailable` so the request is rejected when Nitrate is unreachable." [cfg team params] (try (let [params (assoc (or params {}) :team-id (:id team)) @@ -464,10 +484,13 @@ (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org))))) team)) (catch Throwable cause - (l/error :hint "failed to get team organization info" - :team-id (:id team) - :cause cause) - team))) + (if (= :nitrate-unavailable (-> cause ex-data :type)) + (throw cause) + (do + (l/error :hint "failed to get team organization info" + :team-id (:id team) + :cause cause) + team))))) (defn set-team-organization "Associates a team with an organization in Nitrate. diff --git a/frontend/resources/images/assets/logo-nitrate-unavailable.svg b/frontend/resources/images/assets/logo-nitrate-unavailable.svg new file mode 100644 index 0000000000..20274a3176 --- /dev/null +++ b/frontend/resources/images/assets/logo-nitrate-unavailable.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 4d75d9677d..4b5a0309f7 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -354,6 +354,7 @@ (derive :not-found ::exceptional-state) (derive :bad-gateway ::exceptional-state) (derive :service-unavailable ::exceptional-state) +(derive :nitrate-unavailable ::exceptional-state) (defmethod ptk/handle-error ::exceptional-state [error] diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 9b5b9872ab..502c5ba655 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -86,6 +86,10 @@ (= 502 status) (rx/throw (ex-info "http error" {:type :bad-gateway})) + (and (= 503 status) + (= :nitrate-unavailable (:type body))) + (rx/throw (ex-info "http error" {:type :nitrate-unavailable})) + (= 503 status) (rx/throw (ex-info "http error" {:type :service-unavailable})) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs index 56f5b076a5..ca716f677b 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs @@ -18,6 +18,7 @@ (def ^:svg-id brand-google "brand-google") (def ^:svg-id loader "loader") (def ^:svg-id logo-error-screen "logo-error-screen") +(def ^:svg-id logo-nitrate-unavailable "logo-nitrate-unavailable") (def ^:svg-id logo-subscription "logo-subscription") (def ^:svg-id logo-subscription-light "logo-subscription-light") (def ^:svg-id nitrate-welcome "nitrate-welcome") diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 49dfd92f20..e6f8d6b605 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -322,6 +322,16 @@ [:div {:class (stl/css :sign-info)} [:button {:on-click on-click} (tr "labels.retry")]]])) +(mf/defc nitrate-unavailable* + [] + [:section {:class (stl/css :nitrate-unavailable-layout)} + [:div {:class (stl/css :nitrate-unavailable-content)} + [:> raw-svg* {:id "logo-nitrate-unavailable" :class (stl/css :nitrate-unavailable-logo)}] + [:p {:class (stl/css :nitrate-unavailable-message)} + (tr "labels.nitrate-unavailable.main-message")]] + [:p {:class (stl/css :nitrate-unavailable-footer)} + (tr "labels.copyright-period")]]) + (mf/defc webgl-context-lost* [] (let [on-reload (mf/use-fn #(js/location.reload))] @@ -493,6 +503,9 @@ :service-unavailable [:> service-unavailable*] + :nitrate-unavailable + [:> nitrate-unavailable*] + [:> internal-error* props]))) (mf/defc context-wrapper* diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index 32c80dce5e..8a989ee6cd 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -13,6 +13,51 @@ background-color: var(--color-background-secondary); } +.nitrate-unavailable-layout { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + background-color: var(--color-background-primary); + padding: var(--sp-xxxl); +} + +.nitrate-unavailable-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-xxxl); +} + +.nitrate-unavailable-logo { + inline-size: 146px; + block-size: 64px; + color: var(--color-foreground-primary); + transform: scale(1, -1); +} + +.nitrate-unavailable-message { + @include t.use-typography("title-large"); + + margin: 0; + max-inline-size: 32rem; + text-align: center; + color: var(--color-foreground-primary); + font-weight: 500; +} + +.nitrate-unavailable-footer { + @include t.use-typography("title-medium"); + + margin: 0; + text-align: center; + color: var(--color-foreground-secondary); +} + .deco-before, .deco-after { position: absolute; diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a43cfbd339..dea6c21399 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2910,6 +2910,12 @@ msgstr "New password" msgid "labels.next" msgstr "Next" +#: src/app/main/ui/static.cljs:325 +msgid "labels.nitrate-unavailable.main-message" +msgstr "" +"Penpot is temporarily unavailable due to a system issue. Please try again " +"shortly." + #: src/app/main/ui/dashboard/comments.cljs:122, src/app/main/ui/workspace/comments.cljs:162 msgid "labels.no-comments-available" msgstr "You're all caught up! New comment notifications will appear here." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f984600778..ba81ae5297 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2819,6 +2819,12 @@ msgstr "Nueva contraseña" msgid "labels.next" msgstr "Siguiente" +#: src/app/main/ui/static.cljs:325 +msgid "labels.nitrate-unavailable.main-message" +msgstr "" +"Penpot no está disponible temporalmente debido a un problema del sistema. " +"Por favor, inténtalo de nuevo en unos momentos." + #: src/app/main/ui/dashboard/comments.cljs:122, src/app/main/ui/workspace/comments.cljs:162 msgid "labels.no-comments-available" msgstr "¡Ya estás al día! Nuevas notificaciones de comentarios aparecerán aquí."