(ns dashboard.core (:require [ajax.core :refer [GET]] [clojure.string :as string] [reagent.core :as r] [reagent.dom :as rdom])) (def state (r/atom {})) (def sidebar-cursor (r/cursor state [:sidebar-tip])) (def slow-twists-cursor (r/cursor state [:slow-twists])) (def slow-publishes-cursor (r/cursor state [:slow-publishes])) (def focused-lines-cursor (r/cursor state [:focused-lines])) (def expanded-cursor (r/cursor state [:expanded])) (def selected-poptop-cursor (r/cursor state [:selected-poptop])) ;; async-load flags for the loading indicator: /stats (node α/β) and /lines-detail ;; (per-line counts) both land after the initial light graph render. (def stats-loaded-cursor (r/cursor state [:stats-loaded?])) (def details-loaded-cursor (r/cursor state [:details-loaded?])) ;; per-poptop dynamic aggregates from /poptop-summary (tickets, hoist rates, ;; proof weight); structural metrics are derived client-side from :lines. (def poptop-summary-cursor (r/cursor state [:poptop-summary])) ;; scoped cursors so the poptop summary only re-renders when the data it reads ;; changes, not on every unrelated state write (e.g. the 60s stats tick). (def lines-cursor (r/cursor state [:lines])) (def lines-by-hash-cursor (r/cursor state [:lines-by-hash])) ;; text of the "focus these line-twists" box, so the Select button can read it ;; without walking the DOM. (def line-select-text (r/atom "")) ;; --- tree structure helpers ------------------------------------------------- ;; Server elements: nodes are {:data {:id ...} :meta {...}}; edges are ;; {:data {:id ... :source :target }} (tether points child->parent). (defn edge? [el] (some? (-> el :data :source))) (defn trunc-hash [h] (if (> (count h) 14) (str (subs h 0 8) "…" (subs h (- (count h) 6))) h)) (defn children-by-parent "Map of parent-id -> set of child-ids, derived from the tether edges." [elements] (reduce (fn [m el] (if (edge? el) (update m (-> el :data :target) (fnil conj #{}) (-> el :data :source)) m)) {} elements)) (defn adjacency "Undirected id -> set-of-neighbour-ids map from the tether edges. Undirected so a connected component includes a poptop's ancestors and descendants alike." [elements] (reduce (fn [m el] (if (edge? el) (let [{:keys [source target]} (:data el)] (-> m (update source (fnil conj #{}) target) (update target (fnil conj #{}) source))) m)) {} elements)) (defn component-ids "Set of node ids in the connected component reachable from start-id over adj. The visited set makes spools (tether cycles) terminate." [adj start-id] (loop [seen #{} stack [start-id]] (if-let [id (peek stack)] (if (contains? seen id) (recur seen (pop stack)) (recur (conj seen id) (into (pop stack) (remove seen (get adj id))))) seen))) (defn poptops "Node elements marked is_poptop (nodes carry :meta; edges don't)." [elements] (filter #(-> % :meta :is_poptop) elements)) (defn visible-elements "Elements restricted to the selected poptop's connected component: keep nodes in the component and edges whose endpoints are both in it. nil -> unchanged." [elements selected] (if-not selected elements (let [comp-ids (component-ids (adjacency elements) selected)] (filter (fn [el] (if (edge? el) (let [{:keys [source target]} (:data el)] (and (comp-ids source) (comp-ids target))) (comp-ids (-> el :data :id)))) elements)))) (defn leaf-parents "Set of node ids whose children are all leaves (no grandchildren). These are the groups we collapse: the level-1 lines whose children are level-2 leaves." [elements] (let [kids (children-by-parent elements)] (into #{} (comp (filter (fn [[_ children]] (every? #(empty? (get kids %)) children))) (map key)) kids))) (defn display-elements "Cytoscape elements for the current expand/collapse state. A collapsed leaf-parent hides its leaf children (and the edges touching them); an expanded leaf-parent shows them as ordinary child nodes below it, connected by their tether edges so the rooted layout draws them under the parent." [elements expanded] (let [expanded (or expanded #{}) kids (children-by-parent elements) lps (leaf-parents elements) collapsed (remove expanded lps) hidden-leaf (into #{} (mapcat kids collapsed))] (keep (fn [el] (if (edge? el) (let [{:keys [source target]} (:data el)] (when-not (or (hidden-leaf source) (hidden-leaf target)) el)) (when-not (hidden-leaf (-> el :data :id)) el))) elements))) ;; --- spool clustering ------------------------------------------------------- ;; A spool is a tether cycle: a strongly-connected component (SCC) of size > 1. ;; dagre is a layered-DAG layout, so it smears a cycle across ranks. For a spool ;; that sits at the TOP of a stack we instead contract it to one node, let dagre ;; lay out the rest, and drop its members back as a tight cluster (see draw-graph). (defn parents-by-child "Map of child-id -> set of parent-ids (tether points child->parent). The forward directed graph; children-by-parent is its transpose." [elements] (reduce (fn [m el] (if (edge? el) (update m (-> el :data :source) (fnil conj #{}) (-> el :data :target)) m)) {} elements)) (defn node-ids [elements] (into #{} (comp (remove edge?) (map #(-> % :data :id))) elements)) (defn finish-order "DFS finish order over directed adjacency (iterative; visited set is spool-safe)." [adj ids] (loop [order [] seen #{} todo (seq ids)] (if-let [start (first todo)] (if (contains? seen start) (recur order seen (rest todo)) (let [[order seen] (loop [order order seen (conj seen start) stack [[start (seq (get adj start))]]] (if-let [[node children] (peek stack)] (if-let [c (first children)] (if (contains? seen c) (recur order seen (conj (pop stack) [node (rest children)])) (recur order (conj seen c) (conj (pop stack) [node (rest children)] [c (seq (get adj c))]))) (recur (conj order node) seen (pop stack))) [order seen]))] (recur order seen (rest todo)))) order))) (defn reachable "Ids reachable from start over adj, not crossing into `blocked` ids." [adj start blocked] (loop [stack [start] out #{}] (if-let [n (peek stack)] (if (contains? out n) (recur (pop stack) out) (recur (into (pop stack) (remove #(or (out %) (blocked %)) (get adj n))) (conj out n))) out))) (defn sccs "Strongly-connected components (Kosaraju) of the tether graph, as a seq of id-sets. Singletons included." [elements] (let [ids (node-ids elements) fwd (parents-by-child elements) rev (children-by-parent elements) order (finish-order fwd ids)] (loop [order (reverse order) assigned #{} out []] (if-let [n (first order)] (if (contains? assigned n) (recur (rest order) assigned out) (let [comp (reachable rev n assigned)] (recur (rest order) (into assigned comp) (conj out comp)))) out)))) (defn top-sccs "Non-trivial SCCs (spools, size > 1) that are condensation sinks: no member tethers up to a non-member. These are the spools sitting atop their stack." [elements] (let [fwd (parents-by-child elements)] (filter (fn [scc] (and (> (count scc) 1) (every? (fn [m] (every? scc (get fwd m))) scc))) (sccs elements)))) (defn spool-positions "Positions map id -> #js{:x :y} for a cytoscape `preset` layout. Contract each top spool to a super-node, run dagre on the condensation (so the rest of the stack lays out cleanly with the spool as one top node), then fan each spool's members into a small circle around the super-node's dagre position." [elements tops] (let [kids (children-by-parent elements) rep-of (into {} (mapcat (fn [scc] (let [super (str "spool:" (first (sort scc)))] (map (fn [m] [m super]) scc))) tops)) rep (fn [id] (get rep-of id id)) ;; super-node id -> {:members ordered :n-top n}. Members that tether the ;; spool to non-members (external children) are ordered LAST so they can be ;; anchored at the bottom of the cluster, nearest the children they connect to. supers (into {} (map (fn [scc] (let [scc-set (set scc) ext? (fn [m] (boolean (some #(not (contains? scc-set %)) (get kids m)))) sorted (sort scc) tops-m (remove ext? sorted) ordered (vec (concat tops-m (filter ext? sorted)))] [(str "spool:" (first sorted)) {:members ordered :n-top (count tops-m)}])) tops)) g (js/dagre.graphlib.Graph.)] (.setGraph g #js {:rankdir "BT" :nodesep 25 :ranksep 60}) (.setDefaultEdgeLabel g (fn [] #js {})) (doseq [id (node-ids elements)] (let [r (rep id)] (.setNode g r (if (contains? supers r) #js {:width 200 :height 200} #js {:width 80 :height 80})))) (doseq [el elements :when (edge? el)] (let [s (rep (-> el :data :source)) t (rep (-> el :data :target))] (when (not= s t) (.setEdge g s t)))) (js/dagre.layout g) (reduce (fn [m id] (let [r (rep id) node (.node g r) cx (.-x node) cy (.-y node)] (if-let [{:keys [members n-top]} (get supers r)] (let [k (count members) idx (or (first (keep-indexed #(when (= %2 id) %1) members)) 0) step (/ (* 2 js/Math.PI) k) ;; anchor the midpoint of the external-child block [n-top .. k-1] ;; at π/2 (bottom, since +y points down) so connectors sit lowest. center (/ (+ n-top (dec k)) 2) th (+ (- (/ js/Math.PI 2) (* center step)) (* idx step))] (assoc m id #js {:x (+ cx (* 55 (js/Math.cos th))) :y (+ cy (* 55 (js/Math.sin th)))})) (assoc m id #js {:x cx :y cy})))) {} (node-ids elements)))) ;; --- landing overview ------------------------------------------------------- ;; With no poptop selected, the landing view collapses each poptop-having ;; connected component to ONE synthetic "poptop:" node (see overview-elements), ;; so the first render is compact instead of dumping every line. Clicking a ;; synthetic node selects that poptop, dropping into the single-component view. (defn components "Seq of connected-component id-sets over the (undirected) tether graph." [elements] (let [adj (adjacency elements)] (loop [todo (seq (node-ids elements)) seen #{} out []] (if-let [start (first todo)] (if (contains? seen start) (recur (rest todo) seen out) (let [c (component-ids adj start)] (recur (rest todo) (into seen c) (conj out c)))) out)))) (defn overview-elements "Landing-view elements when no poptop is selected. Each component containing a poptop collapses to one synthetic {:data {:id \"poptop:\"}} node; poptop-free components pass through as their real nodes + internal edges. Returns {:elements [...] :synthetic {synthetic-id -> {:poptop hash :members ids :n count}}}. The \"poptop:\" id prefix can't collide with a real line hash and lets the click handler and styler recognise synthetic nodes." [elements] (let [pt-ids (into #{} (map #(-> % :data :id)) (poptops elements))] (reduce (fn [acc comp] (if-let [pt (some pt-ids comp)] (let [sid (str "poptop:" pt)] (-> acc (update :elements conj {:data {:id sid}}) (assoc-in [:synthetic sid] {:poptop pt :members comp :n (count comp)}))) (update acc :elements into (filter (fn [el] (if (edge? el) (let [{:keys [source target]} (:data el)] (and (comp source) (comp target))) (comp (-> el :data :id)))) elements)))) {:elements [] :synthetic {}} (components elements)))) (defn toggle-expanded! [id] (swap! state update :expanded (fn [s] (let [s (or s #{})] (if (contains? s id) (disj s id) (conj s id)))))) ;; tracks the previous node tap so we can synthesize a double-click (Cytoscape ;; has no native dblclick on its canvas). (def last-tap (atom nil)) (defn set-lines [{:keys [lines]}] (swap! state assoc :lines lines :lines-by-hash (reduce (fn [m {:keys [line_twist] :as line}] (assoc m line_twist line)) {} (map :meta lines)))) (defn http-get [url opts] (GET url (merge {:headers {"Accept" "application/transit+json"} :response-format :json :keywords? true} opts))) (defn set-poptop-summary [{:keys [summary]}] (swap! state assoc :poptop-summary (reduce (fn [m {:keys [poptop] :as r}] (assoc m poptop r)) {} summary))) (defn get-poptop-summary [] (http-get "/poptop-summary" {:handler set-poptop-summary})) (defn merge-line-details "Backfill the expensive per-line counts onto the already-rendered graph. Writes ONLY into :lines-by-hash (a path the graph component doesn't watch), so cytoscape is left untouched — user-dragged positions and zoom survive. Clears cached tooltips so they rebuild with real counts on the next hover." [{:keys [details]}] (let [by-hash (reduce (fn [m {:keys [line_twist] :as d}] (assoc m line_twist d)) {} details)] (swap! state (fn [s] (-> s (update :lines-by-hash (fn [m] (reduce-kv (fn [m h d] (update m h merge d)) (or m {}) by-hash))) (assoc :details-loaded? true) (dissoc :tooltips)))))) (defn get-line-details [] (http-get "/lines-detail" {:handler merge-line-details})) (defn get-lines [] (swap! state assoc :details-loaded? false) (http-get "/lines" {:handler set-lines}) ;; kick off the heavy per-line counts in parallel; they land in :lines-by-hash ;; without redrawing the graph (see merge-line-details). (get-line-details) (get-poptop-summary)) (defn set-slow-twists [{:keys [slow-twists]}] (swap! state assoc :slow-twists slow-twists)) (defn get-slow-twists [] (http-get "/slow-twists" {:handler set-slow-twists})) (defn set-slow-publishes [{:keys [slow-publishes]}] (swap! state assoc :slow-publishes slow-publishes)) (defn get-slow-publishes [] (http-get "/slow-publishes" {:handler set-slow-publishes})) (defn check-line-status [] (http-get (str "/line-exists?id=" (first (remove nil? (keys (:lines-by-hash @state))))) {:handler (fn [{:keys [status]}] (when (false? status) (get-lines)))})) (defn get-stats [update-graph] (http-get "/stats" {:handler update-graph})) (defn set-line-info [{:keys [line]}] (swap! state (fn [state] (-> state (update :lines (fn [{:keys [line_twist] :as l}] (if (= line_twist (:line_twist line)) line l))) (assoc-in [:lines-by-hash (:line_twist line)] line))))) (defn refresh-line-info [line-twist-hash handler] (http-get (str "/line?id=" line-twist-hash) {:handler (fn [response] (set-line-info response) (handler))})) (defn destroy-graphs [] (doseq [graph (vals (:graphs @state))] (.destroy graph))) (defn dash "Render nil as the \"--\" placeholder, otherwise the value itself." [v] (if (some? v) v "--")) (defn commit-interval-str [{:keys [days hours minutes seconds]}] (str days " " hours ":" minutes ":" seconds)) (defn make-tip "Cytoscape-popper tooltip: a compact
 summary shown on node hover."
  [node line]
  (let [{:keys [line_twist is_publicly_writable created_at is_tethered
                uncommitted_hoist_count twists hoist_failures publish_failures]} line]
    (.popper node
             (clj->js
              {:content
               (let [div (js/document.createElement "pre")]
                 (set! (.-color (.-style div)) "white")
                 (set! (.-background (.-style div)) "black")
                 (set! (.-innerHTML div)
                       (str line_twist
                            "\n" (if is_publicly_writable "public" "private")
                            "\ncreated at " created_at
                            "\ntethered? " is_tethered
                            "\nuncommitted hoist requests " (dash uncommitted_hoist_count)
                            "\ncommit interval " (commit-interval-str line)
                            "\ntwists " (dash twists)
                            "\nhoist failures " hoist_failures
                            "\npublish failures " publish_failures))
                 div)}))))

(defn get-id [node]
  (:id (js->clj (.data node) :keywordize-keys true)))

(defn show-line-info [event]
  (let [node (.-target event)
        node-id (get-id node)
        line-info (get-in @state [:lines-by-hash node-id])
        tip (or (get-in @state [:tooltips node-id])
                (let [tip (make-tip node line-info)]
                  (swap! state update :tooltips assoc node-id tip)
                  tip))]
    (.show tip)))

(defn line-detail-rows
  "Ordered [label value] pairs for the line-info sidebar."
  [{:keys [line_twist last_commit hoist_failures publish_failures lock_id
           is_publicly_writable available_tickets tether_line_twist is_tethered
           created_at twists lock_created_at uncommitted_hoist_count
           line_lock_count twist_lock_count] :as line}]
  [["line-twist" line_twist]
   ["last-commit" last_commit]
   ["hoist-failures" hoist_failures]
   ["commit-interval" (commit-interval-str line)]
   ["publish-failures" publish_failures]
   ["lock-id" lock_id]
   ["is-publicly-writable" (str is_publicly_writable)]
   ["available-tickets" (str available_tickets)]
   ["tether-line-twist" tether_line_twist]
   ["is-tethered" (str is_tethered)]
   ["created-at" created_at]
   ["twists" (dash twists)]
   ["lock-created-at" lock_created_at]
   ["uncommitted hoist-count" (dash uncommitted_hoist_count)]
   ["line-lock-count" (dash line_lock_count)]
   ["twist-lock-count" (dash twist_lock_count)]])

(defn show-details-sidebar [event]
  (let [node-id (get-id (.-target event))]
    (refresh-line-info
     node-id
     #(reset! sidebar-cursor
              [:table.table
               (into [:tbody.tbody]
                     (for [[label value] (line-detail-rows (get-in @state [:lines-by-hash node-id]))]
                       [:tr [:td label] [:td value]]))]))))
(defn hide-line-info [event]
  (let [node-id (get-id (.-target event))]
    (some-> (get-in @state [:tooltips node-id]) (.hide))))

(defn tooltip-factory [ref content]
  (js/tippy
   (js/document.createElement "div")
   (clj->js
    {:getReferenceClientRect (.-getBoundingClientRect ref)
     :trigger "manual"
     :content content
     :sticky false})))

(defn int-to-hex [n]
  (let [hex-str (.toString n 16)]
    (if (< (count hex-str) 2)
      (str "0" hex-str)
      hex-str)))

(defn number-to-color [num]
  (let [min-val 10
        max-val 100
        green "#013c0a"
        red "#660101"]
    (cond
      (< num min-val)
      green
      (> num max-val)
      red
      :else
      (let [ratio (/ (- num min-val) (- max-val min-val))
            r (int (* 255 ratio))]
        (str "#" (int-to-hex r) "FF" "00")))))

(def non-public-border-color "#ebed6b")
(def focused-background "#e8aded")

;; shared palette for the dark UI, so cards/tables/legend read consistently.
(def page-bg "#000")
(def card-bg "#141414")
(def text-color "#fff")
(def muted-color "#9aa")
(def heading-color "#ccc")
(def link-color "#8cf")
(def hairline-color "#333")

(def hot-threshold
  "A child counts as 'hot' (⚠) once its uncommitted hoists exceed this."
  10)

(defn set-node-style []
  (let [graph (-> @state :graphs :completed-graph)
        completed-node-data (-> @state :completed-hoists)
        uncompleted-node-data (-> @state :uncompleted-hoists)
        elements (:lines @state)
        kids (children-by-parent elements)
        lps (leaf-parents elements)
        expanded (or (:expanded @state) #{})
        unc (fn [cid] (get uncompleted-node-data (keyword cid) 0))
        com (fn [cid] (get completed-node-data (keyword cid) 0))
        ;; /stats (node α/β + color) loads async, after the light graph render.
        ;; Until it lands, show "--" and a neutral fill instead of a misleading 0/green.
        stats? (boolean (and completed-node-data uncompleted-node-data))
        neutral-color "#2b2b2b"
        tint (fn [n] (if stats? (number-to-color n) neutral-color))
        counts-label (fn [id] (if stats? (str (unc id) "\n" (com id)) "--\n--"))
        decorate (fn [id base]
                   (merge base
                          (if (contains? @focused-lines-cursor id)
                            {:background-color focused-background}
                            {})
                          (if (get-in @state [:lines-by-hash id :is_publicly_writable])
                            {}
                            {:border-color non-public-border-color
                             :border-width "3px"})))]
    (when graph
      (doseq [node (.nodes graph)]
        (let [id (.id node)]
          (if-let [{:keys [poptop members n]} (get (:overview-nodes @state) id)]
            ;; synthetic overview node: one card per poptop component, tinted by
            ;; the worst uncommitted-hoist among its member lines (health map).
            (let [worst (apply max 0 (map unc members))
                  hot   (count (filter #(> (unc %) hot-threshold) members))]
              (.style node (clj->js {:label (str (subs poptop 0 8) "\n" n " lines"
                                                 (when (pos? hot) (str "  ⚠" hot)))
                                     :shape "round-rectangle"
                                     :width "120px"
                                     :height "70px"
                                     :background-color (tint worst)})))
            (let [child-ids (get kids id)
                  base
                  (cond
                    ;; collapsed leaf-parent: compact glyph tinted by worst child
                    (and (contains? lps id) (not (contains? expanded id)))
                    (let [worst (apply max 0 (map unc child-ids))
                          hot (count (filter #(> (unc %) hot-threshold) child-ids))]
                      {:label (str "+" (count child-ids)
                                   (when (pos? hot) (str "  ⚠" hot)))
                       :shape "round-rectangle"
                       :background-color (tint worst)})

                    ;; expanded leaf-parent: keep the group shape but show the line's
                    ;; own stats (its children are visible below with their own)
                    (and (contains? lps id) (contains? expanded id))
                    {:label (counts-label id)
                     :shape "round-rectangle"
                     :background-color (tint (unc id))}

                    ;; ordinary node / leaf: its own uncommitted/committed counts
                    :else
                    {:label (counts-label id)
                     :background-color (tint (unc id))})]
              (.style node (clj->js (decorate id base))))))))))

(defn set-stats [{:keys [completed-hoists uncompleted-hoists]}]
  (swap! state assoc
         :completed-hoists completed-hoists
         :uncompleted-hoists uncompleted-hoists
         :stats-loaded? true)
  (set-node-style))

(defn watch-stats! [] 
  (let [timeout (* 60 1000)]
    (js/setTimeout
     #(get-stats (fn [stats]
                   (set-stats stats)
                   (js/setTimeout watch-stats! timeout)))
     timeout)))

(def style
  [{:selector "label"
    :css
    {:text-wrap "wrap"
     :color "#fff"}}
   {:selector "node",
    :css
    {:content "data(id)",
     :text-valign "center",
     :text-halign "center",
     :height "60px",
     :width "60px",
     :border-color "#fff",
     :border-opacity "1",
     :border-width "10px"}}
   {:selector "$node > node",
    :css
    {:padding-top "10px",
     :padding-left "10px",
     :padding-bottom "10px",
     :padding-right "10px",
     :text-valign "top",
     :text-halign "center",
     :background-color "#bbb"}}
   {:selector "edge"
    :css {:width 3
          :curve-style "bezier"
          :line-color "#eee"
          :target-arrow-color "#ccc"
          :target-arrow-shape "triangle"}}
   {:selector "selected",
    :css {:background-color "white"
          :line-color "white"
          :target-arrow-color "white"
          :source-arrow-color "white"}}])

;; layouts https://blog.js.cytoscape.org/2020/05/11/layouts/
(defn draw-graph [graph-type]
  (let [selected (:selected-poptop @state)
        ;; no selection -> the compact landing overview (one node per poptop
        ;; component); a selection -> that poptop's component, as before.
        {:keys [elements synthetic]} (if selected
                                       {:elements (visible-elements (:lines @state) selected)
                                        :synthetic {}}
                                       (overview-elements (:lines @state)))
        els (display-elements elements (:expanded @state))
        tops (top-sccs els)
        config (clj->js
                {:container (js/document.getElementById (name graph-type))
                 :elements els
                 :style style
                 :userZoomingEnabled false})
        graph (js/cytoscape config)
        ;; A spool at the top of the stack would get smeared across dagre ranks;
        ;; when one is present, lay out via a contracted dagre + preset so the
        ;; spool renders as a tight cluster. No top spool -> dagre as before.
        layout (if (seq tops)
                 (let [pos (spool-positions els tops)]
                   #js {:name "preset"
                        :positions (fn [node] (get pos (.id node)))
                        :fit true})
                 #js {:name "dagre"
                      :rankDir "BT" ;; edges point child->parent, so BT puts parents above
                      ;; overview cards are disconnected; widen the gap so they don't touch
                      :nodeSep (if selected 25 80)
                      :rankSep 60
                      :fit true})]

    (.panzoom graph {})
    (.run (.layout graph layout))

    (.on graph "mouseover" "node" show-line-info)
    (.on graph "mouseout" "node" hide-line-info)
    ;; single click -> details sidebar (unchanged); a second click on the same
    ;; leaf-parent within 300ms -> expand/collapse its hidden subtree.
    (.on graph "click" "node"
         (fn [event]
           (let [id (get-id (.-target event))]
             ;; a synthetic overview node -> select that poptop (drops into the
             ;; single-component view); real nodes -> sidebar + double-click expand.
             (if-let [{:keys [poptop]} (get (:overview-nodes @state) id)]
               (reset! selected-poptop-cursor poptop)
               (do
                 (show-details-sidebar event)
                 (let [now (js/Date.now)
                       prev @last-tap]
                   (if (and prev (= (:id prev) id) (< (- now (:t prev)) 300))
                     (do (reset! last-tap nil)
                         (when (contains? (leaf-parents (:lines @state)) id)
                           (toggle-expanded! id)))
                     (reset! last-tap {:id id :t now}))))))))
    ;; stash so set-node-style can style synthetic nodes on async stats ticks too
    (swap! state assoc :overview-nodes synthetic)
    (swap! state assoc-in [:graphs graph-type] graph)
    (set-node-style)))

;; --- poptop summary ---------------------------------------------------------
;; A per-poptop rollup answering the operational questions: what sits under each
;; poptop, its shape, ticket supply/demand, hoist-request pressure and the proof
;; weight it carries. Structure comes from the tether graph we already hold;
;; ticket/hoist/weight numbers come from /poptop-summary.

(defn subtree-members
  "Set of ids reachable downward from `root` through the child links in `kids`
   (root included). Walking children only (not the undirected component) keeps a
   nested poptop scoped to its own subtree instead of the whole tether tree. The
   seen-set makes spools (tether cycles) terminate."
  [kids root]
  (loop [seen #{} stack [root]]
    (if-let [id (peek stack)]
      (if (contains? seen id)
        (recur seen (pop stack))
        (recur (conj seen id) (into (pop stack) (get kids id))))
      seen)))

(defn subtree-depth
  "Longest tether chain (in lines) from `root` down through its children. The
   seen-set makes spools (tether cycles) terminate."
  [kids root]
  (letfn [(go [node seen]
            (if (contains? seen node)
              0
              (let [seen     (conj seen node)
                    children (get kids node)]
                (if (empty? children)
                  1
                  (inc (apply max (map #(go % seen) children)))))))]
    (go root #{})))

(defn poptop-structure
  "Structural metrics for the subtree rooted at `poptop-id`, derived from the
   already-loaded tether graph and per-line fields. `kids` is the shared
   children-by-parent map and `spool-ids` the set of lines in the top spool, so
   callers compute them once. Scoped to descendants (downward) so a nested
   poptop counts only its own subtree, matching the server's attribution."
  [kids spool-ids by-hash poptop-id]
  (let [members     (subtree-members kids poptop-id)
        metas       (keep #(get by-hash %) members)
        leaf?       (fn [id] (empty? (get kids id)))
        publics     (filter :is_publicly_writable metas)
        avail       (fn [m] (or (:available_tickets m) 0))
        ;; branch factor = how many tree lines tether into a line. It is a
        ;; property of the tree, so ignore the spool cycle at the top (its
        ;; members and any spool tethers). A regular stack has one constant here.
        tree-children (fn [id] (count (remove spool-ids (get kids id))))]
    {:lines-under          (count members)
     :depth                (subtree-depth kids poptop-id)
     :leaves               (count (filter leaf? members))
     :branching            (->> members
                                 (map tree-children)
                                 (remove zero?)
                                 distinct
                                 sort
                                 vec)
     :public-count         (count publics)
     :only-leaves-public?  (every? #(leaf? (:line_twist %)) publics)
     :tickets-available    (reduce + 0 (map avail publics))
     :public-out-of-tickets (count (filter #(zero? (avail %)) publics))}))

(defn round-to [x p]
  (when (some? x)
    (let [f (js/Math.pow 10 p)]
      (/ (js/Math.round (* x f)) f))))

(defn fmt-bytes [b]
  (cond
    (nil? b)            "--"
    (< b 1024)          (str b " B")
    (< b (* 1024 1024)) (str (round-to (/ b 1024) 1) " KB")
    :else               (str (round-to (/ b 1048576) 2) " MB")))

(defn fmt-duration
  "Seconds as a compact human string."
  [s]
  (cond
    (nil? s)     "--"
    (< s 60)     (str (round-to s 1) "s")
    (< s 3600)   (str (round-to (/ s 60) 1) "m")
    (< s 86400)  (str (round-to (/ s 3600) 1) "h")
    :else        (str (round-to (/ s 86400) 1) "d")))

(defn stat-row [label value]
  [:tr
   [:td {:style {:color muted-color :padding-right "12px" :white-space "nowrap"}} label]
   [:td {:style {:color text-color}} value]])

(defn fmt-branching [factors]
  (if (seq factors) (string/join ", " factors) "--"))

(defn poptop-card [id struct dyn selected?]
  (let [{:keys [lines-under depth leaves branching public-count only-leaves-public?
                tickets-available public-out-of-tickets]} struct
        {:keys [tickets_given tickets_returned total_hoists span_seconds
                max_per_sec longest_gap_seconds max_weight_bytes
                ticket_weight_bytes topline_twists]} dyn
        ;; clamp the span to >= 1s so a burst confined to a single second (or a
        ;; lone request) reports a real rate instead of dropping to "--".
        avg-per-sec (when (and (number? total_hoists) (pos? total_hoists) span_seconds)
                      (/ total_hoists (max span_seconds 1)))
        loading?    (nil? dyn)]
    [:div.box
     {:style {:background card-bg
              :border (if selected? (str "2px solid " focused-background) (str "1px solid " hairline-color))
              :margin-bottom "16px"}}
     [:div {:style {:display "flex" :align-items "baseline" :gap "10px"
                    :margin-bottom "8px"}}
      [:a {:style {:font-family "monospace" :font-size "16px" :color link-color}
           :on-click (fn [_] (reset! selected-poptop-cursor id))}
       (trunc-hash id)]
      [:span.tag.is-info.is-light (str lines-under " lines")]
      (when-not only-leaves-public?
        [:span.tag.is-warning "non-leaf public line(s)"])
      (when loading? [:span.tag.is-warning.is-light "⏳"])]
     [:div.columns
      [:div.column
       [:h4 {:style {:color heading-color :margin-bottom "4px"}} "Structure"]
       [:table.table.is-narrow {:style {:background "transparent"}}
        [:tbody
         (stat-row "lines under" lines-under)
         (stat-row "depth / leaves" (str depth " levels, " leaves " leaves"))
         (stat-row "topline twists" (or topline_twists "--"))
         (stat-row "branching factor" (fmt-branching branching))
         (stat-row "only leaves public?" (if only-leaves-public? "yes" "NO"))
         (stat-row "public lines" public-count)]]]
      [:div.column
       [:h4 {:style {:color heading-color :margin-bottom "4px"}} "Tickets"]
       [:table.table.is-narrow {:style {:background "transparent"}}
        [:tbody
         (stat-row "available (public)" tickets-available)
         (stat-row "public, out of tickets" public-out-of-tickets)
         (stat-row "given out" (if dyn tickets_given "--"))
         (stat-row "returned" (if dyn tickets_returned "--"))]]]
      [:div.column
       [:h4 {:style {:color heading-color :margin-bottom "4px"}} "Hoists & weight"]
       ;; the formatters and (or ... "--") own the nil->"--" fallback, so these
       ;; rows read the same whether dyn is loaded, absent, or has nil fields.
       [:table.table.is-narrow {:style {:background "transparent"}}
        [:tbody
         (stat-row "total hoist requests" (if dyn total_hoists "--"))
         (stat-row "hoists/sec avg" (if avg-per-sec (round-to avg-per-sec 4) "--"))
         (stat-row "hoists/sec max" (or max_per_sec "--"))
         (stat-row "longest quiet gap" (fmt-duration longest_gap_seconds))
         (stat-row "proof weight / hoist" (fmt-bytes ticket_weight_bytes))
         (stat-row "max proof weight" (fmt-bytes max_weight_bytes))]]]]]))

(defn draw-poptop-summary []
  ;; the summary is per poptop, so it only shows once a specific poptop is
  ;; chosen in the dropdown; "All lines" hides it entirely.
  (let [selected @selected-poptop-cursor]
    (when selected
      (let [elements  @lines-cursor
            by-hash   @lines-by-hash-cursor
            summary   @poptop-summary-cursor
            kids      (children-by-parent elements)
            ;; lines in the top spool cycle(s), excluded from the branch factor
            spool-ids (into #{} (mapcat identity (top-sccs elements)))
            poptop?   (some #(= selected (-> % :data :id)) (poptops elements))]
        [:div {:style {:margin "0 20px 20px"}}
         [:div {:style {:display "flex" :align-items "baseline" :gap "10px"
                        :margin-bottom "8px"}}
          [:h3 {:style {:color heading-color :margin 0}} "Poptop Summary"]
          [:span.button.is-small {:on-click get-lines} "refresh"]]
         (if poptop?
           [poptop-card selected
            (poptop-structure kids spool-ids by-hash selected)
            (get summary selected)
            true]
           [:p {:style {:color muted-color}} "loading…"])]))))

(defn draw-poptop-select []
  (let [elements (:lines @state)
        adj      (adjacency elements)
        pts      (poptops elements)
        selected @selected-poptop-cursor]
    [:div.field {:style {:margin "20px 20px 0"}}
     [:div.control
      [:div.select
       [:select
        {:value     (or selected "")
         :on-change (fn [e]
                      (let [v (.. e -target -value)]
                        (reset! selected-poptop-cursor (when-not (empty? v) v))))}
        [:option {:value ""} "All lines"]
        (for [pt  pts
              :let [h (-> pt :data :id)
                    n (count (component-ids adj h))]]
          ^{:key h}
          [:option {:value h} (str (trunc-hash h) " (" n " lines)")])]]]]))

(def legend-glyph-style
  {:font-size "14px" :padding "0" :margin "0"
   :line-height "14px" :height "14px" :width "14px"})

(defn legend-swatch
  "A circular legend chip filled with `bg`; any `children` render inside it
   (used for the α/β glyphs)."
  [{:keys [bg border-color border-width]} & children]
  [:span {:style {:margin "10px"}}
   (into [:div {:style {:height "38px" :width "38px"
                        :background-color bg
                        :border-radius "50%"
                        :border-color (or border-color "white")
                        :border-width (or border-width "5px")
                        :border-style "solid"
                        :display "flex" :flex-direction "column"
                        :align-items "center" :text-align "center"}}]
         children)])

(defn draw-legend []
  [:div.card
   [:div.card-header
    [:h2.card-header-title "legend:"
     [legend-swatch {:bg (number-to-color 0)}
      [:div {:style legend-glyph-style} "α"]
      [:div {:style legend-glyph-style} "β"]]
     [:span [:div "α = 'uncommitted'"] [:div "β = 'committed'"]]
     [legend-swatch {:bg (number-to-color 30)}]
     [:span "brighter background = more uncommitted"]
     [legend-swatch {:bg (number-to-color 0)
                     :border-color non-public-border-color :border-width "2px"}]
     [:span "thinner border = non-public"]
     [legend-swatch {:bg focused-background}]
     [:span "purple background = focused"]]]])

(defn draw-lines []
  (let [_ @focused-lines-cursor]
    (check-line-status)
    (r/create-class
     {:component-did-mount    (fn [_] (draw-graph :completed-graph))
      :component-did-update   (fn [_] (draw-graph :completed-graph))
      :component-will-unmount (fn [_] (destroy-graphs))
      :render                 (fn []
                                @lines-cursor
                                @expanded-cursor
                                @selected-poptop-cursor
                                [:div
                                 [draw-legend]
                                 [draw-poptop-select]
                                 ;; summary for the poptop chosen in the dropdown
                                 [draw-poptop-summary]
                                 ;; auto width (not a fixed 1200px) so the graph
                                 ;; fills the card and never overflows; border-box
                                 ;; keeps border/padding inside the width.
                                 [:div#completed-graph.card-content
                                  {:style
                                   {:border "solid"
                                    :border-color text-color
                                    :margin "20px"
                                    :max-width "100%"
                                    :height 500
                                    :box-sizing "border-box"
                                    :display "block"
                                    :color text-color
                                    :background page-bg}}]])})))

(defn loading-indicator
  "Shown outside draw-lines (so toggling it never rebuilds the graph) while the
  heavy async loads are still in flight; disappears once both have landed."
  []
  (let [stats?   @stats-loaded-cursor
        details? @details-loaded-cursor]
    (when-not (and stats? details?)
      [:span.tag.is-warning.is-light
       {:style {:margin "0 20px"}}
       "⏳ loading "
       (->> [(when-not stats? "hoist stats")
             (when-not details? "line details")]
            (remove nil?)
            (interpose " + ")
            (apply str))
       "…"])))

(defn draw-sidebar []
  [:div @sidebar-cursor])

(defn focus-line!
  "Add a line to the focused set and restyle the graph so it highlights."
  [line-twist]
  (swap! focused-lines-cursor (fnil conj #{}) line-twist)
  (set-node-style))

(defn slow-line-table
  "Table of slow lines. `value-header`/`value-key` label and read the timing
   column; clicking a line-twist focuses it in the graph. Wrapped in a
   table-container so the wide line-twist/id columns scroll instead of
   overflowing the card."
  [rows value-header value-key]
  [:div.table-container
   [:table.table.is-fullwidth
    [:thead [:tr [:th "line-twist"] [:th value-header] [:th "id"]]]
    [:tbody
     (for [{:keys [line_twist id] :as row} rows]
       ^{:key id}
       [:tr
        [:td [:a {:on-click #(focus-line! line_twist)} line_twist]]
        [:td (get row value-key)]
        [:td id]])]]])

(defn draw-slow-twists []
  [slow-line-table @slow-twists-cursor "time-to-twist-seconds" :time_to_twist_seconds])

(defn draw-slow-publishes []
  [slow-line-table @slow-publishes-cursor "time-to-publish-seconds" :time_to_publish_seconds])

(defn card
  "A bulma card with a titled header (plus optional header controls) and body."
  [title header-extra & body]
  [:div.card
   [:div.card-header
    (into [:h2.card-header-title title] header-extra)]
   (into [:div.card-content] body)])

(defn apply-line-select! []
  (reset! focused-lines-cursor
          (->> (string/split @line-select-text #"\s+") (remove empty?) set))
  (set-node-style))

(defn relay-controls []
  [:div
   [:div {:style {:display "flex" :align-items "center" :gap "10px" :flex-wrap "wrap"}}
    [:span.button.is-white.is-static "total lines: " (count (filter :meta @lines-cursor))]
    [:span.button {:on-click get-lines} "refresh lines"]
    [:span.button {:on-click (fn [_]
                               (reset! focused-lines-cursor #{})
                               (set-node-style))}
     "clear selected"]
    [loading-indicator]]
   ;; own full-width row; is-expanded lets the input fill the card while the
   ;; Select button stays attached and visible instead of overflowing.
   [:div.field.has-addons {:style {:margin-top "10px" :margin-bottom 0}}
    [:div.control.is-expanded
     [:input.input {:type        "text"
                    :placeholder "Select line-twists"
                    :value       @line-select-text
                    :on-change   #(reset! line-select-text (.. % -target -value))
                    :on-key-down #(when (= "Enter" (.-key %)) (apply-line-select!))}]]
    [:div.control
     [:button.button.is-info {:on-click (fn [_] (apply-line-select!))} "Select"]]]])

(defn home []
  [:div {:data-theme "dark"
         :style {:background page-bg}}
   [:nav.breadcrumb.is-right {:aria-label "breadcrumbs"}
    [:ul
     [:li [:a {:href js/RELAY_ANOMALIES_URL} "anomalies"]]]]
   [card "Relay Info" nil [relay-controls]]
   [card "Slow Twists in last week" nil [draw-slow-twists]]
   [card "Slow Publishes in last week" nil [draw-slow-publishes]]
   ;; the poptop summary now lives under the poptop dropdown inside draw-lines
   [draw-lines]
   [card "Line Info" nil [draw-sidebar]]])

(set! (.-background (.-style js/document.body)) page-bg)
(js/cytoscape.use (js/cytoscapePopper tooltip-factory))
(get-lines) ;; also kicks off /poptop-summary (see get-lines)
(get-slow-twists)
(get-slow-publishes)
(get-stats set-stats)
(watch-stats!)
(rdom/render [home] (.getElementById js/document "app"))