diff --git a/backend/resources/log4j2-devenv.xml b/backend/resources/log4j2-devenv.xml index 31e196829..3cf7ab00b 100644 --- a/backend/resources/log4j2-devenv.xml +++ b/backend/resources/log4j2-devenv.xml @@ -40,7 +40,7 @@ - + diff --git a/backend/src/app/features/components_v2.clj b/backend/src/app/features/components_v2.clj index 565d00320..15d5221c8 100644 --- a/backend/src/app/features/components_v2.clj +++ b/backend/src/app/features/components_v2.clj @@ -32,6 +32,7 @@ [app.common.types.container :as ctn] [app.common.types.file :as ctf] [app.common.types.grid :as ctg] + [app.common.types.modifiers :as ctm] [app.common.types.page :as ctp] [app.common.types.pages-list :as ctpl] [app.common.types.shape :as cts] @@ -978,6 +979,29 @@ (-> file-data (update :pages-index update-vals fix-container)))) + + fix-copies-names + (fn [file-data] + ;; Rename component heads to add the component path to the name + (letfn [(fix-container [container] + (d/update-when container :objects #(cfh/reduce-objects % fix-shape %))) + + (fix-shape [objects shape] + (let [root (ctn/get-component-shape objects shape) + libraries (assoc-in libraries [(:id file-data) :data] file-data) + library (get libraries (:component-file root)) + component (ctkl/get-component (:data library) (:component-id root) true) + path (str/trim (:path component))] + (if (and (ctk/instance-head? shape) + (some? component) + (= (:name component) (:name shape)) + (not (str/empty? path))) + (update objects (:id shape) assoc :name (str path " / " (:name component))) + objects)))] + + (-> file-data + (update :pages-index update-vals fix-container)))) + fix-copies-of-detached (fn [file-data] ;; Find any copy that is referencing a shape inside a component that have @@ -1027,8 +1051,9 @@ (fix-component-nil-objects) (fix-false-copies) (fix-component-root-without-component) - (fix-copies-of-detached); <- Do not add fixes after this and fix-orphan-copies call - ))) + (fix-copies-names) + (fix-copies-of-detached)))); <- Do not add fixes after this and fix-orphan-copies call + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; COMPONENTS MIGRATION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1077,8 +1102,8 @@ {:type :frame :x (:x position) :y (:y position) - :width (+ width (* 2 grid-gap)) - :height (+ height (* 2 grid-gap)) + :width (+ width grid-gap) + :height (+ height grid-gap) :name name :frame-id uuid/zero :parent-id uuid/zero})) @@ -1364,7 +1389,7 @@ (sbuilder/create-svg-shapes svg-data position objects frame-id frame-id #{} false))) (defn- process-media-object - [fdata page-id frame-id mobj position] + [fdata page-id frame-id mobj position shape-cb] (let [page (ctpl/get-page fdata page-id) file-id (get fdata :id) @@ -1414,16 +1439,17 @@ cfsh/prepare-create-artboard-from-selection) changes (fcb/concat-changes changes changes2)] + (shape-cb shape) (:redo-changes changes))) (defn- create-media-grid - [fdata page-id frame-id grid media-group] + [fdata page-id frame-id grid media-group shape-cb] (letfn [(process [fdata mobj position] (let [position (gpt/add position (gpt/point grid-gap grid-gap)) tp (dt/tpoint) err (volatile! false)] (try - (let [changes (process-media-object fdata page-id frame-id mobj position)] + (let [changes (process-media-object fdata page-id frame-id mobj position shape-cb)] (cp/process-changes fdata changes false)) (catch Throwable cause @@ -1472,6 +1498,43 @@ (or (process fdata mobj position) fdata)) (assoc-in fdata [:options :components-v2] true))))) +(defn- fix-graphics-size + [fdata new-grid page-id frame-id] + (let [modify-shape (fn [page shape-id modifiers] + (ctn/update-shape page shape-id #(gsh/transform-shape % modifiers))) + + resize-frame (fn [page] + (let [{:keys [width height]} (meta new-grid) + + frame (ctst/get-shape page frame-id) + width (+ width grid-gap) + height (+ height grid-gap) + + modif-frame (ctm/resize nil + (gpt/point (/ width (:width frame)) + (/ height (:height frame))) + (gpt/point (:x frame) (:y frame)))] + + (modify-shape page frame-id modif-frame))) + + move-components (fn [page] + (let [frame (get (:objects page) frame-id) + shapes (map (d/getf (:objects page)) (:shapes frame))] + (->> (d/zip shapes new-grid) + (reduce (fn [page [shape position]] + (let [position (gpt/add position (gpt/point grid-gap grid-gap)) + modif-shape (ctm/move nil + (gpt/point (- (:x position) (:x (:selrect shape))) + (- (:y position) (:y (:selrect shape))))) + children-ids (cfh/get-children-ids-with-self (:objects page) (:id shape))] + (reduce #(modify-shape %1 %2 modif-shape) + page + children-ids))) + page))))] + (-> fdata + (ctpl/update-page page-id resize-frame) + (ctpl/update-page page-id move-components)))) + (defn- migrate-graphics [fdata] (if (empty? (:media fdata)) @@ -1509,11 +1572,32 @@ (:id frame) (:id frame) nil - true))] - (recur (next groups) - (create-media-grid fdata page-id (:id frame) grid assets) - (gpt/add position (gpt/point 0 (+ height (* 2 grid-gap) frame-gap)))))))))) + true)) + new-shapes (volatile! []) + add-shape (fn [shape] + (vswap! new-shapes conj shape)) + + fdata' (create-media-grid fdata page-id (:id frame) grid assets add-shape) + + ;; When svgs had different width&height and viewport, sometimes the old graphics + ;; importer didn't calculate well the media object size. So, after migration we + ;; recalculate grid size from the actual size of the created shapes. + new-grid (ctst/generate-shape-grid @new-shapes position grid-gap) + + {new-width :width new-height :height} (meta new-grid) + + fdata'' (if-not (and (mth/close? width new-width) (mth/close? height new-height)) + (do + (l/inf :hint "fixing graphics sizes" + :file-id (str (:id fdata)) + :group group-name) + (fix-graphics-size fdata' new-grid page-id (:id frame))) + fdata')] + + (recur (next groups) + fdata'' + (gpt/add position (gpt/point 0 (+ height (* 2 grid-gap) frame-gap)))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PRIVATE HELPERS diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 056c99cc8..3c61e6b35 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -493,7 +493,7 @@ ::mtx/metrics (ig/ref ::mtx/metrics) ::db/pool (ig/ref ::db/pool)} - [::default ::wrk/worker] + [::default ::wrk/runner] {::wrk/parallelism (cf/get ::worker-default-parallelism 1) ::wrk/queue :default ::rds/redis (ig/ref ::rds/redis) @@ -501,7 +501,7 @@ ::mtx/metrics (ig/ref ::mtx/metrics) ::db/pool (ig/ref ::db/pool)} - [::webhook ::wrk/worker] + [::webhook ::wrk/runner] {::wrk/parallelism (cf/get ::worker-webhook-parallelism 1) ::wrk/queue :webhooks ::rds/redis (ig/ref ::rds/redis) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index ea49b6b70..89eee548d 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -201,7 +201,7 @@ (defn- wrap [cfg f mdata] - (l/debug :hint "register method" :name (::sv/name mdata)) + (l/trc :hint "register method" :name (::sv/name mdata)) (let [f (wrap-all cfg f mdata)] (partial f cfg))) diff --git a/backend/src/app/rpc/climit.clj b/backend/src/app/rpc/climit.clj index 3c23a7402..7dbec0861 100644 --- a/backend/src/app/rpc/climit.clj +++ b/backend/src/app/rpc/climit.clj @@ -200,7 +200,7 @@ (reduce (fn [handler [limit-id key-fn]] (if-let [config (get config limit-id)] (let [key-fn (or key-fn noop-fn)] - (l/dbg :hint "instrumenting method" + (l/trc :hint "instrumenting method" :method label :limit (id->str limit-id) :timeout (:timeout config) diff --git a/backend/src/app/rpc/cond.clj b/backend/src/app/rpc/cond.clj index a7db513b8..3fe03c821 100644 --- a/backend/src/app/rpc/cond.clj +++ b/backend/src/app/rpc/cond.clj @@ -51,7 +51,7 @@ [_ f {:keys [::get-object ::key-fn ::reuse-key?] :as mdata}] (if (and (ifn? get-object) (ifn? key-fn)) (do - (l/debug :hint "instrumenting method" :service (::sv/name mdata)) + (l/trc :hint "instrumenting method" :service (::sv/name mdata)) (fn [cfg {:keys [::key] :as params}] (if *enabled* (let [key' (when (or key reuse-key?) diff --git a/backend/src/app/rpc/retry.clj b/backend/src/app/rpc/retry.clj index 3745b9d8f..5e2d62013 100644 --- a/backend/src/app/rpc/retry.clj +++ b/backend/src/app/rpc/retry.clj @@ -44,7 +44,7 @@ (if (::enabled mdata) (let [max-retries (get mdata ::max-retries 3) matches? (get mdata ::when always-false)] - (l/dbg :hint "wrapping retry" :name name :max-retries max-retries) + (l/trc :hint "wrapping retry" :name name :max-retries max-retries) (fn [cfg params] (-> cfg (assoc ::max-retries max-retries) diff --git a/backend/src/app/worker.clj b/backend/src/app/worker.clj index 5c63ecfa0..a648080f3 100644 --- a/backend/src/app/worker.clj +++ b/backend/src/app/worker.clj @@ -8,69 +8,25 @@ "Async tasks abstraction (impl)." (:require [app.common.data :as d] - [app.common.data.macros :as dm] - [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.spec :as us] - [app.common.transit :as t] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] [app.metrics :as mtx] - [app.redis :as rds] [app.util.time :as dt] [clojure.spec.alpha :as s] [cuerdas.core :as str] - [integrant.core :as ig] - [promesa.core :as p] - [promesa.exec :as px]) - (:import - java.util.concurrent.Executor - java.util.concurrent.Future - java.util.concurrent.ThreadPoolExecutor)) + [integrant.core :as ig])) (set! *warn-on-reflection* true) -(s/def ::executor #(instance? Executor %)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Executor -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defmethod ig/pre-init-spec ::executor [_] - (s/keys :req [])) - -(defmethod ig/init-key ::executor - [_ _] - (let [factory (px/thread-factory :prefix "penpot/default/") - executor (px/cached-executor :factory factory :keepalive 60000)] - (l/inf :hint "starting executor") - (reify - java.lang.AutoCloseable - (close [_] - (l/inf :hint "stoping executor") - (px/shutdown! executor)) - - clojure.lang.IDeref - (deref [_] - {:active (.getPoolSize ^ThreadPoolExecutor executor) - :running (.getActiveCount ^ThreadPoolExecutor executor) - :completed (.getCompletedTaskCount ^ThreadPoolExecutor executor)}) - - Executor - (execute [_ runnable] - (.execute ^Executor executor ^Runnable runnable))))) - -(defmethod ig/halt-key! ::executor - [_ instance] - (.close ^java.lang.AutoCloseable instance)) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TASKS REGISTRY ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- wrap-task-handler - [metrics tname f] +(defn- wrap-with-metrics + [f metrics tname] (let [labels (into-array String [tname])] (fn [params] (let [tp (dt/tpoint)] @@ -83,6 +39,7 @@ :labels labels}))))))) (s/def ::registry (s/map-of ::us/string fn?)) +(s/def ::tasks (s/map-of keyword? fn?)) (defmethod ig/pre-init-spec ::registry [_] (s/keys :req [::mtx/metrics ::tasks])) @@ -90,537 +47,13 @@ (defmethod ig/init-key ::registry [_ {:keys [::mtx/metrics ::tasks]}] (l/inf :hint "registry initialized" :tasks (count tasks)) - (reduce-kv (fn [registry k v] + (reduce-kv (fn [registry k f] (let [tname (name k)] (l/trc :hint "register task" :name tname) - (assoc registry tname (wrap-task-handler metrics tname v)))) + (assoc registry tname (wrap-with-metrics f metrics tname)))) {} tasks)) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; EXECUTOR MONITOR -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(s/def ::name ::us/keyword) - -(defmethod ig/pre-init-spec ::monitor [_] - (s/keys :req [::name ::executor ::mtx/metrics])) - -(defmethod ig/prep-key ::monitor - [_ cfg] - (merge {::interval (dt/duration "2s")} - (d/without-nils cfg))) - -(defmethod ig/init-key ::monitor - [_ {:keys [::executor ::mtx/metrics ::interval ::name]}] - (letfn [(monitor! [executor prev-completed] - (let [labels (into-array String [(d/name name)]) - stats (deref executor) - - completed (:completed stats) - completed-inc (- completed prev-completed) - completed-inc (if (neg? completed-inc) 0 completed-inc)] - - (mtx/run! metrics - :id :executor-active-threads - :labels labels - :val (:active stats)) - - (mtx/run! metrics - :id :executor-running-threads - :labels labels - :val (:running stats)) - - (mtx/run! metrics - :id :executors-completed-tasks - :labels labels - :inc completed-inc) - - completed-inc))] - - (px/thread - {:name "penpot/executors-monitor" :virtual true} - (l/inf :hint "monitor: started" :name name) - (try - (loop [completed 0] - (px/sleep interval) - (recur (long (monitor! executor completed)))) - (catch InterruptedException _cause - (l/trc :hint "monitor: interrupted" :name name)) - (catch Throwable cause - (l/err :hint "monitor: unexpected error" :name name :cause cause)) - (finally - (l/inf :hint "monitor: terminated" :name name)))))) - -(defmethod ig/halt-key! ::monitor - [_ thread] - (px/interrupt! thread)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; SCHEDULER -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- decode-task-row - [{:keys [props] :as row}] - (cond-> row - (db/pgobject? props) - (assoc :props (db/decode-transit-pgobject props)))) - -(s/def ::wait-duration ::dt/duration) - -(defmethod ig/pre-init-spec ::dispatcher [_] - (s/keys :req [::mtx/metrics - ::db/pool - ::rds/redis] - :opt [::wait-duration - ::batch-size])) - -(defmethod ig/prep-key ::dispatcher - [_ cfg] - (merge {::batch-size 100 - ::wait-duration (dt/duration "5s")} - (d/without-nils cfg))) - -(def ^:private sql:select-next-tasks - "select id, queue from task as t - where t.scheduled_at <= now() - and (t.status = 'new' or t.status = 'retry') - and queue ~~* ?::text - order by t.priority desc, t.scheduled_at - limit ? - for update skip locked") - -(defmethod ig/init-key ::dispatcher - [_ {:keys [::db/pool ::rds/redis ::batch-size] :as cfg}] - (letfn [(get-tasks [conn] - (let [prefix (str (cf/get :tenant) ":%")] - (seq (db/exec! conn [sql:select-next-tasks prefix batch-size])))) - - (push-tasks! [conn rconn [queue tasks]] - (let [ids (mapv :id tasks) - key (str/ffmt "taskq:%" queue) - res (rds/rpush! rconn key (mapv t/encode ids)) - sql [(str "update task set status = 'scheduled'" - " where id = ANY(?)") - (db/create-array conn "uuid" ids)]] - - (db/exec-one! conn sql) - (l/trc :hist "dispatcher: queue tasks" - :queue queue - :tasks (count ids) - :queued res))) - - (run-batch! [rconn] - (try - (db/with-atomic [conn pool] - (if-let [tasks (get-tasks conn)] - (->> (group-by :queue tasks) - (run! (partial push-tasks! conn rconn))) - (px/sleep (::wait-duration cfg)))) - (catch InterruptedException cause - (throw cause)) - (catch Exception cause - (cond - (rds/exception? cause) - (do - (l/wrn :hint "dispatcher: redis exception (will retry in an instant)" :cause cause) - (px/sleep (::rds/timeout rconn))) - - (db/sql-exception? cause) - (do - (l/wrn :hint "dispatcher: database exception (will retry in an instant)" :cause cause) - (px/sleep (::rds/timeout rconn))) - - :else - (do - (l/err :hint "dispatcher: unhandled exception (will retry in an instant)" :cause cause) - (px/sleep (::rds/timeout rconn))))))) - - (dispatcher [] - (l/inf :hint "dispatcher: started") - (try - (dm/with-open [rconn (rds/connect redis)] - (loop [] - (run-batch! rconn) - (recur))) - (catch InterruptedException _ - (l/trc :hint "dispatcher: interrupted")) - (catch Throwable cause - (l/err :hint "dispatcher: unexpected exception" :cause cause)) - (finally - (l/inf :hint "dispatcher: terminated"))))] - - (if (db/read-only? pool) - (l/wrn :hint "dispatcher: not started (db is read-only)") - (px/fn->thread dispatcher :name "penpot/worker/dispatcher" :virtual true)))) - -(defmethod ig/halt-key! ::dispatcher - [_ thread] - (some-> thread px/interrupt!)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; WORKER -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(declare ^:private run-worker-loop!) -(declare ^:private start-worker!) -(declare ^:private get-error-context) - -(defmethod ig/pre-init-spec ::worker [_] - (s/keys :req [::parallelism - ::mtx/metrics - ::db/pool - ::rds/redis - ::queue - ::registry])) - -(defmethod ig/prep-key ::worker - [_ cfg] - (merge {::parallelism 1} - (d/without-nils cfg))) - -(defmethod ig/init-key ::worker - [_ {:keys [::db/pool ::queue ::parallelism] :as cfg}] - (let [queue (d/name queue) - cfg (assoc cfg ::queue queue)] - (if (db/read-only? pool) - (l/wrn :hint "worker: not started (db is read-only)" :queue queue :parallelism parallelism) - (doall - (->> (range parallelism) - (map #(assoc cfg ::worker-id %)) - (map start-worker!)))))) - -(defmethod ig/halt-key! ::worker - [_ threads] - (run! px/interrupt! threads)) - -(defn- start-worker! - [{:keys [::rds/redis ::worker-id ::queue] :as cfg}] - (px/thread - {:name (format "penpot/worker/runner:%s" worker-id)} - (l/inf :hint "worker: started" :worker-id worker-id :queue queue) - (try - (dm/with-open [rconn (rds/connect redis)] - (let [tenant (cf/get :tenant "main") - cfg (-> cfg - (assoc ::queue (str/ffmt "taskq:%:%" tenant queue)) - (assoc ::rds/rconn rconn) - (assoc ::timeout (dt/duration "5s")))] - (loop [] - (when (px/interrupted?) - (throw (InterruptedException. "interrupted"))) - - (run-worker-loop! cfg) - (recur)))) - - (catch InterruptedException _ - (l/debug :hint "worker: interrupted" - :worker-id worker-id - :queue queue)) - (catch Throwable cause - (l/err :hint "worker: unexpected exception" - :worker-id worker-id - :queue queue - :cause cause)) - (finally - (l/inf :hint "worker: terminated" - :worker-id worker-id - :queue queue))))) - -(defn- run-worker-loop! - [{:keys [::db/pool ::rds/rconn ::timeout ::queue ::registry ::worker-id]}] - (letfn [(handle-task-retry [{:keys [task error inc-by delay] :or {inc-by 1 delay 1000}}] - (let [explain (ex-message error) - nretry (+ (:retry-num task) inc-by) - now (dt/now) - delay (->> (iterate #(* % 2) delay) (take nretry) (last))] - (db/update! pool :task - {:error explain - :status "retry" - :modified-at now - :scheduled-at (dt/plus now delay) - :retry-num nretry} - {:id (:id task)}) - nil)) - - (handle-task-failure [{:keys [task error]}] - (let [explain (ex-message error)] - (db/update! pool :task - {:error explain - :modified-at (dt/now) - :status "failed"} - {:id (:id task)}) - nil)) - - (handle-task-completion [{:keys [task]}] - (let [now (dt/now)] - (db/update! pool :task - {:completed-at now - :modified-at now - :status "completed"} - {:id (:id task)}) - nil)) - - (decode-payload [^bytes payload] - (try - (let [task-id (t/decode payload)] - (if (uuid? task-id) - task-id - (l/err :hint "worker: received unexpected payload (uuid expected)" - :payload task-id))) - (catch Throwable cause - (l/err :hint "worker: unable to decode payload" - :payload payload - :length (alength payload) - :cause cause)))) - - (handle-task [{:keys [name] :as task}] - (let [task-fn (get registry name)] - (if task-fn - (task-fn task) - (l/wrn :hint "no task handler found" :name name)) - {:status :completed :task task})) - - (handle-task-exception [cause task] - (let [edata (ex-data cause)] - (if (and (< (:retry-num task) - (:max-retries task)) - (= ::retry (:type edata))) - (cond-> {:status :retry :task task :error cause} - (dt/duration? (:delay edata)) - (assoc :delay (:delay edata)) - - (= ::noop (:strategy edata)) - (assoc :inc-by 0)) - (do - (l/err :hint "worker: unhandled exception on task" - ::l/context (get-error-context cause task) - :cause cause) - (if (>= (:retry-num task) (:max-retries task)) - {:status :failed :task task :error cause} - {:status :retry :task task :error cause}))))) - - (get-task [task-id] - (ex/try! - (some-> (db/get* pool :task {:id task-id}) - (decode-task-row)))) - - (run-task [task-id] - (loop [task (get-task task-id)] - (cond - (ex/exception? task) - (if (or (db/connection-error? task) - (db/serialization-error? task)) - (do - (l/wrn :hint "worker: connection error on retrieving task from database (retrying in some instants)" - :worker-id worker-id - :cause task) - (px/sleep (::rds/timeout rconn)) - (recur (get-task task-id))) - (do - (l/err :hint "worker: unhandled exception on retrieving task from database (retrying in some instants)" - :worker-id worker-id - :cause task) - (px/sleep (::rds/timeout rconn)) - (recur (get-task task-id)))) - - (nil? task) - (l/wrn :hint "worker: no task found on the database" - :worker-id worker-id - :task-id task-id) - - :else - (try - (l/trc :hint "executing task" - :name (:name task) - :id (str (:id task)) - :queue queue - :worker-id worker-id - :retry (:retry-num task)) - (handle-task task) - (catch InterruptedException cause - (throw cause)) - (catch Throwable cause - (handle-task-exception cause task)))))) - - (process-result [{:keys [status] :as result}] - (ex/try! - (case status - :retry (handle-task-retry result) - :failed (handle-task-failure result) - :completed (handle-task-completion result) - nil))) - - (run-task-loop [task-id] - (loop [result (run-task task-id)] - (when-let [cause (process-result result)] - (if (or (db/connection-error? cause) - (db/serialization-error? cause)) - (do - (l/wrn :hint "worker: database exeption on processing task result (retrying in some instants)" - :cause cause) - (px/sleep (::rds/timeout rconn)) - (recur result)) - (do - (l/err :hint "worker: unhandled exception on processing task result (retrying in some instants)" - :cause cause) - (px/sleep (::rds/timeout rconn)) - (recur result))))))] - - (try - (let [[_ payload] (rds/blpop! rconn timeout queue)] - (some-> payload - decode-payload - run-task-loop)) - - (catch InterruptedException cause - (throw cause)) - - (catch Exception cause - (if (rds/timeout-exception? cause) - (do - (l/err :hint "worker: redis pop operation timeout, consider increasing redis timeout (will retry in some instants)" - :timeout timeout - :cause cause) - (px/sleep timeout)) - - (l/err :hint "worker: unhandled exception" :cause cause)))))) - -(defn- get-error-context - [_ item] - {:params item}) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; CRON -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(declare schedule-cron-task) -(declare synchronize-cron-entries!) - -(s/def ::fn (s/or :var var? :fn fn?)) -(s/def ::id keyword?) -(s/def ::cron dt/cron?) -(s/def ::props (s/nilable map?)) -(s/def ::task keyword?) - -(s/def ::cron-task - (s/keys :req-un [::cron ::task] - :opt-un [::props ::id])) - -(s/def ::entries (s/coll-of (s/nilable ::cron-task))) - -(defmethod ig/pre-init-spec ::cron [_] - (s/keys :req [::db/pool ::entries ::registry])) - -(defmethod ig/init-key ::cron - [_ {:keys [::entries ::registry ::db/pool] :as cfg}] - (if (db/read-only? pool) - (l/wrn :hint "cron: not started (db is read-only)") - (let [running (atom #{}) - entries (->> entries - (filter some?) - ;; If id is not defined, use the task as id. - (map (fn [{:keys [id task] :as item}] - (if (some? id) - (assoc item :id (d/name id)) - (assoc item :id (d/name task))))) - (map (fn [item] - (update item :task d/name))) - (map (fn [{:keys [task] :as item}] - (let [f (get registry task)] - (when-not f - (ex/raise :type :internal - :code :task-not-found - :hint (str/fmt "task %s not configured" task))) - (-> item - (dissoc :task) - (assoc :fn f)))))) - - cfg (assoc cfg ::entries entries ::running running)] - - (l/inf :hint "cron: started" :tasks (count entries)) - (synchronize-cron-entries! cfg) - - (->> (filter some? entries) - (run! (partial schedule-cron-task cfg))) - - (reify - clojure.lang.IDeref - (deref [_] @running) - - java.lang.AutoCloseable - (close [_] - (l/inf :hint "cron: terminated") - (doseq [item @running] - (when-not (.isDone ^Future item) - (.cancel ^Future item true)))))))) - -(defmethod ig/halt-key! ::cron - [_ instance] - (some-> instance d/close!)) - -(def sql:upsert-cron-task - "insert into scheduled_task (id, cron_expr) - values (?, ?) - on conflict (id) - do update set cron_expr=?") - -(defn- synchronize-cron-entries! - [{:keys [::db/pool ::entries]}] - (db/with-atomic [conn pool] - (doseq [{:keys [id cron]} entries] - (l/trc :hint "register cron task" :id id :cron (str cron)) - (db/exec-one! conn [sql:upsert-cron-task id (str cron) (str cron)])))) - -(defn- lock-scheduled-task! - [conn id] - (let [sql (str "SELECT id FROM scheduled_task " - " WHERE id=? FOR UPDATE SKIP LOCKED")] - (some? (db/exec-one! conn [sql (d/name id)])))) - -(defn- execute-cron-task - [{:keys [::db/pool] :as cfg} {:keys [id] :as task}] - (px/thread - {:name (str "penpot/cront-task/" id)} - (try - (db/with-atomic [conn pool] - (db/exec-one! conn ["SET statement_timeout=0;"]) - (db/exec-one! conn ["SET idle_in_transaction_session_timeout=0;"]) - (when (lock-scheduled-task! conn id) - (l/dbg :hint "cron: execute task" :task-id id) - ((:fn task) task)) - (db/rollback! conn)) - - (catch InterruptedException _ - (l/debug :hint "cron: task interrupted" :task-id id)) - - (catch Throwable cause - (binding [l/*context* (get-error-context cause task)] - (l/err :hint "cron: unhandled exception on running task" - :task-id id - :cause cause))) - (finally - (when-not (px/interrupted? :current) - (schedule-cron-task cfg task)))))) - -(defn- ms-until-valid - [cron] - (s/assert dt/cron? cron) - (let [now (dt/now) - next (dt/next-valid-instant-from cron now)] - (dt/diff now next))) - -(defn- schedule-cron-task - [{:keys [::running] :as cfg} {:keys [cron id] :as task}] - (let [ts (ms-until-valid cron) - ft (px/schedule! ts (partial execute-cron-task cfg task))] - - (l/dbg :hint "cron: schedule task" :task-id id - :ts (dt/format-duration ts) - :at (dt/format-instant (dt/in-future ts))) - (swap! running #(into #{ft} (filter p/pending?) %)))) - - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SUBMIT API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -672,6 +105,7 @@ [& {:keys [::task ::delay ::queue ::priority ::max-retries ::conn ::dedupe ::label] :or {delay 0 queue :default priority 100 max-retries 3 label ""} :as options}] + (us/verify! ::submit-options options) (let [duration (dt/duration delay) interval (db/interval duration) diff --git a/backend/src/app/worker/cron.clj b/backend/src/app/worker/cron.clj new file mode 100644 index 000000000..689fcba90 --- /dev/null +++ b/backend/src/app/worker/cron.clj @@ -0,0 +1,157 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.worker.cron + (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.db :as db] + [app.util.time :as dt] + [app.worker :as-alias wrk] + [app.worker.runner :refer [get-error-context]] + [clojure.spec.alpha :as s] + [cuerdas.core :as str] + [integrant.core :as ig] + [promesa.core :as p] + [promesa.exec :as px]) + (:import + java.util.concurrent.Future)) + +(set! *warn-on-reflection* true) + +(def sql:upsert-cron-task + "insert into scheduled_task (id, cron_expr) + values (?, ?) + on conflict (id) + do update set cron_expr=?") + +(defn- synchronize-cron-entries! + [{:keys [::db/pool ::entries]}] + (db/with-atomic [conn pool] + (doseq [{:keys [id cron]} entries] + (l/trc :hint "register cron task" :id id :cron (str cron)) + (db/exec-one! conn [sql:upsert-cron-task id (str cron) (str cron)])))) + +(defn- lock-scheduled-task! + [conn id] + (let [sql (str "SELECT id FROM scheduled_task " + " WHERE id=? FOR UPDATE SKIP LOCKED")] + (some? (db/exec-one! conn [sql (d/name id)])))) + +(declare ^:private schedule-cron-task) + +(defn- execute-cron-task + [cfg {:keys [id] :as task}] + (px/thread + {:name (str "penpot/cron-task/" id)} + (let [tpoint (dt/tpoint)] + (try + (db/tx-run! cfg (fn [{:keys [::db/conn]}] + (db/exec-one! conn ["SET LOCAL statement_timeout=0;"]) + (db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout=0;"]) + (when (lock-scheduled-task! conn id) + (l/dbg :hint "start task" :task-id id) + ((:fn task) task) + (let [elapsed (dt/format-duration (tpoint))] + (l/dbg :hint "end task" :task-id id :elapsed elapsed))))) + + (catch InterruptedException _ + (let [elapsed (dt/format-duration (tpoint))] + (l/debug :hint "task interrupted" :task-id id :elapsed elapsed))) + + (catch Throwable cause + (let [elapsed (dt/format-duration (tpoint))] + (binding [l/*context* (get-error-context cause task)] + (l/err :hint "unhandled exception on running task" + :task-id id + :elapsed elapsed + :cause cause)))) + (finally + (when-not (px/interrupted? :current) + (schedule-cron-task cfg task))))))) + +(defn- ms-until-valid + [cron] + (s/assert dt/cron? cron) + (let [now (dt/now) + next (dt/next-valid-instant-from cron now)] + (dt/diff now next))) + +(defn- schedule-cron-task + [{:keys [::running] :as cfg} {:keys [cron id] :as task}] + (let [ts (ms-until-valid cron) + ft (px/schedule! ts (partial execute-cron-task cfg task))] + + (l/dbg :hint "schedule task" :task-id id + :ts (dt/format-duration ts) + :at (dt/format-instant (dt/in-future ts))) + + (swap! running #(into #{ft} (filter p/pending?) %)))) + + +(s/def ::fn (s/or :var var? :fn fn?)) +(s/def ::id keyword?) +(s/def ::cron dt/cron?) +(s/def ::props (s/nilable map?)) +(s/def ::task keyword?) + +(s/def ::task-item + (s/keys :req-un [::cron ::task] + :opt-un [::props ::id])) + +(s/def ::wrk/entries (s/coll-of (s/nilable ::task-item))) + +(defmethod ig/pre-init-spec ::wrk/cron [_] + (s/keys :req [::db/pool ::wrk/entries ::wrk/registry])) + +(defmethod ig/init-key ::wrk/cron + [_ {:keys [::wrk/entries ::wrk/registry ::db/pool] :as cfg}] + (if (db/read-only? pool) + (l/wrn :hint "service not started (db is read-only)") + (let [running (atom #{}) + entries (->> entries + (filter some?) + ;; If id is not defined, use the task as id. + (map (fn [{:keys [id task] :as item}] + (if (some? id) + (assoc item :id (d/name id)) + (assoc item :id (d/name task))))) + (map (fn [item] + (update item :task d/name))) + (map (fn [{:keys [task] :as item}] + (let [f (get registry task)] + (when-not f + (ex/raise :type :internal + :code :task-not-found + :hint (str/fmt "task %s not configured" task))) + (-> item + (dissoc :task) + (assoc :fn f)))))) + + cfg (assoc cfg ::entries entries ::running running)] + + (l/inf :hint "started" :tasks (count entries)) + (synchronize-cron-entries! cfg) + + (->> (filter some? entries) + (run! (partial schedule-cron-task cfg))) + + (reify + clojure.lang.IDeref + (deref [_] @running) + + java.lang.AutoCloseable + (close [_] + (l/inf :hint "terminated") + (doseq [item @running] + (when-not (.isDone ^Future item) + (.cancel ^Future item true)))))))) + +(defmethod ig/halt-key! ::wrk/cron + [_ instance] + (some-> instance d/close!)) + diff --git a/backend/src/app/worker/dispatcher.clj b/backend/src/app/worker/dispatcher.clj new file mode 100644 index 000000000..dbdb06042 --- /dev/null +++ b/backend/src/app/worker/dispatcher.clj @@ -0,0 +1,110 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.worker.dispatcher + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.logging :as l] + [app.common.transit :as t] + [app.config :as cf] + [app.db :as db] + [app.metrics :as mtx] + [app.redis :as rds] + [app.util.time :as dt] + [app.worker :as-alias wrk] + [clojure.spec.alpha :as s] + [cuerdas.core :as str] + [integrant.core :as ig] + [promesa.exec :as px])) + +(set! *warn-on-reflection* true) + +(defmethod ig/pre-init-spec ::wrk/dispatcher [_] + (s/keys :req [::mtx/metrics ::db/pool ::rds/redis])) + +(defmethod ig/prep-key ::wrk/dispatcher + [_ cfg] + (merge {::batch-size 100 + ::wait-duration (dt/duration "5s")} + (d/without-nils cfg))) + +(def ^:private sql:select-next-tasks + "select id, queue from task as t + where t.scheduled_at <= now() + and (t.status = 'new' or t.status = 'retry') + and queue ~~* ?::text + order by t.priority desc, t.scheduled_at + limit ? + for update skip locked") + +(defmethod ig/init-key ::wrk/dispatcher + [_ {:keys [::db/pool ::rds/redis ::batch-size] :as cfg}] + (letfn [(get-tasks [conn] + (let [prefix (str (cf/get :tenant) ":%")] + (seq (db/exec! conn [sql:select-next-tasks prefix batch-size])))) + + (push-tasks! [conn rconn [queue tasks]] + (let [ids (mapv :id tasks) + key (str/ffmt "taskq:%" queue) + res (rds/rpush! rconn key (mapv t/encode ids)) + sql [(str "update task set status = 'scheduled'" + " where id = ANY(?)") + (db/create-array conn "uuid" ids)]] + + (db/exec-one! conn sql) + (l/trc :hist "queue tasks" + :queue queue + :tasks (count ids) + :queued res))) + + (run-batch! [rconn] + (try + (db/with-atomic [conn pool] + (if-let [tasks (get-tasks conn)] + (->> (group-by :queue tasks) + (run! (partial push-tasks! conn rconn))) + (px/sleep (::wait-duration cfg)))) + (catch InterruptedException cause + (throw cause)) + (catch Exception cause + (cond + (rds/exception? cause) + (do + (l/wrn :hint "redis exception (will retry in an instant)" :cause cause) + (px/sleep (::rds/timeout rconn))) + + (db/sql-exception? cause) + (do + (l/wrn :hint "database exception (will retry in an instant)" :cause cause) + (px/sleep (::rds/timeout rconn))) + + :else + (do + (l/err :hint "unhandled exception (will retry in an instant)" :cause cause) + (px/sleep (::rds/timeout rconn))))))) + + (dispatcher [] + (l/inf :hint "started") + (try + (dm/with-open [rconn (rds/connect redis)] + (loop [] + (run-batch! rconn) + (recur))) + (catch InterruptedException _ + (l/trc :hint "interrupted")) + (catch Throwable cause + (l/err :hint " unexpected exception" :cause cause)) + (finally + (l/inf :hint "terminated"))))] + + (if (db/read-only? pool) + (l/wrn :hint "not started (db is read-only)") + (px/fn->thread dispatcher :name "penpot/worker/dispatcher" :virtual false)))) + +(defmethod ig/halt-key! ::wrk/dispatcher + [_ thread] + (some-> thread px/interrupt!)) diff --git a/backend/src/app/worker/executor.clj b/backend/src/app/worker/executor.clj new file mode 100644 index 000000000..c1d10122c --- /dev/null +++ b/backend/src/app/worker/executor.clj @@ -0,0 +1,116 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.worker.executor + "Async tasks abstraction (impl)." + (:require + [app.common.data :as d] + [app.common.logging :as l] + [app.common.spec :as us] + [app.metrics :as mtx] + [app.util.time :as dt] + [app.worker :as-alias wrk] + [clojure.spec.alpha :as s] + [integrant.core :as ig] + [promesa.exec :as px]) + (:import + java.util.concurrent.Executor + java.util.concurrent.ThreadPoolExecutor)) + +(set! *warn-on-reflection* true) + +(s/def ::wrk/executor #(instance? Executor %)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; EXECUTOR +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defmethod ig/pre-init-spec ::wrk/executor [_] + (s/keys :req [])) + +(defmethod ig/init-key ::wrk/executor + [_ _] + (let [factory (px/thread-factory :prefix "penpot/default/") + executor (px/cached-executor :factory factory :keepalive 60000)] + (l/inf :hint "executor started") + (reify + java.lang.AutoCloseable + (close [_] + (l/inf :hint "stoping executor") + (px/shutdown! executor)) + + clojure.lang.IDeref + (deref [_] + {:active (.getPoolSize ^ThreadPoolExecutor executor) + :running (.getActiveCount ^ThreadPoolExecutor executor) + :completed (.getCompletedTaskCount ^ThreadPoolExecutor executor)}) + + Executor + (execute [_ runnable] + (.execute ^Executor executor ^Runnable runnable))))) + +(defmethod ig/halt-key! ::wrk/executor + [_ instance] + (.close ^java.lang.AutoCloseable instance)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; MONITOR +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(s/def ::name ::us/keyword) + +(defmethod ig/pre-init-spec ::wrk/monitor [_] + (s/keys :req [::wrk/name ::wrk/executor ::mtx/metrics])) + +(defmethod ig/prep-key ::wrk/monitor + [_ cfg] + (merge {::interval (dt/duration "2s")} + (d/without-nils cfg))) + +(defmethod ig/init-key ::wrk/monitor + [_ {:keys [::wrk/executor ::mtx/metrics ::interval ::wrk/name]}] + (letfn [(monitor! [executor prev-completed] + (let [labels (into-array String [(d/name name)]) + stats (deref executor) + + completed (:completed stats) + completed-inc (- completed prev-completed) + completed-inc (if (neg? completed-inc) 0 completed-inc)] + + (mtx/run! metrics + :id :executor-active-threads + :labels labels + :val (:active stats)) + + (mtx/run! metrics + :id :executor-running-threads + :labels labels + :val (:running stats)) + + (mtx/run! metrics + :id :executors-completed-tasks + :labels labels + :inc completed-inc) + + completed-inc))] + + (px/thread + {:name "penpot/executors-monitor" :virtual true} + (l/inf :hint "monitor started" :name name) + (try + (loop [completed 0] + (px/sleep interval) + (recur (long (monitor! executor completed)))) + (catch InterruptedException _cause + (l/trc :hint "monitor: interrupted" :name name)) + (catch Throwable cause + (l/err :hint "monitor: unexpected error" :name name :cause cause)) + (finally + (l/inf :hint "monitor: terminated" :name name)))))) + +(defmethod ig/halt-key! ::wrk/monitor + [_ thread] + (px/interrupt! thread)) diff --git a/backend/src/app/worker/runner.clj b/backend/src/app/worker/runner.clj new file mode 100644 index 000000000..40332ab23 --- /dev/null +++ b/backend/src/app/worker/runner.clj @@ -0,0 +1,272 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.worker.runner + "Async tasks abstraction (impl)." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.transit :as t] + [app.config :as cf] + [app.db :as db] + [app.metrics :as mtx] + [app.redis :as rds] + [app.util.time :as dt] + [app.worker :as-alias wrk] + [clojure.spec.alpha :as s] + [cuerdas.core :as str] + [integrant.core :as ig] + [promesa.exec :as px])) + +(set! *warn-on-reflection* true) + +(defn- decode-task-row + [{:keys [props] :as row}] + (cond-> row + (db/pgobject? props) + (assoc :props (db/decode-transit-pgobject props)))) + +(defn get-error-context + [_ item] + {:params item}) + +(defn- run-worker-loop! + [{:keys [::db/pool ::rds/rconn ::wrk/registry ::timeout ::queue ::id]}] + (letfn [(handle-task-retry [{:keys [task error inc-by delay] :or {inc-by 1 delay 1000}}] + (let [explain (ex-message error) + nretry (+ (:retry-num task) inc-by) + now (dt/now) + delay (->> (iterate #(* % 2) delay) (take nretry) (last))] + (db/update! pool :task + {:error explain + :status "retry" + :modified-at now + :scheduled-at (dt/plus now delay) + :retry-num nretry} + {:id (:id task)}) + nil)) + + (handle-task-failure [{:keys [task error]}] + (let [explain (ex-message error)] + (db/update! pool :task + {:error explain + :modified-at (dt/now) + :status "failed"} + {:id (:id task)}) + nil)) + + (handle-task-completion [{:keys [task]}] + (let [now (dt/now)] + (db/update! pool :task + {:completed-at now + :modified-at now + :status "completed"} + {:id (:id task)}) + nil)) + + (decode-payload [^bytes payload] + (try + (let [task-id (t/decode payload)] + (if (uuid? task-id) + task-id + (l/err :hint "received unexpected payload (uuid expected)" + :payload task-id))) + (catch Throwable cause + (l/err :hint "unable to decode payload" + :payload payload + :length (alength payload) + :cause cause)))) + + (handle-task [{:keys [name] :as task}] + (let [task-fn (get registry name)] + (if task-fn + (task-fn task) + (l/wrn :hint "no task handler found" :name name)) + {:status :completed :task task})) + + (handle-task-exception [cause task] + (let [edata (ex-data cause)] + (if (and (< (:retry-num task) + (:max-retries task)) + (= ::retry (:type edata))) + (cond-> {:status :retry :task task :error cause} + (dt/duration? (:delay edata)) + (assoc :delay (:delay edata)) + + (= ::noop (:strategy edata)) + (assoc :inc-by 0)) + (do + (l/err :hint "unhandled exception on task" + ::l/context (get-error-context cause task) + :cause cause) + (if (>= (:retry-num task) (:max-retries task)) + {:status :failed :task task :error cause} + {:status :retry :task task :error cause}))))) + + (get-task [task-id] + (ex/try! + (some-> (db/get* pool :task {:id task-id}) + (decode-task-row)))) + + (run-task [task-id] + (loop [task (get-task task-id)] + (cond + (ex/exception? task) + (if (or (db/connection-error? task) + (db/serialization-error? task)) + (do + (l/wrn :hint "connection error on retrieving task from database (retrying in some instants)" + :id id + :cause task) + (px/sleep (::rds/timeout rconn)) + (recur (get-task task-id))) + (do + (l/err :hint "unhandled exception on retrieving task from database (retrying in some instants)" + :id id + :cause task) + (px/sleep (::rds/timeout rconn)) + (recur (get-task task-id)))) + + (nil? task) + (l/wrn :hint "no task found on the database" + :id id + :task-id task-id) + + :else + (try + (l/trc :hint "start task" + :queue queue + :runner-id id + :name (:name task) + :task-id (str task-id) + :retry (:retry-num task)) + (let [tpoint (dt/tpoint) + result (handle-task task) + elapsed (dt/format-duration (tpoint))] + + (l/trc :hint "end task" + :queue queue + :runner-id id + :name (:name task) + :task-id (str task-id) + :retry (:retry-num task) + :elapsed elapsed) + + result) + + (catch InterruptedException cause + (throw cause)) + (catch Throwable cause + (handle-task-exception cause task)))))) + + (process-result [{:keys [status] :as result}] + (ex/try! + (case status + :retry (handle-task-retry result) + :failed (handle-task-failure result) + :completed (handle-task-completion result) + nil))) + + (run-task-loop [task-id] + (loop [result (run-task task-id)] + (when-let [cause (process-result result)] + (if (or (db/connection-error? cause) + (db/serialization-error? cause)) + (do + (l/wrn :hint "database exeption on processing task result (retrying in some instants)" + :cause cause) + (px/sleep (::rds/timeout rconn)) + (recur result)) + (do + (l/err :hint "unhandled exception on processing task result (retrying in some instants)" + :cause cause) + (px/sleep (::rds/timeout rconn)) + (recur result))))))] + + (try + (let [queue (str/ffmt "taskq:%" queue) + [_ payload] (rds/blpop! rconn timeout queue)] + (some-> payload + decode-payload + run-task-loop)) + + (catch InterruptedException cause + (throw cause)) + + (catch Exception cause + (if (rds/timeout-exception? cause) + (do + (l/err :hint "redis pop operation timeout, consider increasing redis timeout (will retry in some instants)" + :timeout timeout + :cause cause) + (px/sleep timeout)) + + (l/err :hint "unhandled exception" :cause cause)))))) + +(defn- start-thread! + [{:keys [::rds/redis ::id ::queue] :as cfg}] + (px/thread + {:name (format "penpot/worker/runner:%s" id)} + (l/inf :hint "started" :id id :queue queue) + (try + (dm/with-open [rconn (rds/connect redis)] + (let [tenant (cf/get :tenant "main") + cfg (-> cfg + (assoc ::queue (str/ffmt "%:%" tenant queue)) + (assoc ::rds/rconn rconn) + (assoc ::timeout (dt/duration "5s")))] + (loop [] + (when (px/interrupted?) + (throw (InterruptedException. "interrupted"))) + + (run-worker-loop! cfg) + (recur)))) + + (catch InterruptedException _ + (l/debug :hint "interrupted" + :id id + :queue queue)) + (catch Throwable cause + (l/err :hint "unexpected exception" + :id id + :queue queue + :cause cause)) + (finally + (l/inf :hint "terminated" + :id id + :queue queue))))) + +(s/def ::wrk/queue keyword?) + +(defmethod ig/pre-init-spec ::runner [_] + (s/keys :req [::wrk/parallelism + ::mtx/metrics + ::db/pool + ::rds/redis + ::wrk/queue + ::wrk/registry])) + +(defmethod ig/prep-key ::wrk/runner + [_ cfg] + (merge {::wrk/parallelism 1} + (d/without-nils cfg))) + +(defmethod ig/init-key ::wrk/runner + [_ {:keys [::db/pool ::wrk/queue ::wrk/parallelism] :as cfg}] + (let [queue (d/name queue) + cfg (assoc cfg ::queue queue)] + (if (db/read-only? pool) + (l/wrn :hint "not started (db is read-only)" :queue queue :parallelism parallelism) + (doall + (->> (range parallelism) + (map #(assoc cfg ::id %)) + (map start-thread!)))))) + +(defmethod ig/halt-key! ::wrk/runner + [_ threads] + (run! px/interrupt! threads)) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 27544c4fa..61b5f42bf 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -156,8 +156,8 @@ :app.loggers.database/reporter :app.worker/cron :app.worker/dispatcher - [:app.main/default :app.worker/worker] - [:app.main/webhook :app.worker/worker])) + [:app.main/default :app.worker/runner] + [:app.main/webhook :app.worker/runner])) _ (ig/load-namespaces system) system (-> (ig/prep system) (ig/init))] diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc index e5eae48eb..ebde6bf80 100644 --- a/common/src/app/common/geom/shapes/transforms.cljc +++ b/common/src/app/common/geom/shapes/transforms.cljc @@ -299,12 +299,16 @@ (cond-> shape (neg? dot-x) - (-> (cr/update! :flip-x not) - (cr/update! :rotation -)) + (cr/update! :flip-x not) + + (neg? dot-x) + (cr/update! :rotation -) (neg? dot-y) - (-> (cr/update! :flip-y not) - (cr/update! :rotation -))))) + (cr/update! :flip-y not) + + (neg? dot-y) + (cr/update! :rotation -)))) (defn- apply-transform-move "Given a new set of points transformed, set up the rectangle so it keeps diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 2101f90f7..e76fbe2a6 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -31,7 +31,7 @@ [app.common.uuid :as uuid] [clojure.set :as set])) -(cr/defrecord Shape [id name type x y width height rotation selrect points transform transform-inverse parent-id frame-id]) +(cr/defrecord Shape [id name type x y width height rotation selrect points transform transform-inverse parent-id frame-id flip-x flip-y]) (defn shape? [o] diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs index 981f78336..0a441068f 100644 --- a/frontend/src/app/main/data/comments.cljs +++ b/frontend/src/app/main/data/comments.cljs @@ -67,6 +67,7 @@ (update :comment-threads assoc id (dissoc thread :comment)) (update-in [:workspace-data :pages-index page-id :options :comment-threads-position] assoc id position) (update :comments-local assoc :open id) + (update :comments-local assoc :options nil) (update :comments-local dissoc :draft) (update :workspace-drawing dissoc :comment) (update-in [:comments id] assoc (:id comment) comment)))) @@ -120,6 +121,7 @@ (update :comment-threads assoc id (dissoc thread :comment)) (update-in [:viewer :pages page-id :options :comment-threads-position] assoc id position) (update :comments-local assoc :open id) + (update :comments-local assoc :options nil) (update :comments-local dissoc :draft) (update :workspace-drawing dissoc :comment) (update-in [:comments id] assoc (:id comment) comment)))) @@ -247,14 +249,16 @@ ptk/UpdateEvent (update [_ state] - (d/update-in-when state [:comments thread-id id] assoc :content content)) + (-> state + (d/update-in-when [:comments thread-id id] assoc :content content))) ptk/WatchEvent (watch [_ state _] - (let [share-id (-> state :viewer-local :share-id)] + (let [file-id (:current-file-id state) + share-id (-> state :viewer-local :share-id)] (->> (rp/cmd! :update-comment {:id id :content content :share-id share-id}) (rx/catch #(rx/throw {:type :comment-error})) - (rx/ignore)))))) + (rx/map #(retrieve-comment-threads file-id))))))) (defn delete-comment-thread-on-workspace [{:keys [id] :as thread}] @@ -427,6 +431,7 @@ (update [_ state] (-> state (update :comments-local assoc :open id) + (update :comments-local assoc :options nil) (update :workspace-drawing dissoc :comment))))) (defn close-thread @@ -435,7 +440,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (update :comments-local dissoc :open :draft) + (update :comments-local dissoc :open :draft :options) (update :workspace-drawing dissoc :comment))))) (defn update-filters @@ -490,6 +495,19 @@ (d/update-in-when [:workspace-drawing :comment] merge data) (d/update-in-when [:comments-local :draft] merge data))))) +(defn toggle-comment-options + [comment] + (ptk/reify ::toggle-comment-options + ptk/UpdateEvent + (update [_ state] + (update-in state [:comments-local :options] #(if (= (:id comment) %) nil (:id comment)))))) + +(defn hide-comment-options + [] + (ptk/reify ::hide-comment-options + ptk/UpdateEvent + (update [_ state] + (update-in state [:comments-local :options] (constantly nil))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Helpers diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index d0c6f3a74..044d133f9 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -355,20 +355,22 @@ (ptk/reify ::finalize-file ptk/UpdateEvent (update [_ state] - (dissoc state - :current-file-id - :current-project-id - :workspace-data - :workspace-editor-state - :workspace-file - :workspace-libraries - :workspace-ready? - :workspace-media-objects - :workspace-persistence - :workspace-presence - :workspace-project - :workspace-project - :workspace-undo)) + (-> state + (dissoc + :current-file-id + :current-project-id + :workspace-data + :workspace-editor-state + :workspace-file + :workspace-libraries + :workspace-media-objects + :workspace-persistence + :workspace-presence + :workspace-project + :workspace-ready? + :workspace-undo) + (update :workspace-global dissoc :read-only?) + (assoc-in [:workspace-global :options-mode] :design))) ptk/WatchEvent (watch [_ _ _] diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 22ec299c0..d260c5e0b 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -28,7 +28,6 @@ [app.main.data.modal :as md] [app.main.data.workspace.changes :as dch] [app.main.data.workspace.collapse :as dwc] - [app.main.data.workspace.edition :as dwe] [app.main.data.workspace.libraries-helpers :as dwlh] [app.main.data.workspace.specialized-panel :as-alias dwsp] [app.main.data.workspace.state-helpers :as wsh] @@ -151,7 +150,7 @@ objects (wsh/lookup-page-objects state page-id)] (rx/of (dwc/expand-all-parents [id] objects) - (dwe/clear-edition-mode) + :interrupt ::dwsp/interrupt)))))) (defn select-prev-shape diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 2fd50ec86..31cfd9a60 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -27,6 +27,8 @@ [okulary.core :as l] [rumext.v2 :as mf])) +(def comments-local-options (l/derived :options refs/comments-local)) + (mf/defc resizing-textarea {::mf/wrap-props false} [props] @@ -248,25 +250,28 @@ [{:keys [comment thread users origin] :as props}] (let [owner (get users (:owner-id comment)) profile (mf/deref refs/profile) - options (mf/use-state false) + options (mf/deref comments-local-options) edition? (mf/use-state false) on-toggle-options (mf/use-fn + (mf/deps options) (fn [event] (dom/stop-propagation event) - (swap! options not))) + (st/emit! (dcm/toggle-comment-options comment)))) on-hide-options (mf/use-fn + (mf/deps options) (fn [event] (dom/stop-propagation event) - (reset! options false))) + (st/emit! (dcm/hide-comment-options)))) on-edit-clicked (mf/use-fn + (mf/deps options) (fn [] - (reset! options false) + (st/emit! (dcm/hide-comment-options)) (reset! edition? true))) on-delete-comment @@ -282,7 +287,6 @@ (dcm/delete-comment-thread-on-viewer thread) (dcm/delete-comment-thread-on-workspace thread)))) - on-delete-thread (mf/use-fn (mf/deps thread) @@ -337,7 +341,7 @@ :on-cancel on-cancel}] [:span {:class (stl/css :text)} (:content comment)])]] - [:& dropdown {:show @options + [:& dropdown {:show (= options (:id comment)) :on-close on-hide-options} [:ul {:class (stl/css :comment-options-dropdown)} [:li {:class (stl/css :context-menu-option) @@ -356,7 +360,8 @@ (l/derived (l/in [:comments thread-id]) st/state)) (defn- offset-position [position viewport zoom bubble-margin] - (let [base-x (+ (* (:x position) zoom) (:offset-x viewport)) + (let [viewport (or viewport {:offset-x 0 :offset-y 0 :width 0 :height 0}) + base-x (+ (* (:x position) zoom) (:offset-x viewport)) base-y (+ (* (:y position) zoom) (:offset-y viewport)) w (:width viewport) h (:height viewport) @@ -381,7 +386,7 @@ (some? position-modifier) (gpt/transform position-modifier)) - max-height (int (* (:height viewport) 0.75)) + max-height (when (some? viewport) (int (* (:height viewport) 0.75))) ;; We should probably look for a better way of doing this. bubble-margin {:x 24 :y 0} pos (offset-position base-pos viewport zoom bubble-margin) @@ -418,8 +423,7 @@ :id (str "thread-" thread-id) :style {:left (str pos-x "px") :top (str pos-y "px") - :max-height max-height - :overflow-y "scroll"} + :max-height max-height} :on-click dom/stop-propagation} [:div {:class (stl/css :comments)} diff --git a/frontend/src/app/main/ui/comments.scss b/frontend/src/app/main/ui/comments.scss index c41dcd306..3c6e569ea 100644 --- a/frontend/src/app/main/ui/comments.scss +++ b/frontend/src/app/main/ui/comments.scss @@ -142,10 +142,14 @@ // thread-content .thread-content { position: absolute; - pointer-events: auto; - user-select: text; + overflow-y: scroll; + scrollbar-gutter: stable; width: $s-284; padding: $s-12; + padding-inline-end: 0; + + pointer-events: auto; + user-select: text; border-radius: $br-8; border: $s-2 solid var(--modal-border-color); background-color: var(--comment-modal-background-color); @@ -216,7 +220,8 @@ .comment-options-dropdown { @extend .dropdown-wrapper; position: absolute; - width: $s-120; + width: fit-content; + max-width: $s-200; right: 0; left: unset; .context-menu-option { @@ -238,6 +243,7 @@ margin-bottom: $s-8; padding: $s-8; color: var(--input-foreground-color-active); + resize: vertical; &:focus { border: $s-1 solid var(--input-border-color-active); outline: none; diff --git a/frontend/src/app/main/ui/components/title_bar.scss b/frontend/src/app/main/ui/components/title_bar.scss index c6882c4e0..20e25e233 100644 --- a/frontend/src/app/main/ui/components/title_bar.scss +++ b/frontend/src/app/main/ui/components/title_bar.scss @@ -14,6 +14,7 @@ width: 100%; min-height: $s-32; background-color: var(--title-background-color); + color: var(--title-foreground-color); } .title, @@ -26,7 +27,7 @@ grid-auto-flow: column; height: 100%; min-height: $s-32; - color: var(--title-foreground-color); + overflow: hidden; } diff --git a/frontend/src/app/main/ui/dashboard/templates.cljs b/frontend/src/app/main/ui/dashboard/templates.cljs index 7d2dd5a87..f7a42454f 100644 --- a/frontend/src/app/main/ui/dashboard/templates.cljs +++ b/frontend/src/app/main/ui/dashboard/templates.cljs @@ -8,7 +8,6 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.common.math :as mth] [app.config :as cf] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] @@ -123,7 +122,9 @@ [:div {:class (stl/css :template-card)} [:div {:class (stl/css :img-container)} [:img {:src (dm/str thb) - :alt (:name item)}]] + :alt (:name item) + :loading "lazy" + :decoding "async"}]] [:div {:class (stl/css :card-name)} [:span {:class (stl/css :card-text)} (:name item)] download-icon]]])) @@ -164,7 +165,7 @@ (mf/defc templates-section {::mf/wrap-props false} - [{:keys [default-project-id profile project-id team-id content-width]}] + [{:keys [default-project-id profile project-id team-id]}] (let [templates (mf/deref builtin-templates) templates (mf/with-memo [templates] (filterv #(not= (:id %) "tutorial-for-beginners") templates)) @@ -179,63 +180,41 @@ props (:props profile) collapsed (:builtin-templates-collapsed-status props false) - card-offset* (mf/use-state 0) - card-offset (deref card-offset*) + can-move (mf/use-state {:left false :right true}) - card-width 275 total (count templates) - container-size (* (+ 2 total) card-width) ;; We need space for total plus the libraries&templates link - more-cards (> (+ card-offset (* (+ 1 total) card-width)) content-width) - card-count (mth/floor (/ content-width 275)) - left-moves (/ card-offset -275) - first-card left-moves - last-card (+ (- card-count 1) left-moves) content-ref (mf/use-ref) - on-move-left + move-left (fn [] (dom/scroll-by! (mf/ref-val content-ref) -300 0)) + move-right (fn [] (dom/scroll-by! (mf/ref-val content-ref) 300 0)) + + update-can-move + (fn [scroll-left scroll-available client-width] + (reset! can-move {:left (> scroll-left 0) + :right (> scroll-available client-width)})) + + on-scroll (mf/use-fn - (mf/deps card-offset card-width) - (fn [_event] - (when-not (zero? card-offset) - (dom/animate! (mf/ref-val content-ref) - [#js {:left (dm/str card-offset "px")} - #js {:left (dm/str (+ card-offset card-width) "px")}] - #js {:duration 200 :easing "linear"}) - (reset! card-offset* (+ card-offset card-width))))) + (fn [e] + (let [scroll (dom/get-target-scroll e) + scroll-left (:scroll-left scroll) + scroll-available (- (:scroll-width scroll) scroll-left) + client-rect (dom/get-client-size (dom/get-target e))] + (update-can-move scroll-left scroll-available (unchecked-get client-rect "width"))))) + + on-move-left + (mf/use-fn #(move-left)) on-move-left-key-down - (mf/use-fn - (mf/deps on-move-left first-card) - (fn [event] - (when (kbd/enter? event) - (dom/stop-propagation event) - (on-move-left event) - (when-let [node (dom/get-element (dm/str "card-container-" first-card))] - (dom/focus! node))))) + (mf/use-fn #(move-left)) on-move-right - (mf/use-fn - (mf/deps more-cards card-offset card-width) - (fn [_event] - (when more-cards - (swap! card-offset* inc) - (dom/animate! (mf/ref-val content-ref) - [#js {:left (dm/str card-offset "px")} - #js {:left (dm/str (- card-offset card-width) "px")}] - #js {:duration 200 :easing "linear"}) - (reset! card-offset* (- card-offset card-width))))) + (mf/use-fn #(move-right)) on-move-right-key-down - (mf/use-fn - (mf/deps on-move-right last-card) - (fn [event] - (when (kbd/enter? event) - (dom/stop-propagation event) - (on-move-right event) - (when-let [node (dom/get-element (dm/str "card-container-" last-card))] - (dom/focus! node))))) + (mf/use-fn #(move-right)) on-import-template (mf/use-fn @@ -243,6 +222,12 @@ (fn [template _event] (import-template! template team-id project-id default-project-id section)))] + (mf/with-effect [content-ref templates] + (let [content (mf/ref-val content-ref)] + (when (and (some? content) (some? templates)) + (dom/scroll-to content #js {:behavior "instant" :left 0 :top 0}) + (.dispatchEvent content (js/Event. "scroll"))))) + (mf/with-effect [profile collapsed] (when (and profile (not collapsed)) (st/emit! (dd/fetch-builtin-templates)))) @@ -252,9 +237,8 @@ [:& title {:collapsed collapsed}] [:div {:class (stl/css :content) - :ref content-ref - :style {:left card-offset - :width (dm/str container-size "px")}} + :on-scroll on-scroll + :ref content-ref} (for [index (range (count templates))] [:& card-item @@ -262,24 +246,23 @@ :item (nth templates index) :index index :key index - :is-visible (and (>= index first-card) - (<= index last-card)) + :is-visible true :collapsed collapsed}]) [:& card-item-link - {:is-visible (and (>= total first-card) (<= total last-card)) + {:is-visible true :collapsed collapsed :section section :total total}]] - (when (< card-offset 0) + (when (:left @can-move) [:button {:class (stl/css :move-button :move-left) :tab-index (if ^boolean collapsed "-1" "0") :on-click on-move-left :on-key-down on-move-left-key-down} arrow-icon]) - (when more-cards + (when (:right @can-move) [:button {:class (stl/css :move-button :move-right) :tab-index (if collapsed "-1" "0") :on-click on-move-right diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index 2649d166b..76cf2f455 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -109,24 +109,29 @@ } .content { + display: grid; + grid-template-columns: repeat(auto-fill, minmax($s-276, $s-276)); + grid-auto-flow: column; pointer-events: all; - width: 200%; height: $s-228; margin-left: $s-6; - position: absolute; border-top-left-radius: $s-8; background-color: $db-quaternary; + overflow: scroll hidden; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + scroll-snap-stop: always; } .card-container { width: $s-276; margin-top: $s-20; - display: inline-block; text-align: center; vertical-align: top; background-color: transparent; border: none; padding: 0; + scroll-snap-align: start; } .template-card { diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index 60480d5f6..6883a6d24 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -613,6 +613,7 @@ :permissions permissions :zoom zoom :section section + :shown-thumbnails (:show-thumbnails local) :interactions-mode interactions-mode}]])) ;; --- Component: Viewer diff --git a/frontend/src/app/main/ui/viewer/header.cljs b/frontend/src/app/main/ui/viewer/header.cljs index 1b1734dca..6e3051cf7 100644 --- a/frontend/src/app/main/ui/viewer/header.cljs +++ b/frontend/src/app/main/ui/viewer/header.cljs @@ -201,7 +201,7 @@ :class (stl/css :go-log-btn)} (tr "labels.log-or-sign")])])) (mf/defc header-sitemap - [{:keys [project file page frame] :as props}] + [{:keys [project file page frame toggle-thumbnails] :as props}] (let [project-name (:name project) file-name (:name file) page-name (:name page) @@ -209,11 +209,6 @@ frame-name (:name frame) show-dropdown? (mf/use-state false) - toggle-thumbnails - (mf/use-fn - (fn [] - (st/emit! dv/toggle-thumbnails-panel))) - open-dropdown (mf/use-fn (fn [] @@ -254,12 +249,13 @@ (when (= page-id id) [:span {:class (stl/css :icon-check)} i/tick])])]]] [:div {:class (stl/css :current-frame) + :id "current-frame" :on-click toggle-thumbnails} [:span {:class (stl/css :frame-name)} frame-name] [:span {:class (stl/css :icon)} i/arrow]]]])) (mf/defc header - [{:keys [project file page frame zoom section permissions index interactions-mode]}] + [{:keys [project file page frame zoom section permissions index interactions-mode shown-thumbnails]}] (let [go-to-dashboard (mf/use-fn #(st/emit! (dv/go-to-dashboard))) @@ -282,13 +278,27 @@ (keyword))] (if (or (= section :interactions) (:is-logged permissions)) (st/emit! (dv/go-to-section section)) - (open-login-dialog)))))] + (open-login-dialog))))) + + toggle-thumbnails + (mf/use-fn + (fn [] + (st/emit! dv/toggle-thumbnails-panel))) + + + close-thumbnails + (mf/use-fn + (mf/deps shown-thumbnails) + (fn [_] + (when shown-thumbnails + (st/emit! dv/close-thumbnails-panel))))] [:header {:class (stl/css-case :viewer-header true - :fullscreen (mf/deref fullscreen-ref))} + :fullscreen (mf/deref fullscreen-ref)) + :on-click close-thumbnails} [:div {:class (stl/css :nav-zone)} - ;; If the user doesn't have permission we disable the link + ;; If the user doesn't have permission we disable the link [:a {:class (stl/css :home-link) :on-click go-to-dashboard :style {:cursor (when-not (:can-edit permissions) "auto") @@ -300,6 +310,7 @@ :file file :page page :frame frame + :toggle-thumbnails toggle-thumbnails :index index}]] [:div {:class (stl/css :mode-zone)} diff --git a/frontend/src/app/main/ui/viewer/inspect/exports.scss b/frontend/src/app/main/ui/viewer/inspect/exports.scss index 600262c4e..95e674373 100644 --- a/frontend/src/app/main/ui/viewer/inspect/exports.scss +++ b/frontend/src/app/main/ui/viewer/inspect/exports.scss @@ -16,8 +16,10 @@ } .title-spacing-export-viewer { - @extend .attr-title; margin: 0; + color: var(--entry-foreground-color-hover); + margin-inline-start: calc(-1 * $s-8); + width: calc(100% + $s-8); } .add-export { @@ -26,6 +28,7 @@ width: $s-28; svg { @extend .button-icon; + stroke: var(--icon-foreground); } } diff --git a/frontend/src/app/main/ui/viewer/inspect/right_sidebar.scss b/frontend/src/app/main/ui/viewer/inspect/right_sidebar.scss index a542a2a1b..48bb94620 100644 --- a/frontend/src/app/main/ui/viewer/inspect/right_sidebar.scss +++ b/frontend/src/app/main/ui/viewer/inspect/right_sidebar.scss @@ -21,7 +21,7 @@ } .viewer-code { - padding: 0 $s-8; + padding-inline-start: $s-8; } .tool-windows { diff --git a/frontend/src/app/main/ui/viewer/thumbnails.cljs b/frontend/src/app/main/ui/viewer/thumbnails.cljs index 7da49dfab..4a20d35fe 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.cljs +++ b/frontend/src/app/main/ui/viewer/thumbnails.cljs @@ -107,7 +107,8 @@ (mf/defc thumbnails-panel [{:keys [frames page index show? thumbnail-data] :as props}] - (let [expanded? (mf/use-state false) + (let [expanded-state (mf/use-state false) + expanded? (deref expanded-state) container (mf/use-ref) objects (:objects page) @@ -115,23 +116,27 @@ selected (mf/use-var false) on-item-click - (mf/use-callback - (mf/deps @expanded?) + (mf/use-fn + (mf/deps expanded?) (fn [_ index] (compare-and-set! selected false true) (st/emit! (dv/go-to-frame-by-index index)) - (when @expanded? - (on-close))))] + (when expanded? + (on-close)))) + + toggle-expand + (mf/use-fn + #(swap! expanded-state not))] [:section {:class (stl/css-case :viewer-thumbnails true - :expanded @expanded?) - ;; This is better as an inline-style so it won't make a reflow of every frame inside + :expanded expanded?) + ;; This is better as an inline-style so it won't make a reflow of every frame inside :style {:display (when (not show?) "none")} :ref container} - [:& thumbnails-summary {:on-toggle-expand #(swap! expanded? not) + [:& thumbnails-summary {:on-toggle-expand toggle-expand :on-close on-close :total (count frames)}] - [:& thumbnails-content {:expanded? @expanded? + [:& thumbnails-content {:expanded? expanded? :total (count frames)} (for [[i frame] (d/enumerate frames)] [:& thumbnail-item {:index i diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 7fe2d8e3c..1055426db 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -73,7 +73,6 @@ (fn [] (close-modals) (st/emit! (dw/set-options-mode :design) - (dw/set-workspace-read-only false) (dw/go-to-dashboard project)))) nav-to-project diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss index 1fad7eeb8..72706d601 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss @@ -66,8 +66,7 @@ &:hover { .cell-name { - display: grid; - grid-template-columns: 1fr auto; + display: block; } } diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index debc36252..afe62a2b1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -144,7 +144,10 @@ (conj :rect :circle :path :bool))] (or (= uuid/zero id) (and (or (str/includes? (str/lower (:name shape)) (str/lower search)) - (str/includes? (dm/str (:id shape)) (str/lower search))) + ;; Only for local development we allow search for ids. Otherwise will be hard + ;; search for numbers or single letter shape names (ie: "A") + (and *assert* + (str/includes? (dm/str (:id shape)) (str/lower search)))) (or (empty? filters) (and (contains? filters :component) (contains? shape :component-id)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs index d5170a8b5..570b25faf 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.cljs @@ -616,9 +616,10 @@ [:div {:class (stl/css :name-wrapper)} [:div {:class (stl/css :component-name)} - (if multi - (tr "settings.multiple") - (cfh/last-path shape-name))] + [:span {:class (stl/css :component-name-inside)} + (if multi + (tr "settings.multiple") + (cfh/last-path shape-name))]] (when (and can-swap? (not multi)) [:div {:class (stl/css :component-parent-name)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index c36756069..d024187a7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -56,7 +56,6 @@ padding-right: 0.5rem; .component-name-wrapper { width: 100%; - border-radius: $br-8; } } @@ -93,6 +92,7 @@ min-height: $s-32; padding: $s-8 0 $s-8 $s-2; border-radius: $br-8 0 0 $br-8; + overflow: hidden; } .component-name { @@ -103,6 +103,11 @@ min-height: $s-16; } +.component-name-inside { + direction: ltr; + unicode-bidi: bidi-override; +} + .component-parent-name { @include bodySmallTypography; @include textEllipsis; diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs index 4fde27d0c..20d41a9f7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs @@ -189,6 +189,7 @@ ;; shortcuts.unmask ;; shortcuts.v-distribute ;; shortcuts.zoom-selected + ;; shortcuts.toggle-layout-grid (let [translat-pre (case type :sc "shortcuts." :sec "shortcut-section." diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index 23d8de43f..79321b508 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -26,7 +26,7 @@ (def guide-width 1) (def guide-opacity 0.7) (def guide-opacity-hover 1) -(def guide-color colors/new-primary) +(def guide-color colors/new-danger) (def guide-pill-width 34) (def guide-pill-height 20) (def guide-pill-corner-radius 4) @@ -378,7 +378,7 @@ :transform (when (= axis :y) (str "rotate(-90 " text-x "," text-y ")")) :style {:font-size (/ rulers/font-size zoom) :font-family rulers/font-family - :fill colors/black}} + :fill colors/white}} ;; If the guide is associated to a frame we show the position relative to the frame (fmt/format-number (- pos (if (= axis :x) (:x frame) (:y frame))))]]))]))) diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index ed91e2b6c..8d04c1ac2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -335,8 +335,8 @@ flip-x (get shape :flip-x) flip-y (get shape :flip-y) - half-flip? (or (and (some? flip-x) (not (some? flip-y))) - (and (some? flip-y) (not (some? flip-x))))] + half-flip? (or (and flip-x (not flip-y)) + (and flip-y (not flip-x)))] (when (and (not ^boolean read-only?) (not (:transforming shape)) @@ -357,7 +357,7 @@ (and ^boolean half-flip? (or (= position :top-right) (= position :bottom-left))) - (- rotation 90) + (+ rotation 90) :else rotation) diff --git a/frontend/src/app/util/code_gen/style_css_formats.cljs b/frontend/src/app/util/code_gen/style_css_formats.cljs index 69f841dc9..0a9cdd515 100644 --- a/frontend/src/app/util/code_gen/style_css_formats.cljs +++ b/frontend/src/app/util/code_gen/style_css_formats.cljs @@ -20,6 +20,8 @@ :height :size :min-width :size :min-height :size + :max-width :size + :max-height :size :background :color :border :border :border-radius :string-or-size-array diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 0a59c586e..01d34e582 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -645,6 +645,12 @@ (when (some? element) (.-scrollLeft element))) +(defn scroll-to + ([^js element options] + (.scrollTo element options)) + ([^js element x y] + (.scrollTo element x y))) + (defn set-scroll-pos! [^js element scroll] (when (some? element) @@ -756,6 +762,12 @@ [] (.reload (.-location js/window))) +(defn scroll-by! + ([element x y] + (.scrollBy ^js element x y)) + ([x y] + (scroll-by! js/window x y))) + (defn animate! ([item keyframes duration] (animate! item keyframes duration nil)) ([item keyframes duration onfinish] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 498fd2597..64d26fe66 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -3023,6 +3023,9 @@ msgstr "Zoom lense increase" msgid "shortcuts.zoom-selected" msgstr "Zoom to selected" +msgid "shortcuts.toggle-layout-grid" +msgstr "Add/remove grid layout" + #: src/app/main/ui/dashboard/team.cljs msgid "team.webhooks.max-length" msgstr "The webhook name must contain at most 2048 characters." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 02f484954..18ee3b030 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -3069,6 +3069,9 @@ msgstr "Incrementar zoom a objetivo" msgid "shortcuts.zoom-selected" msgstr "Zoom a selección" +msgid "shortcuts.toggle-layout-grid" +msgstr "Añadir/eliminar grid layout" + #: src/app/main/ui/dashboard/team.cljs msgid "team.webhooks.max-length" msgstr "El nombre del webhook debe contener como máximo 2048 caracteres."