diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 0ca870db09..f99bdb93cf 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -8,6 +8,7 @@ (:require [app.common.time :as ct] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.event :as ev] [app.main.data.exports.wasm :as wasm.exports] [app.main.data.helpers :as dsh] @@ -141,35 +142,57 @@ :name page-name})))))) (defn- initialize-export-status - [exports cmd resource] + "`job` is only present on the job API path; without it the widget counts the + exports the client submitted, exactly as it always has." + [exports cmd resource {:keys [job-id total status backend] :as job}] (ptk/reify ::initialize-export-status ptk/UpdateEvent (update [_ state] - (assoc state :export {:in-progress true - :resource-id (:id resource) - :healthy? true - :error false - :progress 0 - :widget-visible true - :detail-visible true - :exports exports - :last-update (ct/now) - :cmd cmd})))) + (assoc state :export (cond-> {:in-progress true + :resource-id (:id resource) + :healthy? true + :error false + :progress 0 + :widget-visible true + :detail-visible true + :exports exports + :last-update (ct/now) + :cmd cmd} + (some? job) + (assoc :job-id job-id + :total total + :status status + :backend backend)))))) (defn- update-export-status - [{:keys [done status resource-uri filename mtype] :as data}] + [{:keys [done total status resource-uri filename mtype] :as data}] (ptk/reify ::update-export-status ptk/UpdateEvent (update [_ state] (let [time-diff (ct/diff-ms (get-in state [:export :last-update]) (ct/now)) - healthy? (< time-diff 6000)] + healthy? (< time-diff 6000) + ;; The legacy path has no server-side figures to track; it keeps + ;; reporting progress over the client's own list. + job? (some? (get-in state [:export :job-id]))] (cond-> state + job? + (update :export assoc :status status) + + (and job? (some? total)) + (update :export assoc :total total) + (= status "running") (update :export assoc :progress done :last-update (ct/now) :healthy? healthy?) (= status "error") (update :export assoc :in-progress false :error (:cause data) :last-update (ct/now) :healthy? healthy?) + (= status "cancelling") + (update :export assoc :last-update (ct/now) :healthy? healthy?) + + (= status "cancelled") + (update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?) + (= status "ended") (update :export assoc :in-progress false :last-update (ct/now) :healthy? healthy?)))) @@ -178,17 +201,78 @@ (when (= status "ended") (dom/trigger-download-uri filename mtype resource-uri))))) +;; The exporter is at capacity. Not a crash: the widget says so and the user +;; retries, instead of the generic error dialog. +(def ^:private saturation-codes #{:queue-full}) + +(defn- export-failed + "Reports a failure that happened before the export ever started, so the widget + settles instead of waiting for progress that will never arrive." + [exports cmd cause] + (ptk/reify ::export-failed + ptk/UpdateEvent + (update [_ state] + (assoc state :export {:in-progress false + :widget-visible true + :detail-visible true + :healthy? true + :progress 0 + :total (count exports) + :exports exports + :cmd cmd + :error (or (ex-message cause) true) + :error-code (:code (ex-data cause)) + :last-update (ct/now)})))) + +(defn cancel-export + "Stops the running export. Only reachable on the job API path, where the + exporter can actually abort the work. + + The widget settles from here rather than from the job's `cancelled` message: + the outcome is known once the request returns, and waiting on a round trip + through redis and the websocket would leave it stuck whenever that message is + missed." + [] + (ptk/reify ::cancel-export + ptk/WatchEvent + (watch [_ state _] + (when-let [job-id (get-in state [:export :job-id])] + (let [resource-id (get-in state [:export :resource-id]) + settle (rx/concat + (rx/of (update-export-status {:status "cancelled"})) + (->> (rx/of (clear-export-state resource-id)) + (rx/delay default-timeout)))] + (rx/concat + ;; Stopping is not instantaneous: the request has to reach the + ;; exporter and the work has to unwind. + (rx/of (update-export-status {:status "cancelling"})) + (->> (rp/cmd! :cancel-export-job {:job-id job-id}) + (rx/mapcat (fn [_] settle)) + ;; Already finished, or the exporter is gone; either way + ;; there is nothing left to stop. + (rx/catch (fn [_] settle))))))))) + ;; TODO: Remove once we support WASM SVG export (def ^:private wasm-export-types #{:jpeg :webp :png :pdf}) (defn- wasm-export-enabled? "WASM export is available when the `wasm-export/v1` feature is active AND render-wasm is active for the current file. When render-wasm is inactive its - shape tree isn't loaded, so a client-side WASM render would crash." + shape tree isn't loaded, so a client-side WASM render would crash. + + This governs the client-side render only; it says nothing about the exporter." [state] (and (features/active-feature? state "wasm-export/v1") (features/active-feature? state "render-wasm/v1"))) +(defn- wasm-export-available? + "Whether the *exporter* renders with render-wasm. Its `enable-wasm-export` + flag has to be on too, otherwise the browser backend does the work and the + job API would promise capabilities the server does not have." + [state] + (and (wasm-export-enabled? state) + (contains? cf/flags :wasm-export))) + (defn- use-wasm-export? "Whether to take the client-side WASM export path for `export`." [state export] @@ -223,7 +307,7 @@ :profile-id profile-id :cmd :export-shapes :wait true - :is-wasm (wasm-export-enabled? state)})] + :is-wasm (wasm-export-available? state)})] (rx/concat (dwp/force-persist-and-wait 400) @@ -252,7 +336,7 @@ :cmd cmd :profile-id profile-id :force-multiple true - :is-wasm (wasm-export-enabled? state)} + :is-wasm (wasm-export-available? state)} (some? name) (assoc :name name)) @@ -266,7 +350,8 @@ stopper (rx/filter #(or (= "ended" (:status %)) - (= "error" (:status %))) + (= "error" (:status %)) + (= "cancelled" (:status %))) progress-stream)] (swap! st/ongoing-tasks conj :export) @@ -276,11 +361,30 @@ (rx/of ::dwp/force-persist) ;; Launch the exportation process and stores the resource id - ;; locally. - (->> (rp/cmd! :export params) - (rx/map (fn [{:keys [id] :as resource}] - (vreset! resource-id id) - (initialize-export-status exports cmd resource)))) + ;; locally. With wasm export active the job API is used instead: it + ;; answers with the exporter's own object count and gives a handle + ;; to cancel. + (->> (if (wasm-export-available? state) + (->> (rp/cmd! :create-export-job params) + (rx/map (fn [{job-id :id :keys [total] :as job}] + (vreset! resource-id (:resource-id job)) + (initialize-export-status exports cmd + {:id (:resource-id job)} + {:job-id job-id + :total total + :status (:state job) + :backend (:backend job)})))) + (->> (rp/cmd! :export params) + (rx/map (fn [{:keys [id] :as resource}] + (vreset! resource-id id) + (initialize-export-status exports cmd resource nil))))) + (rx/catch (fn [cause] + ;; Saturation is an answer, not a fault. + (if (contains? saturation-codes (:code (ex-data cause))) + (rx/of (export-failed exports cmd cause)) + (rx/concat + (rx/of (export-failed exports cmd cause)) + (rx/throw cause)))))) ;; We proceed to update the export state with incoming ;; progress updates. We delay the stopper for give some time @@ -297,7 +401,8 @@ ;; for ensure that after some security time, the stream is ;; completely closed. (->> progress-stream - (rx/filter #(= "ended" (:status %))) + (rx/filter #(or (= "ended" (:status %)) + (= "cancelled" (:status %)))) (rx/take 1) (rx/delay default-timeout) (rx/map #(clear-export-state @resource-id)) @@ -316,7 +421,7 @@ (watch [_ state _] (let [params (select-keys (:export state) [:exports :cmd])] (when (seq params) - (rx/of (request-multiple-export params))))))) + (rx/of (request-export params))))))) (defn export-shapes-event [exports origin] diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index 722c9a882a..68749a7f45 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -276,6 +276,28 @@ (let [default {:wait false :blob? false}] (send-export (merge default params)))) +(defmethod cmd! :create-export-job + [_ params] + (->> (http/send! {:method :post + :uri (u/join cf/public-uri "api/export/jobs") + :body (http/transit-data params) + :headers {"x-external-session-id" (cf/external-session-id) + "x-event-origin" (::ev/origin (meta params))} + :credentials "include" + :response-type :text}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + +(defmethod cmd! :cancel-export-job + [_ {:keys [job-id]}] + (->> (http/send! {:method :delete + :uri (u/join cf/public-uri "api/export/jobs/" (str job-id)) + :headers {"x-external-session-id" (cf/external-session-id)} + :credentials "include" + :response-type :text}) + (rx/map http/conditional-decode-transit) + (rx/mapcat handle-response))) + (defn- multipart-upload [id params] (->> (http/send! {:method :post diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index 052910c404..f1be8e32a2 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -218,11 +218,25 @@ theme (or (:theme profile) theme/default) is-default-theme? (= theme/default theme) error? (:error state) + ;; The exporter is at capacity: worth its own wording, so the user + ;; knows retrying later is the thing to do. + busy? (= :queue-full (:error-code state)) healthy? (:healthy? state) detail-visible? (:detail-visible state) widget-visible? (:widget-visible state) progress (:progress state) items (:exports state) + job-id (:job-id state) + status (:status state) + queued? (and (some? job-id) (= "queued" status)) + cancelling? (and (some? job-id) (= "cancelling" status)) + cancelled? (and (some? job-id) (= "cancelled" status)) + ;; Only the wasm backend can actually stop: a browser render holds its + ;; pool slot until playwright gives up. + cancellable? (and (some? job-id) + (= "wasm" (:backend state)) + (:in-progress state) + (not cancelling?)) total (or (:total state) (count items)) complete? (= progress total) circ (* 2 Math/PI 12) @@ -236,6 +250,8 @@ color (cond error? clr/new-danger + (or cancelling? + cancelled?) clr/new-warning healthy? (if is-default-theme? clr/new-primary clr/new-primary-light) @@ -248,11 +264,20 @@ title (cond + busy? (tr "workspace.options.exporting-busy") error? (tr "workspace.options.exporting-object-error") + cancelling? (tr "workspace.options.exporting-cancelling") + cancelled? (tr "workspace.options.exporting-cancelled") + queued? (tr "workspace.options.exporting-queued") complete? (tr "workspace.options.exporting-complete") healthy? (tr "workspace.options.exporting-object") (not healthy?) (tr "workspace.options.exporting-object-slow")) + cancel-export + (mf/use-fn + (fn [] + (st/emit! (de/cancel-export)))) + retry-last-operation (mf/use-fn (fn [] @@ -294,11 +319,25 @@ [:div {:class (stl/css :export-progress-title)} [:div {:class (stl/css :title-text)} title] - (if error? + (cond + error? [:button {:class (stl/css :retry-btn) :on-click retry-last-operation} (tr "workspace.options.retry")] + cancellable? + [:* + [:button {:class (stl/css :retry-btn) + :on-click cancel-export} + (tr "workspace.options.cancel-export")] + [:span {:class (stl/css :progress)} + (dm/str progress " / " total)]] + + ;; A counter for work that is being abandoned says nothing useful. + (or cancelling? cancelled?) + nil + + :else [:span {:class (stl/css :progress)} (dm/str progress " / " total)])] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index b85b0f4cc0..ef945ef771 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7702,10 +7702,30 @@ msgstr "Remove export" msgid "workspace.options.export.suffix" msgstr "Suffix" +#: src/app/main/ui/exports/assets.cljs:325 +msgid "workspace.options.cancel-export" +msgstr "Cancel" + #: src/app/main/ui/exports/assets.cljs:252 msgid "workspace.options.exporting-complete" msgstr "Export complete" +#: src/app/main/ui/exports/assets.cljs:259 +msgid "workspace.options.exporting-cancelled" +msgstr "Export cancelled" + +#: src/app/main/ui/exports/assets.cljs:258 +msgid "workspace.options.exporting-cancelling" +msgstr "Cancelling..." + +#: src/app/main/ui/exports/assets.cljs:261 +msgid "workspace.options.exporting-queued" +msgstr "Waiting..." + +#: src/app/main/ui/exports/assets.cljs:256 +msgid "workspace.options.exporting-busy" +msgstr "Export service is busy, please try again later" + #: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 msgid "workspace.options.exporting-object" msgstr "Exporting…" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index acfeeb8fab..e1ce838cd7 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -7491,10 +7491,30 @@ msgstr "Eliminar exportación" msgid "workspace.options.export.suffix" msgstr "Sufijo" +#: src/app/main/ui/exports/assets.cljs:325 +msgid "workspace.options.cancel-export" +msgstr "Cancelar" + #: src/app/main/ui/exports/assets.cljs:252 msgid "workspace.options.exporting-complete" msgstr "Exportación completa" +#: src/app/main/ui/exports/assets.cljs:259 +msgid "workspace.options.exporting-cancelled" +msgstr "Exportación cancelada" + +#: src/app/main/ui/exports/assets.cljs:258 +msgid "workspace.options.exporting-cancelling" +msgstr "Cancelando..." + +#: src/app/main/ui/exports/assets.cljs:261 +msgid "workspace.options.exporting-queued" +msgstr "Esperando..." + +#: src/app/main/ui/exports/assets.cljs:256 +msgid "workspace.options.exporting-busy" +msgstr "La cola de exportación está llena, inténtalo de nuevo en unos momentos" + #: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:253, src/app/main/ui/inspect/exports.cljs:216, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 msgid "workspace.options.exporting-object" msgstr "Exportando…"