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."