ui.html 90 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978
  1. <!doctype html>
  2. <html data-theme="dark">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>sm121</title>
  7. <style>
  8. :root[data-theme="dark"] {
  9. --bg: #0d1117; --panel: #161b22; --fg: #e6edf3; --muted: #8b949e;
  10. --border: #30363d; --accent: #58a6ff; --shade: rgba(0,0,0,.55);
  11. /* monospace content (raw/logs/jobs/node names) — the Alacritty font (~/.alacritty.toml) */
  12. --mono: "DMMono Nerd Font", Menlo, ui-monospace, SFMono-Regular, monospace;
  13. --led-on: #3fb950; --led-off: #f85149; --led-busy: #d29922; --led-free: #30363d;
  14. --glow-on: 0 0 8px 2px rgba(63,185,80,.55); --glow-busy: 0 0 8px 2px rgba(210,153,34,.55);
  15. --btn: #21262d; --btn-hover: #30363d;
  16. }
  17. :root[data-theme="light"] {
  18. --bg: #f6f8fa; --panel: #ffffff; --fg: #1f2328; --muted: #656d76;
  19. --border: #d0d7de; --accent: #0969da; --shade: rgba(31,35,40,.20);
  20. --mono: "DMMono Nerd Font", Menlo, ui-monospace, SFMono-Regular, monospace;
  21. --led-on: #1a7f37; --led-off: #cf222e; --led-busy: #9a6700; --led-free: #8c959f;
  22. --glow-on: 0 0 8px 2px rgba(26,127,55,.35); --glow-busy: 0 0 8px 2px rgba(154,103,0,.35);
  23. --btn: #f6f8fa; --btn-hover: #eaeef2;
  24. }
  25. * { box-sizing: border-box; }
  26. html, body { height: 100%; }
  27. body {
  28. margin: 0; background: var(--bg); color: var(--fg);
  29. font: 14px/1.45 ui-sans-serif, system-ui, "SF Pro", sans-serif;
  30. display: flex; flex-direction: column;
  31. transition: background .25s, color .25s;
  32. }
  33. header {
  34. display: flex; align-items: center; gap: 12px;
  35. padding: 10px 16px; border-bottom: 1px solid var(--border);
  36. background: var(--panel); position: sticky; top: 0; z-index: 5;
  37. }
  38. header h1 { font-size: 15px; margin: 0; font-weight: 650; letter-spacing: .3px; }
  39. header .spacer { flex: 1; }
  40. .ssec { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
  41. .ssec .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--led-off); }
  42. .ssec.ok .dot { background: var(--led-on); }
  43. .ssec.re .dot { animation: pulse 1s infinite; }
  44. button.tbtn {
  45. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  46. border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 13px;
  47. }
  48. button.tbtn:hover { background: var(--btn-hover); }
  49. /* --- window manager ------------------------------------------------------ */
  50. #winlayer { position: relative; flex: 1; overflow: hidden; }
  51. .win {
  52. position: absolute; background: var(--panel); border: 1px solid var(--border);
  53. border-radius: 10px; display: flex; flex-direction: column;
  54. box-shadow: 0 6px 24px rgba(0,0,0,.35); animation: winIn .3s ease both;
  55. }
  56. /* maximized: the frame disappears and the window IS the layer; its title
  57. moves into the header (with a restore button) */
  58. .win.max { border: 0; border-radius: 0; box-shadow: none; }
  59. .win.max .win-bar, .win.max .win-resize { display: none; }
  60. /* `display: flex` would override the `hidden` attribute (whose UA rule is
  61. just `display: none`) — guard it, like .dlg-back does */
  62. #maxinfo { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); }
  63. #maxinfo[hidden] { display: none; }
  64. #maxinfo .maxtitle { color: var(--fg); font-weight: 600; }
  65. #maxinfo .wbtn { width: 22px; height: 20px; font-size: 12px; }
  66. .win-bar {
  67. display: flex; align-items: center; gap: 6px; padding: 7px 10px;
  68. border-bottom: 1px solid var(--border); cursor: grab; user-select: none;
  69. font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted);
  70. flex-shrink: 0;
  71. }
  72. .win-bar:active { cursor: grabbing; }
  73. .win-bar .wtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  74. .wbtn {
  75. background: none; border: 1px solid transparent; color: var(--muted);
  76. border-radius: 5px; width: 20px; height: 18px; cursor: pointer; font: inherit; font-size: 12px;
  77. line-height: 1; padding: 0;
  78. }
  79. .wbtn:hover { background: var(--btn-hover); color: var(--fg); }
  80. .win-body { flex: 1; overflow: auto; padding: 12px 14px; }
  81. .win-resize {
  82. position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; cursor: nwse-resize;
  83. background: linear-gradient(135deg, transparent 50%, var(--border) 50%);
  84. }
  85. #dock {
  86. display: flex; gap: 6px; align-items: center; padding: 6px 12px;
  87. border-top: 1px solid var(--border); background: var(--panel); min-height: 34px; flex-shrink: 0;
  88. }
  89. #dock:empty { display: none; }
  90. .dock-chip {
  91. background: var(--btn); color: var(--muted); border: 1px solid var(--border);
  92. border-radius: 999px; padding: 2px 10px; font-size: 12px; cursor: pointer; font: inherit;
  93. }
  94. .dock-chip:hover { color: var(--fg); border-color: var(--accent); }
  95. /* --- cluster board ------------------------------------------------------- */
  96. .cluster.anim { animation: winIn .35s ease both; }
  97. .cluster { margin-bottom: 14px; }
  98. .cluster-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
  99. .cluster-head b { font-size: 13px; }
  100. .cluster-head .free { color: var(--muted); font-size: 12px; }
  101. .cluster-head .down { color: var(--led-off); font-size: 12px; }
  102. .cluster-head .soft { color: var(--led-busy); font-size: 12px; }
  103. .node {
  104. display: flex; align-items: center; gap: 10px; padding: 5px 0;
  105. border-bottom: 1px solid var(--border); cursor: pointer;
  106. }
  107. .node:hover { background: var(--btn); }
  108. .node:last-child { border-bottom: none; }
  109. .led {
  110. width: 11px; height: 11px; border-radius: 50%; background: var(--led-off);
  111. transition: background .4s, box-shadow .4s; flex-shrink: 0;
  112. }
  113. .led.on { background: var(--led-on); box-shadow: var(--glow-on); }
  114. .led.b { background: var(--led-busy); box-shadow: var(--glow-busy); }
  115. .led.off { background: var(--led-off); }
  116. .led.flip { animation: flip .45s ease; }
  117. .nname { width: 64px; font-family: var(--mono); font-size: 12.5px; }
  118. .nmeta { color: var(--muted); font-size: 12px; flex: 1; overflow: hidden; text-overflow: ellipsis; }
  119. .containers { width: 100%; margin-top: 2px; }
  120. .crow { display: flex; align-items: center; gap: 8px; padding: 2px 0 2px 21px; font-size: 12px; color: var(--muted); }
  121. .cled { width: 7px; height: 7px; border-radius: 50%; background: var(--led-busy); box-shadow: var(--glow-busy); flex-shrink: 0; }
  122. .cname { font-family: var(--mono); color: var(--fg); }
  123. .actions { display: flex; gap: 8px; align-items: center; margin: 6px 0 0 21px; flex-wrap: wrap; }
  124. .actions select { font: inherit; }
  125. .actions input[type=checkbox] {
  126. appearance: none; -webkit-appearance: none;
  127. width: 15px; height: 15px; margin: 0; flex-shrink: 0;
  128. border: 1.5px solid var(--border); border-radius: 4px; background: var(--btn);
  129. cursor: pointer; position: relative; transition: background .15s, border-color .15s;
  130. }
  131. .actions input[type=checkbox]:hover { border-color: var(--accent); }
  132. .actions input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
  133. .actions input[type=checkbox]:checked::after {
  134. content: ""; position: absolute; left: 3.5px; top: 0;
  135. width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
  136. transform: rotate(45deg);
  137. }
  138. .actions select {
  139. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  140. border-radius: 6px; padding: 2px 6px; font-size: 12.5px; max-width: 260px; height: 26px;
  141. }
  142. .actions label { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; height: 26px; }
  143. .actions label input { margin: 0; }
  144. .strip { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
  145. .chip {
  146. display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none;
  147. border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; font-size: 12px;
  148. color: var(--muted); background: var(--btn); transition: border-color .15s, color .15s;
  149. }
  150. .chip .m { opacity: .7; }
  151. .chip.active { color: var(--fg); border-color: var(--accent); }
  152. .abtn {
  153. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  154. border-radius: 6px; padding: 0 12px; cursor: pointer; font: inherit; font-size: 12.5px;
  155. height: 26px; transition: background .15s;
  156. }
  157. .abtn:hover:not(:disabled) { background: var(--btn-hover); }
  158. .abtn:disabled { opacity: .45; cursor: default; }
  159. .abtn.danger { color: var(--led-off); }
  160. .jobs { font-family: var(--mono); font-size: 12px; }
  161. .job { display: flex; gap: 8px; align-items: baseline; padding: 4px 0; border-bottom: 1px solid var(--border); }
  162. .job.new { animation: winIn .3s ease both; }
  163. .job:last-child { border-bottom: none; }
  164. .jdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; align-self: center; }
  165. .job.ok .jdot { background: var(--led-on); }
  166. .job.failed .jdot { background: var(--led-off); }
  167. .jkind { width: 48px; color: var(--muted); }
  168. .jmsg { color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  169. .jjob .jmsg { color: var(--fg); }
  170. .jobs-empty { color: var(--muted); font-size: 12.5px; padding: 6px 0; }
  171. /* --- log windows ---------------------------------------------------------- */
  172. .logwin .win-body { display: flex; flex-direction: column; padding: 0; }
  173. .logbar {
  174. display: flex; gap: 8px; align-items: center; padding: 8px 10px;
  175. border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap;
  176. }
  177. .logbar select {
  178. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  179. border-radius: 6px; padding: 2px 6px; font-size: 12px; max-width: 300px; height: 26px;
  180. }
  181. .logbar label { font-size: 12px; color: var(--muted); display: inline-flex; gap: 4px; align-items: center; }
  182. .logbar input[type=checkbox] {
  183. appearance: none; -webkit-appearance: none; width: 15px; height: 15px; margin: 0;
  184. border: 1.5px solid var(--border); border-radius: 4px; background: var(--btn);
  185. cursor: pointer; position: relative;
  186. }
  187. .logbar input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
  188. .logbar input[type=checkbox]:checked::after {
  189. content: ""; position: absolute; left: 3.5px; top: 0;
  190. width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
  191. transform: rotate(45deg);
  192. }
  193. .lognote { font-size: 12px; color: var(--muted); flex: 1; }
  194. .lognote.dead { color: var(--led-busy); }
  195. .logpre {
  196. flex: 1; margin: 0; padding: 10px 12px; overflow: auto;
  197. font: 11.5px/1.5 var(--mono); white-space: pre-wrap;
  198. word-break: break-word; color: var(--fg);
  199. }
  200. /* --- raw content windows --------------------------------------------------- */
  201. .rawwin .win-body { display: flex; flex-direction: column; padding: 0; }
  202. .recwin .win-body { padding: 0; }
  203. .recwin iframe { width: 100%; height: 100%; border: 0; display: block; background: var(--panel); }
  204. /* the embedded report page resolves `color-scheme: light dark` from this
  205. element, so it follows the dashboard theme instead of the OS one */
  206. :root[data-theme="dark"] .recwin iframe { color-scheme: dark; }
  207. :root[data-theme="light"] .recwin iframe { color-scheme: light; }
  208. .rawbar {
  209. display: flex; gap: 8px; align-items: center; padding: 8px 10px;
  210. border-bottom: 1px solid var(--border); flex-shrink: 0;
  211. }
  212. .rawbar select {
  213. background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  214. border-radius: 6px; padding: 2px 6px; font-size: 12px; max-width: 320px; height: 26px;
  215. }
  216. .rawbar .abtn { height: 26px; }
  217. .rawnote { font-size: 12px; color: var(--muted); flex: 1; }
  218. /* --- polish: skeleton + attention ------------------------------------------ */
  219. .skeleton { height: 14px; border-radius: 6px; margin: 10px 0;
  220. background: linear-gradient(90deg, var(--btn) 25%, var(--btn-hover) 50%, var(--btn) 75%);
  221. background-size: 200% 100%; animation: shimmer 1.2s infinite; }
  222. @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
  223. .cled.exited { background: var(--led-off); box-shadow: 0 0 8px 2px rgba(248,81,73,.5); animation: pulse 1.2s infinite; }
  224. .abtn:disabled { animation: pulse 1.2s infinite; }
  225. .job.jjob .jdot { animation: pulse 1.2s infinite; }
  226. /* --- serve flags (CLI parity) + node logs button + dock menu ---------------- */
  227. .fnode {
  228. background: var(--btn); color: var(--muted); border: 1px solid var(--border);
  229. border-radius: 6px; padding: 2px 6px; font-size: 12px; height: 26px; max-width: 140px;
  230. }
  231. .flags summary {
  232. cursor: pointer; font-size: 12px; color: var(--muted); height: 26px;
  233. display: inline-flex; align-items: center; padding: 0 8px;
  234. border: 1px dashed var(--border); border-radius: 6px; user-select: none;
  235. }
  236. .flags summary:hover { color: var(--fg); border-color: var(--accent); }
  237. .flags-in {
  238. display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;
  239. padding: 8px; background: var(--btn); border-radius: 6px;
  240. }
  241. .flags-in label { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 4px; }
  242. .flags-in input[type=checkbox] {
  243. appearance: none; -webkit-appearance: none; width: 14px; height: 14px; margin: 0;
  244. border: 1.5px solid var(--border); border-radius: 4px; background: var(--bg);
  245. cursor: pointer; position: relative;
  246. }
  247. .flags-in input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
  248. .flags-in input[type=checkbox]:checked::after {
  249. content: ""; position: absolute; left: 3px; top: 0; width: 3.5px; height: 7px;
  250. border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
  251. }
  252. .flags-in input[type=text] {
  253. background: var(--bg); color: var(--fg); border: 1px solid var(--border);
  254. border-radius: 5px; padding: 1px 5px; font: inherit; font-size: 12px; height: 22px;
  255. }
  256. .logsbtn { margin-left: auto; height: 20px; font-size: 11px; padding: 0 8px; flex-shrink: 0; }
  257. .win.flash { outline: 2px solid var(--accent); outline-offset: -1px; }
  258. .dockmenu {
  259. position: fixed; bottom: 44px; right: 12px; display: flex; flex-direction: column; gap: 4px;
  260. background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 8px;
  261. box-shadow: 0 12px 40px rgba(0,0,0,.5); z-index: 300; animation: winIn .15s ease both;
  262. }
  263. /* window switcher menu (header): same look, anchored below the header */
  264. .winmenu { position: fixed; top: 42px; right: 12px; bottom: auto;
  265. display: flex; flex-direction: column; gap: 2px;
  266. background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 8px;
  267. box-shadow: 0 12px 40px rgba(0,0,0,.5); z-index: 300; animation: winIn .15s ease both; }
  268. .winmenu .dock-chip { text-align: left; }
  269. .winmenu .wmax-on { font-weight: 600; }
  270. /* --- confirm dialog ------------------------------------------------------- */
  271. .dlg-back[hidden] { display: none; }
  272. .dlg-back {
  273. position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 400;
  274. display: flex; align-items: center; justify-content: center; animation: fadeIn .15s ease;
  275. }
  276. .dlg {
  277. background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
  278. padding: 18px; width: min(460px, 92vw); max-height: 80vh; overflow: auto;
  279. box-shadow: 0 12px 40px rgba(0,0,0,.5); animation: winIn .2s ease both;
  280. }
  281. .dlg h3 { margin: 0 0 10px; font-size: 14px; }
  282. .dlg .body { font-size: 13px; color: var(--fg); margin-bottom: 12px; }
  283. .dlg .body code { font-family: var(--mono); font-size: 12px; background: var(--btn); padding: 1px 5px; border-radius: 4px; }
  284. .dlg .cbox { font-family: var(--mono); font-size: 12px; color: var(--muted); margin: 8px 0 0; padding: 8px; background: var(--btn); border-radius: 6px; }
  285. .dlg .cbox li { margin: 2px 0; }
  286. .dlg input {
  287. width: 100%; background: var(--btn); color: var(--fg); border: 1px solid var(--border);
  288. border-radius: 6px; padding: 6px 8px; font: inherit; margin: 8px 0;
  289. }
  290. .dlg .row { display: flex; justify-content: flex-end; gap: 8px; }
  291. @keyframes flip { 0% { transform: scale(1); } 40% { transform: scale(1.5); } 100% { transform: scale(1); } }
  292. @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
  293. @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  294. @keyframes winIn { from { opacity: 0; transform: translateY(-4px) scale(.985); } to { opacity: 1; transform: none; } }
  295. @media (prefers-reduced-motion: reduce) {
  296. *, .led, .led.flip, .ssec.re .dot, .cluster, .job, .dlg, .win, .skeleton, .cled.exited, .abtn:disabled { animation: none !important; }
  297. }
  298. /* --- topology map ------------------------------------------------------- */
  299. #topo { height: 100%; overflow: hidden; }
  300. #topo svg { width: 100%; height: 100%; display: block; }
  301. #topo .topo-empty { height: 100%; display: grid; place-items: center; color: var(--muted); }
  302. #topo text { font: 12px/1.25 ui-sans-serif, system-ui; fill: var(--fg); }
  303. #topo .sub { fill: var(--muted); font-size: 10px; }
  304. #topo .dev { transition: opacity .5s, filter .5s; }
  305. #topo .dev.unpowered { opacity: .7; filter: grayscale(1); }
  306. #topo .dev.unpowered .body { fill: var(--bg); stroke-dasharray: 4 3; filter: none; }
  307. #topo .dev .body { fill: color-mix(in srgb, var(--fg) 5%, var(--panel)); stroke: color-mix(in srgb, var(--fg) 55%, transparent); stroke-width: 1.4; filter: drop-shadow(0 1px 2px var(--shade)); }
  308. #topo .dev .top { fill: color-mix(in srgb, var(--fg) 12%, transparent); }
  309. #topo .dev .vent { fill: color-mix(in srgb, var(--fg) 32%, transparent); }
  310. #topo .dev .plate { fill: color-mix(in srgb, var(--fg) 10%, var(--panel)); }
  311. #topo .dev .nm { font-size: 12.5px; font-weight: 600; }
  312. #topo .dev .ip { font-size: 10px; fill: var(--muted); font-family: var(--mono); }
  313. #topo .dev .ctr { fill: color-mix(in srgb, var(--muted) 22%, transparent); stroke: color-mix(in srgb, var(--muted) 60%, transparent); }
  314. #topo .dev .ctr.hot { fill: color-mix(in srgb, var(--led-busy) 35%, transparent); }
  315. #topo .dev .ctrt { font-size: 8.5px; fill: var(--muted); }
  316. #topo .dev.planned .body { stroke-dasharray: 4 4; opacity: .5; }
  317. .pled { fill: var(--led-free); }
  318. .pled.on { fill: var(--led-on); filter: drop-shadow(0 0 3px var(--led-on)); }
  319. .pled.b { fill: var(--led-busy); filter: drop-shadow(0 0 3px var(--led-busy)); animation: pulse 1.6s infinite; }
  320. .pled.off { fill: var(--led-off); }
  321. #topo .cable { fill: none; stroke-linecap: round; stroke-linejoin: round; }
  322. #topo .c-200g { stroke: color-mix(in srgb, var(--accent) 78%, transparent); stroke-width: 2.6; }
  323. #topo .c-dac { stroke: color-mix(in srgb, #a371f7 78%, transparent); stroke-width: 2.6; }
  324. #topo .c-10g { stroke: color-mix(in srgb, var(--muted) 68%, transparent); stroke-width: 1.5; }
  325. #topo .flow { opacity: 0; transition: opacity .6s; }
  326. #topo .flow.live { opacity: .9; }
  327. #topo .flow.c-200g { stroke: var(--accent); stroke-dasharray: 4 10; animation: flow 1.1s linear infinite; }
  328. #topo .flow.c-dac { stroke: #a371f7; stroke-dasharray: 4 10; animation: flow 1.1s linear infinite; }
  329. #topo .flow.c-10g { stroke: color-mix(in srgb, var(--fg) 50%, transparent); stroke-width: 1.5; stroke-dasharray: 3 9; animation: flow 1.6s linear infinite; }
  330. /* bus-mode crossings: the 10g run hops over the 200g lane */
  331. #topo .bridge { fill: none; stroke: color-mix(in srgb, var(--fg) 70%, transparent); stroke-width: 1.5; }
  332. #topo .cablemode { cursor: pointer; fill: var(--muted); font-size: 11px; }
  333. #topo .cablemode:hover { fill: var(--fg); text-decoration: underline; }
  334. @keyframes flow { to { stroke-dashoffset: -14; } }
  335. #topo .port { fill: var(--led-free); }
  336. #topo .port.on { fill: var(--led-on); }
  337. #topo .swbox { fill: color-mix(in srgb, var(--fg) 5%, var(--panel)); stroke: color-mix(in srgb, var(--fg) 55%, transparent); stroke-width: 1.4; }
  338. #topo .sw .swbox { transition: stroke .4s; }
  339. #topo .sw.ib.on .swbox { stroke: color-mix(in srgb, var(--led-on) 65%, transparent); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--led-on) 35%, transparent)); }
  340. #topo .sw.ib.off .swbox { stroke: color-mix(in srgb, var(--led-off) 75%, transparent); }
  341. #topo .sw.ib.unk .swbox { stroke: color-mix(in srgb, var(--led-busy) 60%, transparent); }
  342. #topo .fan .fbody { fill: color-mix(in srgb, var(--fg) 5%, var(--panel)); stroke: color-mix(in srgb, var(--fg) 55%, transparent); stroke-width: 1.4; }
  343. #topo .fan .blade { fill: color-mix(in srgb, var(--fg) 34%, transparent); }
  344. #topo .fan .hub { fill: color-mix(in srgb, var(--fg) 50%, transparent); }
  345. #topo .fan .blades { transform-origin: 770px 318px; animation: spin 1.4s linear infinite; }
  346. #topo .fan.off .blades { animation: none; opacity: .4; }
  347. #topo .fan.unk .blades { animation: spin 4s linear infinite; opacity: .55; }
  348. @keyframes spin { to { transform: rotate(1turn); } }
  349. #topo .cluster-frame { cursor: pointer; }
  350. #topo .cluster-frame:hover .frame { stroke-width: 2.2; }
  351. #topo .fchip { cursor: pointer; }
  352. #topo .fchip:hover rect { stroke-width: 2; }
  353. #topo .fchip.down { filter: saturate(.5); }
  354. #topo .frame { stroke-width: 1.5; transition: opacity .4s; }
  355. #topo .cluster-frame.down .frame { stroke-dasharray: 5 4; opacity: .5; }
  356. #topo .fchip rect { fill: var(--panel); stroke-width: 1.2; }
  357. #topo .fchip text { font-size: 10.5px; letter-spacing: .3px; font-weight: 600; }
  358. #topo .fchip .cdot { fill: var(--led-off); }
  359. #topo .fchip .cdot.on { fill: var(--led-on); }
  360. #topo .legend text { font-size: 10.5px; fill: var(--muted); }
  361. </style>
  362. </head>
  363. <body>
  364. <header>
  365. <h1>sm121</h1>
  366. <div id="maxinfo" hidden><span>⛶</span><span class="maxtitle" id="max-title"></span>
  367. <button class="wbtn" id="max-restore" title="restore window size">❐</button>
  368. <button class="wbtn" id="max-min" title="minimize">–</button>
  369. <button class="wbtn" id="max-close" title="close">×</button></div>
  370. <div class="spacer"></div>
  371. <button class="tbtn" id="winmenu" title="all open windows (switch without losing maximize state)">▦</button>
  372. <div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
  373. <button class="tbtn" id="theme">◐</button>
  374. </header>
  375. <div id="winlayer"></div>
  376. <div id="dock"></div>
  377. <div class="dlg-back" id="dlg" hidden>
  378. <div class="dlg">
  379. <h3 id="dlg-title"></h3>
  380. <div class="body" id="dlg-body"></div>
  381. <div class="row">
  382. <button class="tbtn" id="dlg-cancel">Cancel</button>
  383. <button class="abtn" id="dlg-ok">Confirm</button>
  384. </div>
  385. </div>
  386. </div>
  387. <script>
  388. "use strict";
  389. const $ = (s) => document.querySelector(s);
  390. const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
  391. const state = { snap: null, jobs: new Map(), recipes: [], active: localStorage.getItem("sm121-active") || "", panel: null, shown: new Set(), flags: {} };
  392. /*FLAGS-LT:START*/
  393. // Pure: build the POST /api/serve body from cluster + recipe + dry + the
  394. // per-cluster flags state. Absent keys are omitted (server defaults win):
  395. // fit "" = bare --fit (whole cluster), mirror "" = bare --mirror (default
  396. // path); a non-empty path wins over the bare checkbox.
  397. function serveBody(cluster, recipe, dry, f) {
  398. const body = { cluster, recipe, dry_run: !!dry };
  399. if (f.node) body.node = f.node;
  400. if (f.solo) body.solo = true;
  401. if (f.force) body.force = true;
  402. if (f.clear) body.clear = true;
  403. if (f.keep) body.keep = true;
  404. const fit = String(f.fit || "").trim();
  405. if (fit) body.fit = fit;
  406. else if (f.fitAll) body.fit = "";
  407. const mir = String(f.mirror || "").trim();
  408. if (mir) body.mirror = mir;
  409. else if (f.mirrorAll) body.mirror = "";
  410. return body;
  411. }
  412. function flagsOf(cluster) {
  413. return (state.flags[cluster] = state.flags[cluster] || { node: "", solo: false, force: false, clear: false, keep: false, fit: "", fitAll: false, mirror: "", mirrorAll: false });
  414. }
  415. /*FLAGS-LT:END*/
  416. /*SNAP-LT:START*/
  417. // Pure: same fleet + jobs? (`ts` changes on every probe, so it is
  418. // excluded). Used to skip DOM rebuilds when nothing changed — no blink.
  419. // (log-attaches are intentionally excluded: they change out-of-band and are
  420. // tracked in their own windows, not the cluster board)
  421. function sameSnapshot(a, b) {
  422. return JSON.stringify({ c: a.clusters, j: a.jobs }) === JSON.stringify({ c: b.clusters, j: b.jobs });
  423. }
  424. /*SNAP-LT:END*/
  425. // --- theme ---------------------------------------------------------------
  426. const root = document.documentElement;
  427. root.dataset.theme = localStorage.getItem("sm121-theme") || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
  428. $("#max-restore").onclick = () => maxedHandle?.unmaximize();
  429. $("#max-min").onclick = () => maxedHandle?.minimize();
  430. $("#max-close").onclick = () => maxedHandle?.close();
  431. $("#theme").onclick = () => {
  432. root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
  433. localStorage.setItem("sm121-theme", root.dataset.theme);
  434. };
  435. // --- window manager ------------------------------------------------------
  436. // Vanilla win manager: absolute windows, drag by titlebar, corner resize,
  437. // minimize/close to the dock (chip to restore), click-to-front z-order.
  438. let ztop = 20;
  439. const wins = {}; // named windows: { topo, state, jobs }
  440. // every window ever created (named, logs, raw, recipes) — the dock lists
  441. // minimized ones from here, so a window can't be minimized into nowhere
  442. const winRegistry = new Set();
  443. const logWins = new Map(); // attach_id -> { win, pre, buf, paused, ... }
  444. const front = (el) => { el.style.zIndex = ++ztop; };
  445. // Multiple windows can be maximized at once — each keeps its own state —
  446. // but only one is shown: the rest stay maximized and merely hidden, and
  447. // the header switcher (▦) moves between them without losing anything.
  448. let maxedHandle = null; // the maximized window currently on screen
  449. function showMaxTitle(h) {
  450. if (maxedHandle && maxedHandle !== h) maxedHandle.win.style.display = "none";
  451. maxedHandle = h;
  452. h.win.style.display = "";
  453. front(h.win);
  454. $("#max-title").textContent = h.title;
  455. $("#maxinfo").hidden = false;
  456. }
  457. function hideMaxTitle(h) {
  458. if (maxedHandle !== h) return;
  459. maxedHandle = null;
  460. // a still-maximized window behind this one comes to the front of the
  461. // maximized stack — the layer never goes back to bare
  462. const others = [...winRegistry].filter(
  463. (x) => x !== h && x.isMaximized() && !x.isClosed() && x.win.isConnected,
  464. );
  465. if (others.length) showMaxTitle(others[others.length - 1]);
  466. else $("#maxinfo").hidden = true;
  467. }
  468. // Bring any open window forward: un-minimize if needed, and if it is
  469. // maximized, make it the one shown (hiding the other maximized ones).
  470. function activateWindow(h) {
  471. if (h.isMinimized()) h.restore();
  472. if (h.isMaximized()) {
  473. for (const o of winRegistry)
  474. if (o !== h && o.isMaximized() && !o.isClosed() && o.win.isConnected) o.win.style.display = "none";
  475. h.win.style.display = "";
  476. showMaxTitle(h);
  477. } else front(h.win);
  478. saveWinState();
  479. }
  480. let winMenuEl = null;
  481. function toggleWinMenu() {
  482. if (winMenuEl) { winMenuEl.remove(); winMenuEl = null; return; }
  483. const m = document.createElement("div");
  484. m.className = "winmenu";
  485. const open = [...winRegistry].filter((h) => !h.isClosed() && h.win.isConnected);
  486. const glyph = (h) => (h.isMaximized() ? "⛶ " : h.isMinimized() ? "– " : "▪ ");
  487. for (const h of open) {
  488. const b = document.createElement("button");
  489. b.className = "dock-chip" + (h === maxedHandle ? " wmax-on" : "");
  490. b.textContent = glyph(h) + h.label;
  491. b.title = (h.isMaximized() ? "maximized" : h.isMinimized() ? "minimized" : "floating") + " — click to show";
  492. b.onclick = () => { winMenuEl.remove(); winMenuEl = null; activateWindow(h); };
  493. m.appendChild(b);
  494. }
  495. document.body.appendChild(m);
  496. winMenuEl = m;
  497. setTimeout(() => addEventListener("click", () => { m.remove(); winMenuEl = null; }, { once: true }), 0);
  498. }
  499. $("#winmenu").onclick = (e) => { e.stopPropagation(); toggleWinMenu(); };
  500. // --- window layout persistence -------------------------------------------
  501. // The dashboard has no session: geometry, minimized/closed/maximized and
  502. // which optional windows are open are kept in localStorage and restored on
  503. // load (log windows come back from the server's attach registry instead).
  504. const WS_KEY = "sm121-wins";
  505. let restoring = false; // don't write half-restored state back
  506. function readWinState() {
  507. try { return JSON.parse(localStorage.getItem(WS_KEY) || "{}"); } catch { return {}; }
  508. }
  509. function saveWinState() {
  510. if (restoring) return;
  511. const st = {};
  512. const put = (k, h) => {
  513. st[k] = { ...h.geometry(), min: h.isMinimized(), closed: h.isClosed(), max: h.isMaximized() };
  514. };
  515. for (const [k, h] of Object.entries(wins)) put(k, h);
  516. if (recipesWin) put("recipes", recipesWin);
  517. if (rawWin) put("raw", rawWin);
  518. try { localStorage.setItem(WS_KEY, JSON.stringify(st)); } catch { /* private mode */ }
  519. }
  520. // Re-open an optional window (recipes/raw) exactly as it was left.
  521. function restoreWindow(key, open) {
  522. const st = readWinState()[key];
  523. if (!st || st.closed) return;
  524. Promise.resolve(open(st)).then((h) => {
  525. if (st.max) h.maximize();
  526. else if (st.min) h.minimize();
  527. });
  528. }
  529. // Windows keep px geometry, so a shrinking layer (dock appears, browser
  530. // resize) would push their bottom edge out of view — clamp them back in.
  531. function fitToLayer() {
  532. const layer = $("#winlayer");
  533. const W = layer.clientWidth, H = layer.clientHeight;
  534. for (const h of winRegistry) {
  535. const w = h.win;
  536. if (!w.isConnected || w.classList.contains("max")) continue;
  537. const left = parseFloat(w.style.left) || 0, top = parseFloat(w.style.top) || 0;
  538. if (left + w.offsetWidth > W) w.style.width = Math.max(240, W - left) + "px";
  539. if (top + w.offsetHeight > H) {
  540. if (top > H - 120) w.style.top = Math.max(0, H - 200) + "px";
  541. w.style.height = Math.max(140, H - (parseFloat(w.style.top) || 0)) + "px";
  542. }
  543. }
  544. }
  545. function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, removeOnClose }) {
  546. const win = document.createElement("section");
  547. win.className = "win" + (extraCls ? " " + extraCls : "");
  548. if (id) win.id = id;
  549. win.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;z-index:${++ztop}`;
  550. const bar = document.createElement("div");
  551. bar.className = "win-bar";
  552. bar.innerHTML = `<span class="wtitle">${esc(title)}</span>
  553. <button class="wbtn" data-act="max" title="maximize">▢</button>
  554. <button class="wbtn" data-act="min" title="minimize">–</button>
  555. <button class="wbtn" data-act="close" title="close">×</button>`;
  556. const body = document.createElement("div");
  557. body.className = "win-body";
  558. body.innerHTML = bodyHtml;
  559. const rz = document.createElement("div");
  560. rz.className = "win-resize";
  561. win.append(bar, body, rz);
  562. $("#winlayer").appendChild(win);
  563. const closed = { value: false };
  564. const doMin = () => {
  565. // minimize keeps the maximize state: a maximized window minimized goes
  566. // to the maximized stack (▦ switcher) and comes back full-screen
  567. win.style.display = "none";
  568. if (maxedHandle === handle) hideMaxTitle(handle);
  569. renderDock();
  570. saveWinState();
  571. };
  572. bar.querySelector('[data-act="min"]').onclick = doMin;
  573. // maximize = drop the frame and fill the window layer (the dock is a
  574. // sibling below it, so it stays usable); the title moves to the header
  575. const maxBtn = bar.querySelector('[data-act="max"]');
  576. let geom = null;
  577. const unmax = () => {
  578. if (!geom) return;
  579. win.classList.remove("max");
  580. Object.assign(win.style, geom);
  581. geom = null;
  582. maxBtn.textContent = "▢";
  583. maxBtn.title = "maximize";
  584. hideMaxTitle(handle);
  585. saveWinState();
  586. };
  587. maxBtn.onclick = () => {
  588. front(win);
  589. if (geom) { unmax(); return; }
  590. geom = { left: win.style.left, top: win.style.top, width: win.style.width, height: win.style.height };
  591. win.classList.add("max");
  592. win.style.left = "0px";
  593. win.style.top = "0px";
  594. win.style.width = "100%";
  595. win.style.height = "100%";
  596. maxBtn.textContent = "❐";
  597. maxBtn.title = "restore size";
  598. showMaxTitle(handle);
  599. saveWinState();
  600. };
  601. const doClose = () => {
  602. closed.value = true;
  603. hideMaxTitle(handle);
  604. if (removeOnClose) {
  605. // log/recipes/raw: gone for good (reopened from the + menu)
  606. win.remove();
  607. winRegistry.delete(handle);
  608. } else {
  609. // named windows stay addressable: the dock offers a + chip
  610. win.style.display = "none";
  611. }
  612. renderDock();
  613. saveWinState();
  614. if (onClosed) onClosed();
  615. };
  616. bar.querySelector('[data-act="close"]').onclick = doClose;
  617. bar.addEventListener("pointerdown", (e) => {
  618. if (e.target.closest(".wbtn") || win.classList.contains("max")) return;
  619. front(win);
  620. const ox = win.offsetLeft, oy = win.offsetTop; // baseline — read ONCE
  621. drag(
  622. e.clientX,
  623. e.clientY,
  624. (dx, dy) => {
  625. win.style.left = Math.max(0, ox + dx) + "px";
  626. win.style.top = Math.max(0, oy + dy) + "px";
  627. },
  628. saveWinState,
  629. );
  630. });
  631. rz.addEventListener("pointerdown", (e) => {
  632. e.stopPropagation();
  633. front(win);
  634. const ow = win.offsetWidth, oh = win.offsetHeight; // baseline — read ONCE
  635. drag(
  636. e.clientX,
  637. e.clientY,
  638. (dx, dy) => {
  639. win.style.width = Math.max(240, ow + dx) + "px";
  640. win.style.height = Math.max(140, oh + dy) + "px";
  641. },
  642. saveWinState,
  643. );
  644. });
  645. win.addEventListener("pointerdown", () => front(win));
  646. const handle = {
  647. win, body, title,
  648. label: short || title,
  649. unmaximize: unmax,
  650. minimize: doMin,
  651. close: doClose,
  652. maximize: () => maxBtn.onclick(),
  653. isMaximized: () => !!geom,
  654. setGeometry(g) {
  655. if (!g) return;
  656. const px = (v, d) => (Number.isFinite(parseFloat(v)) ? parseFloat(v) : d);
  657. win.style.left = Math.max(0, px(g.left, 0)) + "px";
  658. win.style.top = Math.max(0, px(g.top, 0)) + "px";
  659. win.style.width = Math.max(240, px(g.width, 600)) + "px";
  660. win.style.height = Math.max(140, px(g.height, 400)) + "px";
  661. },
  662. // the normal (un-maximized) rect — that is what gets persisted
  663. geometry: () => {
  664. const g = geom || { left: win.style.left, top: win.style.top, width: win.style.width, height: win.style.height };
  665. return { left: g.left, top: g.top, width: g.width, height: g.height };
  666. },
  667. restore() { win.style.display = ""; closed.value = false; front(win); renderDock(); },
  668. // a hidden maximized window is display:none but NOT minimized — it
  669. // waits in the maximized stack, reachable from the ▦ switcher
  670. isMinimized: () => win.style.display === "none" && !geom,
  671. isClosed: () => closed.value,
  672. };
  673. winRegistry.add(handle);
  674. return handle;
  675. }
  676. function drag(sx, sy, move, done) {
  677. const st = { x: sx, y: sy };
  678. const mv = (e) => move(e.clientX - st.x, e.clientY - st.y);
  679. const up = () => {
  680. removeEventListener("pointermove", mv);
  681. removeEventListener("pointerup", up);
  682. if (done) done();
  683. };
  684. addEventListener("pointermove", mv);
  685. addEventListener("pointerup", up);
  686. }
  687. function renderDock() {
  688. const dock = $("#dock");
  689. dock.innerHTML = "";
  690. const chip = (label, fn) => {
  691. const b = document.createElement("button");
  692. b.className = "dock-chip";
  693. b.textContent = label;
  694. b.onclick = fn;
  695. dock.appendChild(b);
  696. };
  697. const min = [...winRegistry].filter((h) => h.isMinimized() && h.win.isConnected);
  698. // "– " = minimized (still open), "+ " = closed (bring it back)
  699. for (const h of min) chip((h.isClosed() ? "+ " : "– ") + h.label, h.restore);
  700. if (!min.length && !rawWin) chip("+ raw config / recipe / effective", openRaw);
  701. // New-window menu (plan): State / Logs / Jobs / Raw.
  702. const plus = document.createElement("button");
  703. plus.className = "dock-chip plus";
  704. plus.textContent = "+";
  705. plus.title = "new window";
  706. plus.onclick = (e) => { e.stopPropagation(); toggleDockMenu(); };
  707. dock.appendChild(plus);
  708. fitToLayer(); // the dock just appeared/disappeared → the layer resized
  709. }
  710. let dockMenuEl = null;
  711. function closeDockMenu() { if (dockMenuEl) { dockMenuEl.remove(); dockMenuEl = null; } }
  712. function toggleDockMenu() {
  713. if (dockMenuEl) { closeDockMenu(); return; }
  714. const m = document.createElement("div");
  715. m.className = "dockmenu";
  716. const item = (label, fn) => {
  717. const b = document.createElement("button");
  718. b.className = "dock-chip";
  719. b.textContent = label;
  720. b.onclick = () => { closeDockMenu(); fn(); };
  721. m.appendChild(b);
  722. };
  723. item("topo", () => activateWindow(wins.topo));
  724. item("state", () => activateWindow(wins.state));
  725. item("jobs", () => activateWindow(wins.jobs));
  726. item("recipes", openRecipes);
  727. item("logs…", openLogsPicker);
  728. item("raw", openRaw);
  729. document.body.appendChild(m);
  730. dockMenuEl = m;
  731. setTimeout(() => addEventListener("click", closeDockMenu, { once: true }), 0);
  732. }
  733. // Open a logs window from the dock: pick a node (★ = running containers).
  734. function openLogsPicker() {
  735. const snap = state.snap;
  736. const body = document.createElement("div");
  737. body.className = "body";
  738. const sel = document.createElement("select");
  739. sel.style.cssText = "width:100%;margin:8px 0;padding:6px;background:var(--btn);color:var(--fg);border:1px solid var(--border);border-radius:6px;";
  740. if (snap) for (const c of snap.clusters) for (const n of c.nodes) {
  741. const o = document.createElement("option");
  742. o.value = c.name + "|" + n.name;
  743. o.textContent = (n.containers.length ? "★ " : "") + c.name + " / " + n.name + (n.containers.length ? " (" + n.containers.length + ")" : "");
  744. sel.appendChild(o);
  745. }
  746. body.innerHTML = `Open a logs window for a node (★ = has sm121 containers).`;
  747. body.appendChild(sel);
  748. openDialogHtml("open logs", body, () => {
  749. const i = sel.value.indexOf("|");
  750. openLog(sel.value.slice(0, i), sel.value.slice(i + 1));
  751. });
  752. }
  753. // --- SSE -------------------------------------------------------------------
  754. function sse(cls, txt) { $("#sse").className = "ssec " + cls; $("#sse-t").textContent = txt; }
  755. /*SSE-LT:START*/
  756. function connectSSE() {
  757. // the fleet polls every 30s, so a working stream is never silent for
  758. // long (plus 15s server keepalive comments). If it IS silent, the chip
  759. // is lying — rebuild the stream instead of trusting "open".
  760. const POLL_MS = 30000, STALE_MS = 3 * POLL_MS + 5000;
  761. let es = null, last = Date.now();
  762. const bounce = (txt) => {
  763. if (es) es.close();
  764. sse("re", txt);
  765. es = new EventSource("/events");
  766. // connection is up and a snapshot is already on its way — don't sit on
  767. // "connecting…" for a whole poll interval
  768. es.onopen = () => { last = Date.now(); sse("ok", "live"); };
  769. es.onmessage = (e) => {
  770. last = Date.now();
  771. sse("ok", "live");
  772. let ev;
  773. try { ev = JSON.parse(e.data); } catch { return; }
  774. if (ev.type === "state") onState(ev.snapshot);
  775. else if (ev.type === "job") onJob(ev);
  776. else if (ev.type === "log") onLog(ev);
  777. };
  778. es.onerror = () => {
  779. sse("re", "reconnecting…");
  780. // readyState CLOSED = EventSource gave up for good — only a fresh
  781. // object reconnects (the server replays the snapshot, so it heals
  782. // without a manual page reload)
  783. if (es.readyState === 2) setTimeout(() => bounce("reconnecting…"), 1000);
  784. };
  785. };
  786. bounce("connecting…");
  787. setInterval(() => {
  788. if (document.visibilityState === "visible" && Date.now() - last > STALE_MS) bounce("resyncing…");
  789. }, POLL_MS);
  790. // back from a frozen/sleeping tab or a network hop: check immediately
  791. document.addEventListener("visibilitychange", () => {
  792. if (document.visibilityState === "visible" && Date.now() - last > 40000) bounce("resyncing…");
  793. });
  794. addEventListener("online", () => bounce("reconnecting…"));
  795. }
  796. /*SSE-LT:END*/
  797. // --- state render ------------------------------------------------------------
  798. function ledState(n) { return n.reachable ? (n.occupied ? "b" : "on") : "off"; }
  799. function meta(n, c) {
  800. if (!n.reachable) return "offline";
  801. if (!c.up) return "online — cluster offline";
  802. if (n.occupied) return n.containers.map((x) => x.status).join(", ");
  803. return "free";
  804. }
  805. function clusterReason(c) {
  806. let r = "";
  807. if (c.up) r = "";
  808. else if (c.switch_off) r = "switch off";
  809. else {
  810. const off = c.nodes.filter((n) => !n.reachable).map((n) => n.name);
  811. r = off.length + " node" + (off.length === 1 ? "" : "s") + " offline" + (off.length ? " (" + off.join(", ") + ")" : "");
  812. }
  813. if (c.soft_off && c.soft_off.length) r = (r ? r + " · " : "") + "⚙ " + c.soft_off.join(", ") + " off";
  814. return r;
  815. }
  816. function renderCluster(c, prev, animate) {
  817. const el = document.createElement("div");
  818. el.className = "cluster" + (animate ? " anim" : "");
  819. const head = document.createElement("div");
  820. head.className = "cluster-head";
  821. if (c.up) {
  822. const soft = c.soft_off && c.soft_off.length ? `<span class="soft" title="non-blocking entity off">⚙ ${esc(c.soft_off.join(", "))} off</span>` : "";
  823. head.innerHTML = `<b>${esc(c.name)}</b><span class="free">${c.free} free</span>${soft}`;
  824. } else {
  825. head.innerHTML = `<b>${esc(c.name)}</b><span class="down">⏻ offline — ${esc(clusterReason(c))}</span>`;
  826. }
  827. el.appendChild(head);
  828. const prevNodes = prev ? Object.fromEntries(prev.nodes.map((n) => [n.name, n])) : {};
  829. for (const n of c.nodes) {
  830. const row = document.createElement("div");
  831. row.className = "node";
  832. row.title = "open logs for " + n.name;
  833. const cls = ledState(n);
  834. const flip = prevNodes[n.name] && ledState(prevNodes[n.name]) !== cls ? " flip" : "";
  835. row.innerHTML = `<span class="led ${cls}${flip}"></span>
  836. <span class="nname">${esc(n.name)}</span>
  837. <span class="nmeta" title="${esc(n.ssh_ip)}">${esc(meta(n, c))}</span>
  838. <button class="abtn logsbtn" title="open logs for ${esc(n.name)}">▤ logs</button>`;
  839. row.querySelector(".logsbtn").onclick = (e) => { e.stopPropagation(); openLog(c.name, n.name); };
  840. row.onclick = () => openLog(c.name, n.name);
  841. el.appendChild(row);
  842. if (n.occupied && n.containers.length) {
  843. const box = document.createElement("div");
  844. box.className = "containers";
  845. for (const x of n.containers) {
  846. const crow = document.createElement("div");
  847. crow.className = "crow";
  848. const dead = /exited|dead/i.test(x.status);
  849. crow.innerHTML = `<span class="cled${dead ? " exited" : ""}"></span><span class="cname">${esc(x.name)}</span><span>${esc(x.recipe)} · ${esc(x.status)}</span>`;
  850. box.appendChild(crow);
  851. }
  852. el.appendChild(box);
  853. }
  854. }
  855. el.appendChild(actionBar(c, prevNodes));
  856. return el;
  857. }
  858. function actionBar(c, prevNodes) {
  859. const bar = document.createElement("div");
  860. bar.className = "actions";
  861. if (!c.up) {
  862. // Down cluster: serve/stop are meaningless — only power actions apply.
  863. bar.appendChild(abtn("power-" + c.name, "power on", () => doPower("on", c.name), ""));
  864. bar.appendChild(abtn("pwswitch-" + c.name, "switch", () => doPower("switch", c.name), ""));
  865. return bar;
  866. }
  867. const sel = document.createElement("select");
  868. sel.id = "recipe-" + c.name;
  869. if (!state.recipes.length) {
  870. sel.innerHTML = "<option value=''></option>";
  871. } else {
  872. for (const r of state.recipes) {
  873. const o = document.createElement("option");
  874. o.value = r.name;
  875. o.textContent = r.name + (r.deploy === "tmux" ? " (tmux)" : "");
  876. sel.appendChild(o);
  877. }
  878. }
  879. bar.appendChild(sel);
  880. const dry = document.createElement("label");
  881. dry.innerHTML = `<input type="checkbox" id="dry-${esc(c.name)}"> dry-run`;
  882. bar.appendChild(dry);
  883. // Serve flags (CLI parity): solo / force / clear / keep / fit / mirror / node.
  884. // Values persist in state.flags across re-renders.
  885. const f = flagsOf(c.name);
  886. const nodeSel = document.createElement("select");
  887. nodeSel.className = "fnode";
  888. nodeSel.title = "target node (default: cluster selection)";
  889. nodeSel.innerHTML = `<option value=''>node: auto</option>` + c.nodes.map((n) => `<option value='${esc(n.name)}'>${esc(n.name)}</option>`).join("");
  890. nodeSel.value = f.node || "";
  891. nodeSel.onchange = () => { f.node = nodeSel.value; };
  892. const det = document.createElement("details");
  893. det.className = "flags";
  894. det.innerHTML = `<summary title="solo · force · fit · clear · mirror · keep (keep containers after stop)">⚙ flags</summary>
  895. <div class="flags-in">
  896. <label><input type="checkbox" id="fsolo-${esc(c.name)}"> solo</label>
  897. <label><input type="checkbox" id="fforce-${esc(c.name)}"> force</label>
  898. <label><input type="checkbox" id="fclear-${esc(c.name)}" title="clear cgroup memory limits (sudo) on launch nodes"> clear</label>
  899. <label><input type="checkbox" id="fkeep-${esc(c.name)}" title="keep containers after stop"> keep</label>
  900. <label title="scale TP to N nodes / whole cluster">fit <input type="checkbox" id="ffitb-${esc(c.name)}" title="bare --fit: whole cluster">all <input type="text" id="ffit-${esc(c.name)}" placeholder="N" size="3"></label>
  901. <label title="per-node HF mirror">mirror <input type="checkbox" id="fmirr-${esc(c.name)}" title="bare --mirror: ~/.cache/hf-models">~ <input type="text" id="fmirror-${esc(c.name)}" placeholder="~/.cache/hf-models" size="18"></label>
  902. </div>`;
  903. const wire = (id, key, isText) => {
  904. const el = det.querySelector("#" + id);
  905. if (isText) { el.value = f[key] || ""; el.oninput = () => { f[key] = el.value; }; }
  906. else { el.checked = !!f[key]; el.onchange = () => { f[key] = el.checked; }; }
  907. };
  908. wire("fsolo-" + c.name, "solo");
  909. wire("fforce-" + c.name, "force");
  910. wire("fclear-" + c.name, "clear");
  911. wire("fkeep-" + c.name, "keep");
  912. wire("ffitb-" + c.name, "fitAll");
  913. wire("ffit-" + c.name, "fit", true);
  914. wire("fmirr-" + c.name, "mirrorAll");
  915. wire("fmirror-" + c.name, "mirror", true);
  916. bar.appendChild(det);
  917. bar.appendChild(nodeSel);
  918. bar.appendChild(abtn("serve-" + c.name, "serve", () => doServe(c.name)));
  919. bar.appendChild(abtn("stop-" + c.name, "stop", () => doStop(c.name), "danger"));
  920. bar.appendChild(abtn("power-" + c.name, "power off", () => doPower("off", c.name), "danger"));
  921. bar.appendChild(abtn("pwswitch-" + c.name, "switch", () => doPower("switch", c.name), ""));
  922. return bar;
  923. }
  924. function abtn(id, label, fn, cls) {
  925. const b = document.createElement("button");
  926. b.className = "abtn " + (cls || "");
  927. b.id = id;
  928. b.textContent = label;
  929. b.onclick = fn;
  930. return b;
  931. }
  932. function onState(snap, force) {
  933. const prev = state.snap;
  934. updateLogNotes(snap); // runs even on no-churn ticks (attaches change out-of-band)
  935. if (!force && prev && sameSnapshot(prev, snap)) {
  936. state.snap = snap; // adopt the fresh attaches state; no DOM churn
  937. return; // nothing else changed — no rebuild
  938. }
  939. state.snap = snap;
  940. state.jobs = new Map(snap.jobs.map((j) => [j.id, j]));
  941. renderTopo(snap);
  942. if (!snap.clusters.find((c) => c.name === state.active)) {
  943. state.active = snap.clusters[0] ? snap.clusters[0].name : "";
  944. }
  945. // control state must survive the rebuild (recipe pick, dry-run tick)
  946. const activeName = state.active;
  947. const keepRecipe = recipeOf(activeName);
  948. const keepDry = dryOf(activeName);
  949. const animate = state.panel !== activeName; // first paint or cluster switch
  950. state.panel = activeName;
  951. const box = $("#clusters");
  952. box.innerHTML = "";
  953. const active = snap.clusters.find((c) => c.name === activeName);
  954. if (active) {
  955. box.appendChild(renderCluster(active, prev ? prev.clusters.find((p) => p.name === activeName) : null, animate));
  956. }
  957. restoreControls(activeName, keepRecipe, keepDry);
  958. // compact fleet strip: one chip per cluster, click to make it active
  959. const strip = document.createElement("div");
  960. strip.className = "strip";
  961. for (const c of snap.clusters) {
  962. const chip = document.createElement("div");
  963. chip.className = "chip" + (c.name === state.active ? " active" : "");
  964. const dot = c.up ? (c.free > 0 ? "on" : "b") : "off";
  965. const m = c.up ? c.free + " free" : "⏻ OFF";
  966. chip.title = clusterReason(c);
  967. chip.innerHTML = `<span class="led ${dot}"></span>${esc(c.name)}<span class="m">${m}</span>`;
  968. chip.onclick = () => { state.active = c.name; localStorage.setItem("sm121-active", c.name); onState(state.snap, true); };
  969. strip.appendChild(chip);
  970. }
  971. box.appendChild(strip);
  972. renderJobs();
  973. }
  974. function restoreControls(name, recipe, dry) {
  975. if (!name) return;
  976. const d = document.getElementById("dry-" + name);
  977. if (d && dry) d.checked = true;
  978. const sel = document.getElementById("recipe-" + name);
  979. if (sel && recipe) {
  980. for (const o of sel.options) if (o.value === recipe) { sel.value = recipe; break; }
  981. }
  982. }
  983. // --- topology map -------------------------------------------------------------
  984. // The physical layer behind the windows: 8 GX10 boxes on a 2×4 grid, the
  985. // 200G fabric switch and the passive 10G switch in the middle corridor,
  986. // DAC direct links, HA glyphs (fan) and cluster frames. The structure is
  987. // rebuilt only when the topo signature changes (config edit); state ticks
  988. // just flip classes so flow/pulse/spin animations keep running.
  989. const TG_W = 1060, TG_H = 540;
  990. const tgPos = (i) => {
  991. const cx = 20 + (i % 4) * 255;
  992. return { x: cx + 37, y: i < 4 ? 74 : 386, w: 180, h: 104, dcx: cx + 127 };
  993. };
  994. const tgSlot = (n) => {
  995. const m = String(n).match(/\d+/);
  996. return m ? +m[0] : 999;
  997. };
  998. const tgIdx = (t, name) => t.nodes.findIndex((n) => n === name);
  999. // cluster identity colors — green/red stay reserved for up/down state
  1000. const TG_HUES = ["#58a6ff", "#a371f7", "#f778ba", "#f0883e", "#39c5cf", "#e3b341", "#ff7b72", "#79c0ff", "#d2a8ff", "#ffa657"];
  1001. const hueOf = (name) => {
  1002. let h = 0;
  1003. for (const c of String(name)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
  1004. return TG_HUES[h % TG_HUES.length];
  1005. };
  1006. function topoSig(t) {
  1007. return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
  1008. + "|" + t.switches.map((s) => s.entity).join(",");
  1009. }
  1010. function tgCablePath(l, t) {
  1011. const bi = tgIdx(t, l.b);
  1012. if (bi < 0) return "";
  1013. const b = tgPos(bi);
  1014. if (l.kind === "10g") {
  1015. const px = 396 + bi * 38;
  1016. if (bi < 4) { // row0: device bottom → 10G switch top
  1017. const ex = b.dcx + 40, ey = b.y + b.h;
  1018. return `M ${ex} ${ey} C ${ex} ${ey + 26} ${px} ${ey + 6} ${px} 210`;
  1019. }
  1020. return `M ${b.dcx} ${b.y} C ${b.dcx} ${b.y - 50} ${px} ${b.y - 80} ${px} 266`;
  1021. }
  1022. if (l.kind === "200g") {
  1023. const fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
  1024. const px = 396 + (fi % 4) * 80;
  1025. if (bi < 4) { // row0: device bottom → fabric switch top
  1026. const ex = b.dcx - 40, ey = b.y + b.h;
  1027. return `M ${ex} ${ey} C ${ex} ${ey + 76} ${px} ${ey + 66} ${px} 290`;
  1028. }
  1029. // row1: device top → fabric switch bottom — an S-curve inside the 40px
  1030. // corridor (switch bottom 346 .. device top 386) so the last approach
  1031. // to the port is vertical, not a slide in from the side
  1032. const ex = b.dcx - 40, ey = b.y;
  1033. return `M ${ex} ${ey} C ${ex} 363 ${px} 363 ${px} 346`;
  1034. }
  1035. // dac: arc between adjacent device tops
  1036. const ai = tgIdx(t, l.a);
  1037. if (ai < 0) return "";
  1038. const a = tgPos(ai);
  1039. const mx = (a.x + a.w + b.x) / 2;
  1040. return `M ${a.x + a.w - 4} ${a.y + 14} C ${mx} ${a.y - 22} ${mx} ${a.y - 22} ${b.x + 4} ${b.y + 14}`;
  1041. }
  1042. function devSvg(name, i) {
  1043. const d = tgPos(i);
  1044. return `<g class="dev" data-dev="${esc(name)}" transform="translate(${d.x},${d.y})">
  1045. <title>${esc(name)}</title>
  1046. <rect class="body" width="${d.w}" height="${d.h}" rx="10"/>
  1047. <rect class="top" x="4" y="3" width="${d.w - 8}" height="7" rx="3.5"/>
  1048. <rect class="vent" x="14" y="24" width="5" height="24" rx="2.5"/>
  1049. <rect class="vent" x="23" y="24" width="5" height="24" rx="2.5"/>
  1050. <rect class="vent" x="32" y="24" width="5" height="24" rx="2.5"/>
  1051. <rect class="ctr" x="122" y="18" width="52" height="15" rx="7.5"/>
  1052. <text class="ctrt" x="148" y="29" text-anchor="middle">…</text>
  1053. <rect class="plate" x="1" y="62" width="${d.w - 2}" height="41" rx="8"/>
  1054. <text class="nm" x="12" y="79">${esc(name)}</text>
  1055. <text class="ip" x="12" y="94">…</text>
  1056. <circle class="pled" cx="166" cy="74" r="4"/>
  1057. </g>`;
  1058. }
  1059. function swSvg(sw, entries) {
  1060. const label = sw.kind === "lan" ? "10G switch"
  1061. : sw.entity.includes(".") ? sw.entity.split(".")[1] : sw.entity;
  1062. const ib = sw.kind === "ib";
  1063. const y = ib ? 290 : 210;
  1064. const sub = ib ? "200G fabric · HA" : "passive · no HA";
  1065. const step = ib ? 80 : 38;
  1066. // ib ports sit 4+4 across two edges (the box fits 4 per edge at step 80);
  1067. // lan ports keep the continuous index (step 38 fits 8 across 2 rows)
  1068. const rows = entries.map((p) => { const col = ib ? p.i % 4 : p.i; return `<rect class="port" data-${ib ? "fab" : "lan"}port="${esc(p.name)}"
  1069. x="${396 + col * step - 7}" y="${p.top ? y + 2 : y + 49}" width="14" height="7" rx="2"/>`; }).join("");
  1070. return `<g class="sw ${sw.kind}" data-sw="${esc(sw.entity)}" transform="translate(0,0)">
  1071. <title>${esc(sw.entity)}</title>
  1072. <rect class="swbox" x="370" y="${y}" width="320" height="56" rx="8"/>
  1073. ${rows}
  1074. <text x="386" y="${y + 32}">${esc(label)}</text>
  1075. <text class="sub" x="386" y="${y + 46}">${sub}</text>
  1076. ${ib ? '<circle class="pled swled" cx="672" cy="' + (y + 40) + '" r="4"/>' : ""}
  1077. </g>`;
  1078. }
  1079. function fanSvg(sw) {
  1080. return `<g class="fan" data-sw="${esc(sw.entity)}">
  1081. <title>${esc(sw.entity)}</title>
  1082. <circle class="fbody" cx="770" cy="318" r="26"/>
  1083. <g class="blades">
  1084. <path class="blade" d="M770 294 C 781 298 783 310 775 316 L 770 318 Z"/>
  1085. <path class="blade" d="M770 294 C 781 298 783 310 775 316 L 770 318 Z" transform="rotate(120 770 318)"/>
  1086. <path class="blade" d="M770 294 C 781 298 783 310 775 316 L 770 318 Z" transform="rotate(240 770 318)"/>
  1087. <circle class="hub" cx="770" cy="318" r="5"/>
  1088. </g>
  1089. <text class="sub" x="770" y="362" text-anchor="middle">${esc(sw.entity.split(".")[1] || sw.entity)}</text>
  1090. </g>`;
  1091. }
  1092. function topoFrames(snap) {
  1093. const t = snap.topo;
  1094. // clusters with identical node sets share one frame (several chips on
  1095. // it) — no doubled rectangles, no mutual-containment depth inflation
  1096. const seen = new Map();
  1097. const groups = [];
  1098. for (const c of snap.clusters) {
  1099. const key = c.nodes.map((n) => n.name).sort().join("|");
  1100. if (seen.has(key)) seen.get(key).clusters.push(c);
  1101. else {
  1102. const g = { clusters: [c], nodes: c.nodes.map((n) => n.name) };
  1103. seen.set(key, g);
  1104. groups.push(g);
  1105. }
  1106. }
  1107. const contains = (a, b) => b.nodes.every((n) => a.nodes.includes(n));
  1108. const depthOf = (x) => groups.filter((o) => o !== x && contains(o, x)).length;
  1109. // pads grow with cluster rank in its region: the outermost (most
  1110. // containing) cluster sits furthest out on BOTH edges, so label chips
  1111. // land on separate "floors" and frame edges never stack into one line;
  1112. // inner frames hug their nodes
  1113. const pad = (g) => {
  1114. const d = depthOf(g);
  1115. const region = groups.filter((o) => o.nodes.some((n) => g.nodes.includes(n)));
  1116. const rmax = Math.max(...region.map(depthOf));
  1117. return { x: 14 + 10 * (rmax - d), top: 12 + 18 * (rmax - d), bottom: 12 + 5 * (rmax - d) };
  1118. };
  1119. const rects = [];
  1120. for (const g of groups) {
  1121. const ps = g.nodes.map((n) => tgPos(tgIdx(t, n))).filter(Boolean);
  1122. if (!ps.length) continue;
  1123. const p = pad(g);
  1124. const x = Math.min(...ps.map((q) => q.x)) - p.x;
  1125. const y = Math.min(...ps.map((q) => q.y)) - p.top;
  1126. const w = Math.max(...ps.map((q) => q.x + q.w)) + p.x - x;
  1127. const h = Math.max(...ps.map((q) => q.y + q.h)) + p.bottom - y;
  1128. rects.push({ g, x, y, w, h, area: w * h });
  1129. }
  1130. rects.sort((a, b) => b.area - a.area); // big frames behind, nested on top
  1131. // a chip never shares its hue with a frame that contains its frame
  1132. // (containers sort first — bigger area — so their hues are final)
  1133. for (const r of rects) {
  1134. const containers = rects.filter((o) => o !== r && o.x <= r.x && o.y <= r.y
  1135. && o.x + o.w >= r.x + r.w && o.y + o.h >= r.y + r.h);
  1136. for (const c of r.g.clusters) {
  1137. let hue = hueOf(c.name);
  1138. while (containers.some((o) => o.hue === hue)) {
  1139. hue = TG_HUES[(TG_HUES.indexOf(hue) + 1) % TG_HUES.length];
  1140. }
  1141. c._hue = hue;
  1142. }
  1143. r.hue = r.g.clusters[0]._hue;
  1144. }
  1145. // chip bands: chips sharing a frame-top floor get x-staggered so labels
  1146. // never overlap (merged frames put several chips on one frame)
  1147. const bands = new Map();
  1148. for (const r of rects) {
  1149. for (const c of r.g.clusters) (bands.get(r.y) ?? bands.set(r.y, []).get(r.y)).push({ r, c });
  1150. }
  1151. for (const band of bands.values()) {
  1152. band.sort((a, b) => a.r.x - b.r.x || a.c.name.localeCompare(b.c.name));
  1153. let cursor = -1e9;
  1154. for (const chip of band) {
  1155. chip.cw = chip.c.name.length * 6.6 + 30;
  1156. chip.x = Math.max(chip.r.x + 12, cursor);
  1157. cursor = chip.x + chip.cw + 6;
  1158. }
  1159. }
  1160. state.topoGroupTitles = {};
  1161. for (const r of rects) {
  1162. state.topoGroupTitles[r.g.clusters[0].name] = r.g.clusters.map((c) => c.name).join(" · ");
  1163. }
  1164. // two layers: frame rects (below cables/devices) and chips (topmost —
  1165. // opaque badges, nothing shows through, always clickable)
  1166. let frames = "", chips = "";
  1167. for (const r of rects) {
  1168. frames += `<g class="cluster-frame" data-cluster="${esc(r.g.clusters[0].name)}">
  1169. <title>${esc(r.g.clusters.map((c) => c.name).join(" · "))}</title>
  1170. <rect class="frame" style="stroke:${r.hue};fill:color-mix(in srgb, ${r.hue} 6%, transparent)"
  1171. x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="14"/>
  1172. </g>`;
  1173. }
  1174. for (const r of rects) {
  1175. for (const c of r.g.clusters) {
  1176. const chip = bands.get(r.y).find((x) => x.c === c);
  1177. chips += `<g class="fchip" data-cluster="${esc(c.name)}" transform="translate(${chip.x},${r.y - 8})">
  1178. <title>${esc(c.name)}</title>
  1179. <rect width="${chip.cw}" height="16" rx="8" style="stroke:${c._hue}"/>
  1180. <circle class="cdot" cx="10" cy="8" r="3.5"/>
  1181. <text x="18" y="12" style="fill:${c._hue}">${esc(c.name)}</text>
  1182. </g>`;
  1183. }
  1184. }
  1185. return { frames, chips };
  1186. }
  1187. function cablesSvg(t, bus) {
  1188. let out = "";
  1189. for (const l of t.links) {
  1190. const p = bus ? tgCablePathBus(l, t) : tgCablePath(l, t);
  1191. if (!p) continue;
  1192. const key = `${l.kind}|${l.a}|${l.b}`;
  1193. out += `<path class="cable c-${l.kind}" d="${p}"/>`;
  1194. out += `<path class="cable c-${l.kind} flow" data-flow="${esc(key)}" d="${p}"/>`;
  1195. }
  1196. if (bus) out += bridgesSvg(t);
  1197. return out;
  1198. }
  1199. // Bus mode: row-1 (bottom) cables route as right-angle runs along two
  1200. // shared lanes — 200g at y=366 (in the 40px corridor under the fabric
  1201. // switch), 10g at y=278 (the 24px gap between the two switches). A 10g
  1202. // cable whose vertical would cut the fabric switch detours around its
  1203. // nearer side. Where a 10g run crosses the 200g lane it hops over it on
  1204. // a bridge arc, so crossings read as intentional.
  1205. function tgCablePathBus(l, t) {
  1206. const bi = tgIdx(t, l.b);
  1207. if (bi < 0 || bi < 4) return tgCablePath(l, t); // row 0 and DAC unchanged
  1208. const b = tgPos(bi);
  1209. if (l.kind === "200g") {
  1210. const ex = b.dcx - 40;
  1211. const fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
  1212. const px = 396 + (fi % 4) * 80;
  1213. return `M ${ex} 386 L ${ex} 366 L ${px} 366 L ${px} 346`;
  1214. }
  1215. // 10g: up the device side, across the inter-switch gap, down to the port
  1216. const px = 396 + bi * 38;
  1217. const x = b.dcx;
  1218. if (x < 370 || x > 690) return `M ${x} 386 L ${x} 278 L ${px} 278 L ${px} 266`;
  1219. const side = x < 530 ? 350 : 710; // nearest clearance past the fabric box
  1220. return `M ${x} 386 L ${x} 366 L ${side} 366 L ${side} 278 L ${px} 278 L ${px} 266`;
  1221. }
  1222. // Small semicircle hops where a 10g row-1 vertical crosses the 200g lane.
  1223. function bridgesSvg(t) {
  1224. const lane = t.links
  1225. .filter((l) => l.kind === "200g")
  1226. .map((l) => {
  1227. const bi = tgIdx(t, l.b);
  1228. if (bi < 4) return null;
  1229. const b = tgPos(bi);
  1230. const ex = b.dcx - 40;
  1231. const fi = t.links.filter((x) => x.kind === "200g").findIndex((x) => x.b === l.b);
  1232. const px = 396 + (fi % 4) * 80;
  1233. return { y: 366, x1: Math.min(ex, px), x2: Math.max(ex, px) };
  1234. })
  1235. .filter(Boolean);
  1236. let out = "";
  1237. for (const l of t.links.filter((l) => l.kind === "10g")) {
  1238. const bi = tgIdx(t, l.b);
  1239. if (bi < 4) continue;
  1240. const x = tgPos(bi).dcx;
  1241. // detour devices turn ON the lane (a T-junction) — only a vertical that
  1242. // spans the lane (outer devices, x outside the fabric box) gets a hop
  1243. if (x >= 370 && x <= 690) continue;
  1244. for (const h of lane)
  1245. if (x > h.x1 + 2 && x < h.x2 - 2)
  1246. out += `<path class="bridge b-10g" d="M ${x - 5} ${h.y} A 5 5 0 0 1 ${x + 5} ${h.y}"/>`;
  1247. }
  1248. return out;
  1249. }
  1250. function switchesSvg(t) {
  1251. const fi = {};
  1252. const fabEntries = t.links.filter((l) => l.kind === "200g").map((l) => ({
  1253. name: l.b, i: (fi[l.b] = fi[l.b] ?? Object.keys(fi).length), top: fi[l.b] < 4,
  1254. }));
  1255. const lanEntries = t.nodes.map((n, i) => ({ name: n, i, top: i < 4 }));
  1256. const glyphs = t.switches.map((s) => s.kind === "aux" ? fanSvg(s) : swSvg(s, fabEntries)).join("");
  1257. // the passive 10G switch has no HA entity — the UI draws it explicitly
  1258. return glyphs + swSvg({ entity: "lan", kind: "lan" }, lanEntries);
  1259. }
  1260. const devsSvg = (t) => t.nodes.map((n, i) => devSvg(n, i)).join("");
  1261. function legendSvg(bus) {
  1262. return `<g class="legend" transform="translate(20,526)">
  1263. <line x1="0" y1="0" x2="26" y2="0" class="cable c-200g"/><text x="32" y="4">200G fabric</text>
  1264. <line x1="120" y1="0" x2="146" y2="0" class="cable c-dac"/><text x="152" y="4">DAC direct</text>
  1265. <line x1="240" y1="0" x2="266" y2="0" class="cable c-10g"/><text x="272" y="4">10G mgmt</text>
  1266. <circle cx="352" cy="0" r="4" class="pled on"/><text x="362" y="4">plug on</text>
  1267. <circle cx="428" cy="0" r="4" class="pled off"/><text x="438" y="4">plug off / HA?</text>
  1268. <text x="560" y="4" class="cablemode" data-topobus="1">cables: ${bus ? "bus + bridges" : "direct"} ▸</text>
  1269. </g>`;
  1270. }
  1271. // Per-tick state update: flip LED/port/frame/flow classes in place —
  1272. // the SVG skeleton survives, so CSS animations keep running.
  1273. // Power policy for fabric ports and their cable flows: a port is green only
  1274. // when its switch reports ON (unknown is tolerated) AND the node is
  1275. // occupied — an HA-OFF switch cannot carry traffic, so all of its ports
  1276. // stay gray even if a node still reports a running container.
  1277. /*TOPOPORT:START*/
  1278. function fabPortOn(swOn, occupied) {
  1279. return swOn !== false && !!occupied;
  1280. }
  1281. /*TOPOPORT:END*/
  1282. function topoTick(snap) {
  1283. const t = snap.topo;
  1284. const ns = {};
  1285. for (const c of snap.clusters) for (const n of c.nodes) {
  1286. const s = (ns[n.name] ||= { reachable: false, occupied: false, ha: undefined, containers: [] });
  1287. s.reachable ||= n.reachable;
  1288. s.occupied ||= n.occupied;
  1289. if (!s.ip) s.ip = n.ssh_ip;
  1290. if (n.ha !== null) s.ha = n.ha;
  1291. for (const ct of n.containers) if (!s.containers.some((x) => x.name === ct.name)) s.containers.push(ct);
  1292. }
  1293. for (const name of t.nodes) {
  1294. const g = document.querySelector(`#topo g[data-dev="${CSS.escape(name)}"]`);
  1295. const s = ns[name];
  1296. if (!g || !s) continue;
  1297. const led = g.querySelector(".pled");
  1298. if (led) led.setAttribute("class", "pled " + (s.reachable ? (s.occupied ? "b" : "on") : "off"));
  1299. g.classList.toggle("unpowered", s.ha === false);
  1300. const ip = g.querySelector(".ip");
  1301. if (ip) ip.textContent = s.ip || "";
  1302. const ctr = g.querySelector(".ctrt"), chip = g.querySelector(".ctr");
  1303. const recipes = [...new Set(s.containers.map((x) => x.recipe))];
  1304. if (ctr) ctr.textContent = s.containers.length ? `${s.containers.length}·${recipes[0].slice(0, 7)}` : "idle";
  1305. if (chip) chip.classList.toggle("hot", s.containers.length > 0);
  1306. const tt = g.querySelector("title");
  1307. if (tt) tt.textContent = `${name} · ${s.ip || ""} · ${s.reachable ? (s.occupied ? "serving" : "free") : "down"}`
  1308. + (s.ha === false ? " · plug OFF" : "")
  1309. + (s.containers.length ? " · " + s.containers.map((x) => x.name).join(", ") : "");
  1310. }
  1311. const swStateOf = (ent) => t.states.find((x) => x.entity === ent)?.on;
  1312. for (const l of t.links) {
  1313. const fl = document.querySelector(`#topo path[data-flow="${CSS.escape(l.kind + "|" + l.a + "|" + l.b)}"]`);
  1314. if (!fl) continue;
  1315. const B = ns[l.b], A = l.kind === "dac" ? ns[l.a] : null;
  1316. fl.classList.toggle("live", l.kind === "10g" ? !!B?.reachable
  1317. : l.kind === "200g" ? fabPortOn(swStateOf(l.a), B?.occupied)
  1318. : !!(B?.occupied && A?.occupied));
  1319. }
  1320. for (const name of t.nodes) {
  1321. const lp = document.querySelector(`#topo rect[data-lanport="${CSS.escape(name)}"]`);
  1322. if (lp) lp.classList.toggle("on", !!ns[name]?.reachable);
  1323. const fp = document.querySelector(`#topo rect[data-fabport="${CSS.escape(name)}"]`);
  1324. if (fp) {
  1325. const swEnt = fp.closest("[data-sw]")?.dataset?.sw;
  1326. fp.classList.toggle("on", fabPortOn(swStateOf(swEnt), ns[name]?.occupied));
  1327. }
  1328. }
  1329. for (const sw of t.switches) {
  1330. const st = t.states.find((x) => x.entity === sw.entity);
  1331. const g = document.querySelector(`#topo [data-sw="${CSS.escape(sw.entity)}"]`);
  1332. if (!g) continue;
  1333. const cls = st?.on === true ? "on" : st?.on === false ? "off" : "unk";
  1334. g.classList.remove("on", "off", "unk");
  1335. g.classList.add(cls);
  1336. const led = g.querySelector(".swled");
  1337. if (led) led.setAttribute("class", "pled swled " + (st?.on === true ? "on" : st?.on === false ? "off" : ""));
  1338. const tt = g.querySelector("title");
  1339. if (tt) tt.textContent = `${sw.entity} · ${st?.on === true ? "on" : st?.on === false ? "OFF" : "state unknown"}`;
  1340. }
  1341. for (const c of snap.clusters) {
  1342. const st = `${c.up ? `up · ${c.free} free` : "down"}${c.switch_off ? " · HA switch off" : ""}`;
  1343. for (const g of document.querySelectorAll(`#topo [data-cluster="${CSS.escape(c.name)}"]`)) {
  1344. g.classList.toggle("down", !c.up);
  1345. const dot = g.querySelector(".cdot");
  1346. if (dot) dot.setAttribute("class", "cdot " + (c.up ? "on" : "off"));
  1347. const tt = g.querySelector("title");
  1348. if (tt) tt.textContent = `${(state.topoGroupTitles || {})[c.name] || c.name} · ${st}`;
  1349. }
  1350. }
  1351. }
  1352. function renderTopo(snap) {
  1353. const host = document.getElementById("topo");
  1354. const t = snap.topo;
  1355. if (!host || !t) return;
  1356. if (state.topoBus === undefined) state.topoBus = localStorage.getItem("sm121-topobus") === "1";
  1357. const sig = topoSig(t) + (state.topoBus ? ":bus" : ":direct");
  1358. if (state.topoSig !== sig) {
  1359. state.topoSig = sig;
  1360. const tf = topoFrames(snap);
  1361. host.innerHTML = `<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
  1362. ${tf.frames}${cablesSvg(t, state.topoBus)}${switchesSvg(t)}${devsSvg(t)}${legendSvg(state.topoBus)}${tf.chips}
  1363. </svg>`;
  1364. for (const g of host.querySelectorAll("[data-cluster]")) {
  1365. g.onclick = () => {
  1366. state.active = g.dataset.cluster;
  1367. localStorage.setItem("sm121-active", state.active);
  1368. if (wins.state) wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win);
  1369. onState(state.snap, true);
  1370. };
  1371. }
  1372. // cable-mode toggle (legend): direct bezier runs ↔ bus + bridge arcs
  1373. const cm = host.querySelector("[data-topobus]");
  1374. if (cm)
  1375. cm.onclick = () => {
  1376. state.topoBus = !state.topoBus;
  1377. localStorage.setItem("sm121-topobus", state.topoBus ? "1" : "0");
  1378. state.topoSig = ""; // force a re-render with the other mode
  1379. renderTopo(state.snap || snap);
  1380. };
  1381. }
  1382. topoTick(snap);
  1383. }
  1384. // --- jobs ---------------------------------------------------------------------
  1385. // "Running" entries come from the docker labels (sm121.recipe / sm121.cluster)
  1386. // on live containers — jobs don't need to have started from this dashboard.
  1387. // One row per launcher job, NOT per node×cluster: a container is visible on
  1388. // every cluster sharing its node, so the board used to look entirely busy.
  1389. /*JOBS-LT:START*/
  1390. function runningFromLabels() {
  1391. const m = new Map();
  1392. if (!state.snap) return [];
  1393. for (const c of state.snap.clusters) {
  1394. if (!c.up) continue; // down clusters report no containers
  1395. for (const n of c.nodes) {
  1396. for (const x of n.containers) {
  1397. const key = x.cluster + "|" + x.recipe;
  1398. let r = m.get(key);
  1399. if (!r) m.set(key, (r = { cluster: x.cluster, recipe: x.recipe, nodes: [], statuses: new Set() }));
  1400. if (!r.nodes.includes(n.name)) r.nodes.push(n.name);
  1401. r.statuses.add(x.status);
  1402. }
  1403. }
  1404. }
  1405. const nat = (a, b) => a.localeCompare(b, undefined, { numeric: true });
  1406. return [...m.values()].map((r) => ({ ...r, nodes: r.nodes.sort(nat), statuses: [...r.statuses].sort(nat) }));
  1407. }
  1408. /*JOBS-LT:END*/
  1409. function renderJobs() {
  1410. const box = $("#jobs");
  1411. box.innerHTML = "";
  1412. const prevShown = state.shown;
  1413. state.shown = new Set();
  1414. const row = (key, cls, html) => {
  1415. const el = document.createElement("div");
  1416. el.className = "job " + cls + (prevShown.has(key) ? "" : " new");
  1417. el.innerHTML = html;
  1418. box.appendChild(el);
  1419. state.shown.add(key);
  1420. return el;
  1421. };
  1422. const run = runningFromLabels();
  1423. if (run.length) {
  1424. const h = document.createElement("div");
  1425. h.className = "jobs-empty"; h.textContent = "running (docker labels)";
  1426. box.appendChild(h);
  1427. for (const r of run) {
  1428. const many = r.nodes.length > 1;
  1429. row("c:" + r.cluster + ":" + r.recipe, "jjob",
  1430. `<span class="jdot"></span><span class="jkind">serve</span>
  1431. <span class="jmsg">${esc(r.cluster)}:${esc(r.recipe || "?")} — ${r.nodes.length} node${many ? "s" : ""} (${esc(r.nodes.join(", "))}) · ${esc(r.statuses.join(", "))}</span>`);
  1432. }
  1433. }
  1434. const js = [...state.jobs.values()].sort((a, b) => b.started - a.started);
  1435. if (!run.length && !js.length) { box.innerHTML = '<div class="jobs-empty">no sm121 containers running, no actions yet — serve / stop / power started from this dashboard appear here</div>'; return; }
  1436. if (run.length) {
  1437. const sep = document.createElement("div");
  1438. sep.className = "jobs-empty"; sep.textContent = "actions";
  1439. box.appendChild(sep);
  1440. }
  1441. for (const j of js) {
  1442. const cls = j.status === "ok" ? "ok" : j.status === "failed" ? "failed" : "jjob";
  1443. const when = new Date(j.started * 1000).toLocaleTimeString();
  1444. row("j:" + j.id, cls, `<span class="jdot"></span><span class="jkind">${esc(j.kind)}</span>
  1445. <span class="jmsg">${esc(j.cluster)}${j.recipe ? ":" + esc(j.recipe) : ""} — ${esc(j.message)} <span>(${when})</span></span>`);
  1446. setBtnBusy(j, j.status === "running");
  1447. }
  1448. }
  1449. function setBtnBusy(j, busy) {
  1450. // power jobs disable both the off and switch buttons (the job event
  1451. // doesn't carry the action)
  1452. const keys = j.kind === "power" ? ["power", "pwswitch"] : [j.kind];
  1453. for (const k of keys) {
  1454. const el = document.getElementById(k + "-" + j.cluster);
  1455. if (el) el.disabled = busy;
  1456. }
  1457. }
  1458. // --- job events ------------------------------------------------------------------
  1459. function onJob(ev) {
  1460. if (ev.event === "started") {
  1461. state.jobs.set(ev.id, { id: ev.id, kind: ev.kind, cluster: ev.cluster, recipe: ev.recipe, status: "running", message: ev.message, started: Date.now() / 1000 });
  1462. }
  1463. const j = state.jobs.get(ev.id);
  1464. if (j && ev.event !== "started") {
  1465. j.message = ev.message;
  1466. j.status = ev.event === "ok" ? "ok" : "failed";
  1467. }
  1468. renderJobs();
  1469. }
  1470. // Stream-end notes: the snapshot carries attaches[].done; surface a
  1471. // re-attach hint in each log window whose stream died.
  1472. function updateLogNotes(snap) {
  1473. for (const w of logWins.values()) {
  1474. if (!w.attach || !snap.attaches) continue;
  1475. const a = snap.attaches.find((x) => x.id === w.attach);
  1476. const note = w.win.querySelector(".lognote");
  1477. if (!note) continue;
  1478. if (a && a.done && !note.classList.contains("dead")) {
  1479. note.textContent = "stream ended — click ↻ to re-attach";
  1480. note.classList.add("dead");
  1481. }
  1482. }
  1483. }
  1484. function attachDone(attachId) {
  1485. if (!attachId) return true;
  1486. const a = state.snap && state.snap.attaches ? state.snap.attaches.find((x) => x.id === attachId) : null;
  1487. return a ? a.done : false;
  1488. }
  1489. function flashWin(win) {
  1490. win.classList.add("flash");
  1491. setTimeout(() => win.classList.remove("flash"), 700);
  1492. }
  1493. // --- log windows -------------------------------------------------------------------
  1494. const MAX_PRE_LINES = 1000;
  1495. // ponytail: trimPre re-splits the whole text per line (O(n)); fine at log
  1496. // rates — ring it (line array) if a busy container makes this visible.
  1497. function trimPre(pre) {
  1498. const t = pre.textContent;
  1499. if (t.split("\n").length > MAX_PRE_LINES + 1) {
  1500. pre.textContent = t.split("\n").slice(-MAX_PRE_LINES).join("\n");
  1501. }
  1502. }
  1503. function appendLogLine(w, line) {
  1504. if (w.paused) { w.buf += line + "\n"; return; }
  1505. preSet(w, w.pre.textContent + line + "\n");
  1506. }
  1507. function preSet(w, text) {
  1508. w.pre.textContent = text;
  1509. trimPre(w.pre);
  1510. if (!w.paused) w.pre.scrollTop = w.pre.scrollHeight;
  1511. }
  1512. function onLog(ev) {
  1513. const w = logWins.get(ev.attach);
  1514. if (!w) return; // window closed (or from another tab) — nothing to show
  1515. appendLogLine(w, ev.line);
  1516. }
  1517. async function openLog(cluster, node, recipe) {
  1518. const snap = state.snap;
  1519. const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
  1520. const n = c ? c.nodes.find((x) => x.name === node) : null;
  1521. if (!n) return;
  1522. // A window for this node is already open and no specific recipe asked for
  1523. // → front it with feedback; if its stream died, re-attach (fresh history).
  1524. if (!recipe) {
  1525. for (const w of logWins.values()) if (w.cluster === cluster && w.node === node) {
  1526. front(w.win);
  1527. flashWin(w.win);
  1528. if (w.attach && attachDone(w.attach)) reattachLog(w); // dead stream → fresh
  1529. return;
  1530. }
  1531. }
  1532. recipe = recipe || (n.containers[0] ? n.containers[0].recipe : "");
  1533. const win = makeWin({
  1534. title: "logs: " + node + (recipe ? " · " + recipe : ""),
  1535. short: "logs: " + node,
  1536. x: 20 + (logWins.size * 26) % 240, y: 20 + (logWins.size * 26) % 160,
  1537. w: Math.min(620, innerWidth - 40), h: Math.min(420, innerHeight * 0.6),
  1538. bodyHtml: `<div class="logbar">
  1539. <select title="container on ${esc(node)}"></select>
  1540. <label><input type="checkbox" class="logpause"> pause</label>
  1541. <button class="abtn logcopy">copy</button>
  1542. <button class="abtn logrefresh" title="re-attach (fresh history + stream)">↻</button>
  1543. <span class="lognote"></span>
  1544. </div><pre class="logpre"></pre>`,
  1545. extraCls: "logwin",
  1546. removeOnClose: true,
  1547. onClosed: () => {
  1548. const w = [...logWins.entries()].find(([, x]) => x.node === node && x.cluster === cluster && x.win === win.win);
  1549. if (w) detachLog(w[0]);
  1550. },
  1551. });
  1552. const w = { cluster, node, recipe, rank: n.rank, win: win.win, pre: win.body.querySelector(".logpre"), buf: "", paused: false };
  1553. win.body.querySelector(".logcopy").onclick = async () => {
  1554. try { await navigator.clipboard.writeText(w.pre.textContent); } catch {}
  1555. };
  1556. win.body.querySelector(".logrefresh").onclick = () => { if (w.attach) reattachLog(w); };
  1557. const pauseCb = win.body.querySelector(".logpause");
  1558. pauseCb.onchange = () => {
  1559. w.paused = pauseCb.checked;
  1560. if (!w.paused && w.buf) { preSet(w, w.pre.textContent + w.buf); w.buf = ""; }
  1561. };
  1562. const sel = win.body.querySelector("select");
  1563. for (const x of n.containers) {
  1564. const o = document.createElement("option");
  1565. o.value = x.recipe;
  1566. o.textContent = x.name + " · " + x.recipe;
  1567. sel.appendChild(o);
  1568. }
  1569. if (n.containers.length) sel.value = recipe;
  1570. sel.onchange = async () => {
  1571. if (!sel.value) return;
  1572. const cur = [...logWins.entries()].find(([, x]) => x.win === win.win);
  1573. if (cur) detachLog(cur[0], false); // drop the old stream, keep this window
  1574. await attachLog(w, sel.value);
  1575. };
  1576. const note = win.body.querySelector(".lognote");
  1577. // No containers AND no explicit recipe → nothing to attach; just the note.
  1578. // An explicit recipe attaches anyway (the snapshot may lag the fleet —
  1579. // the container can be running but not yet probed).
  1580. if (!n.containers.length && !recipe) {
  1581. note.textContent = "no sm121 containers on " + node + " — nothing to stream";
  1582. note.classList.add("dead");
  1583. logWins.set("noop:" + cluster + ":" + node, { ...w, attach: null });
  1584. return;
  1585. }
  1586. await attachLog(w, recipe || (n.containers[0] ? n.containers[0].recipe : ""));
  1587. }
  1588. async function attachLog(w, recipe) {
  1589. w.recipe = recipe;
  1590. w.win.querySelector(".wtitle").textContent = "logs: " + w.node + " · " + recipe;
  1591. const post = await api("/api/logs", { cluster: w.cluster, node: w.node, recipe, rank: w.rank });
  1592. w.attach = post.attach_id;
  1593. preSet(w, (post.history || []).join("\n"));
  1594. // Re-key: a select-change swap detached the old entry under its old id.
  1595. for (const [k] of [...logWins.entries()]) {
  1596. if (logWins.get(k).win === w.win && logWins.get(k).attach !== post.attach_id) logWins.delete(k);
  1597. }
  1598. logWins.set(post.attach_id, w);
  1599. const note = w.win.querySelector(".lognote");
  1600. note.textContent = "";
  1601. note.classList.remove("dead");
  1602. }
  1603. function detachLog(attachId, hide = true) {
  1604. const w = logWins.get(attachId);
  1605. if (!w) return;
  1606. logWins.delete(attachId);
  1607. if (w.attach) fetch("/api/logs", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ attach_id: w.attach }) }).catch(() => {});
  1608. if (hide && w.win && w.win.isConnected) { w.win.style.display = "none"; renderDock(); }
  1609. }
  1610. // Re-attach an existing window: drop the old stream (window stays) and
  1611. // start a fresh one (new 200-line history + follow).
  1612. async function reattachLog(w) {
  1613. const recipe = w.recipe;
  1614. if (w.attach) detachLog(w.attach, false);
  1615. await attachLog(w, recipe);
  1616. }
  1617. // --- raw content windows ---------------------------------------------------------
  1618. // One window, a kind selector: config / effective / recipe:<name>. Content is
  1619. // verbatim from the server (effective is pretty JSON); copy grabs the text.
  1620. let rawWin = null;
  1621. let recipesWin = null;
  1622. // The recipes panel IS the `sm121 report --serve` page, nested by the
  1623. // server at /recipes and embedded as-is: table, grouping, stars, the /r/
  1624. // viewer and live updates stay one implementation (paths prefix-rewritten
  1625. // server-side, theme inherited through the iframe's color-scheme).
  1626. function openRecipes(g) {
  1627. if (recipesWin) { activateWindow(recipesWin); return recipesWin; } // shows a minimized/hidden-max one too
  1628. const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
  1629. recipesWin = makeWin({
  1630. id: "win-recipes", title: "Recipes", x: r("left", 24), y: r("top", 24),
  1631. w: r("width", Math.min(1180, innerWidth - 48)), h: r("height", Math.min(760, innerHeight - 80)),
  1632. bodyHtml: `<iframe src="/recipes" title="recipes report"></iframe>`,
  1633. extraCls: "recwin",
  1634. removeOnClose: true,
  1635. onClosed: () => { recipesWin = null; },
  1636. });
  1637. saveWinState();
  1638. return recipesWin;
  1639. }
  1640. async function openRaw(g) {
  1641. if (rawWin) { activateWindow(rawWin); return rawWin; }
  1642. const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
  1643. const handle = makeWin({
  1644. title: "raw", x: r("left", 20), y: r("top", 20),
  1645. w: r("width", Math.min(680, innerWidth - 40)), h: r("height", Math.min(460, innerHeight * 0.65)),
  1646. bodyHtml: `<div class="rawbar">
  1647. <select></select>
  1648. <button class="abtn rawcopy">copy</button>
  1649. <span class="rawnote"></span>
  1650. </div><pre class="logpre rawpre"></pre>`,
  1651. extraCls: "rawwin",
  1652. removeOnClose: true,
  1653. onClosed: () => { rawWin = null; },
  1654. });
  1655. rawWin = handle;
  1656. const sel = handle.body.querySelector("select");
  1657. const pre = handle.body.querySelector(".rawpre");
  1658. const note = handle.body.querySelector(".rawnote");
  1659. handle.body.querySelector(".rawcopy").onclick = async () => {
  1660. try { await navigator.clipboard.writeText(pre.textContent); } catch {}
  1661. };
  1662. const fill = () => {
  1663. // rebuild only when the recipe set changed (works with zero recipes:
  1664. // config/effective are always selectable; recipe data may live in an
  1665. // external workspace).
  1666. const sig = state.recipes.map((r) => r.name).join(",");
  1667. if (sel.dataset.sig === sig && sel.options.length) return;
  1668. sel.dataset.sig = sig;
  1669. const cur = sel.value;
  1670. sel.innerHTML = "";
  1671. for (const kind of ["config", "effective", "recipe:", ...state.recipes.map((r) => "recipe:" + r.name)]) {
  1672. const o = document.createElement("option");
  1673. o.value = kind === "recipe:" ? "" : kind;
  1674. o.textContent = kind === "effective" ? "effective (merged config)" : kind;
  1675. if (kind === "recipe:") { o.disabled = true; o.textContent = "— recipes —"; }
  1676. sel.appendChild(o);
  1677. }
  1678. sel.value = cur || "config";
  1679. load();
  1680. };
  1681. let seq = 0;
  1682. async function load() {
  1683. const kind = sel.value;
  1684. const mine = ++seq; // responses can land out of order — last one wins
  1685. note.textContent = "";
  1686. if (!kind) return;
  1687. const path = kind === "config" ? "/api/raw/config" : kind === "effective" ? "/api/raw/effective" : "/api/raw/recipe/" + encodeURIComponent(kind.slice("recipe:".length));
  1688. try {
  1689. const res = await fetch(path);
  1690. const text = await res.text();
  1691. if (mine !== seq) return; // a newer selection already answered
  1692. if (!res.ok) { note.textContent = "error: " + text; return; }
  1693. if (kind === "effective") {
  1694. try { pre.textContent = JSON.stringify(JSON.parse(text), null, 2); } catch { pre.textContent = text; }
  1695. } else pre.textContent = text;
  1696. } catch (e) { if (mine === seq) note.textContent = "error: " + e.message; }
  1697. }
  1698. sel.onchange = load;
  1699. fill(); // config/effective are always available; recipes fill in later
  1700. state.onRecipesReady = fill; // recipes may arrive after the first open
  1701. saveWinState();
  1702. return handle;
  1703. }
  1704. // --- actions -----------------------------------------------------------------------
  1705. const cur = () => state.snap;
  1706. function recipeOf(cluster) {
  1707. const sel = document.getElementById("recipe-" + cluster);
  1708. return sel ? sel.value : "";
  1709. }
  1710. function dryOf(cluster) {
  1711. const d = document.getElementById("dry-" + cluster);
  1712. return d ? d.checked : false;
  1713. }
  1714. async function api(path, body) {
  1715. const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
  1716. if (!res.ok) {
  1717. let m = res.status;
  1718. try { m = (await res.json()).error || m; } catch {}
  1719. throw new Error(m);
  1720. }
  1721. return res.json();
  1722. }
  1723. function doServe(cluster) {
  1724. const recipe = recipeOf(cluster);
  1725. if (!recipe) { alert("pick a recipe first"); return; }
  1726. const body = serveBody(cluster, recipe, dryOf(cluster), flagsOf(cluster));
  1727. if (body.dry_run) {
  1728. api("/api/serve", body).then((r) => openDialog("dry-run: " + r.job, "started (watch the jobs window)", () => {}));
  1729. return;
  1730. }
  1731. openDialog(`serve ${recipe} on ${cluster}`, `Launches <code>${esc(recipe)}</code> on cluster <code>${esc(cluster)}</code>.`, () => {
  1732. api("/api/serve", body).catch((e) => openDialog("serve failed", esc(e.message), () => {}, true));
  1733. });
  1734. }
  1735. function doStop(cluster) {
  1736. const snap = cur();
  1737. const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
  1738. const containers = c ? c.nodes.flatMap((n) => n.containers) : [];
  1739. const recipe = recipeOf(cluster);
  1740. if (!recipe) { alert("pick the recipe to stop first"); return; }
  1741. const body = document.createElement("div");
  1742. body.className = "body";
  1743. const nodeSel = document.createElement("select");
  1744. nodeSel.id = "stopnode";
  1745. nodeSel.title = "stop on one node instead of all";
  1746. nodeSel.innerHTML = `<option value=''>all nodes</option>` + (c ? c.nodes.map((n) => `<option value='${esc(n.name)}'>${esc(n.name)}</option>`).join("") : "");
  1747. const list = containers.length
  1748. ? `<ul class="cbox">${containers.map((x) => `<li><b>${esc(x.name)}</b> — ${esc(x.status)}</li>`).join("")}</ul>`
  1749. : `<div class="cbox">no containers detected on ${esc(cluster)} (recipe may not be running here)</div>`;
  1750. body.innerHTML = `Will run <code>sm121 stop</code> for <code>${esc(recipe)}</code> on <code>${esc(cluster)}</code>. Node: <span data-stopnode></span>. Currently running:${list}`;
  1751. body.querySelector("[data-stopnode]").replaceWith(nodeSel);
  1752. openDialogHtml(`stop ${recipe}`, body, () => {
  1753. api("/api/stop", { cluster, recipe, node: nodeSel.value || undefined }).catch((e) => openDialog("stop failed", esc(e.message), () => {}, true));
  1754. });
  1755. }
  1756. function doPower(action, cluster) {
  1757. if (action === "off") {
  1758. const snap = cur();
  1759. const c = snap ? snap.clusters.find((x) => x.name === cluster) : null;
  1760. const busy = c ? c.nodes.filter((n) => n.occupied) : [];
  1761. if (busy.length) {
  1762. openDialog(`power off ${cluster} blocked`,
  1763. `Cluster has ${busy.length} occupied node(s): <code>${esc(busy.map((n) => n.name).join(", "))}</code>. Stop its workloads first.`,
  1764. () => {}, true);
  1765. return;
  1766. }
  1767. twoStep(`power off ${cluster}`,
  1768. `Shuts down <b>all nodes</b> of <code>${esc(cluster)}</code> (SSH then Home Assistant). This can kill live traffic.`,
  1769. cluster, () => {
  1770. api("/api/power", { action: "off", cluster }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
  1771. });
  1772. return;
  1773. }
  1774. if (action === "switch") {
  1775. const snap = cur();
  1776. const names = snap ? snap.clusters.map((c) => c.name) : [];
  1777. const body = document.createElement("div");
  1778. body.className = "body";
  1779. let inner = `Power <code>${esc(cluster)}</code> off and bring up another cluster instead.`;
  1780. const sel = document.createElement("select");
  1781. sel.style.cssText = "width:100%;margin:8px 0;padding:6px;background:var(--btn);color:var(--fg);border:1px solid var(--border);border-radius:6px;";
  1782. for (const n of names.filter((n) => n !== cluster)) {
  1783. const o = document.createElement("option");
  1784. o.value = n; o.textContent = n;
  1785. sel.appendChild(o);
  1786. }
  1787. body.innerHTML = inner;
  1788. body.appendChild(sel);
  1789. openDialogHtml(`power switch from ${cluster}`, body, () => {
  1790. const to = sel.value;
  1791. api("/api/power", { action: "switch", cluster, to }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
  1792. });
  1793. return;
  1794. }
  1795. twoStep(`power on ${cluster}`, `Turns on <code>${esc(cluster)}</code> via Home Assistant (SSH is not used).`, cluster, () => {
  1796. api("/api/power", { action: "on", cluster }).catch((e) => openDialog("power failed", esc(e.message), () => {}, true));
  1797. });
  1798. }
  1799. function twoStep(title, bodyHtml, nameToType, go) {
  1800. openDialogHtml(title, (() => { const d = document.createElement("div"); d.className = "body"; d.innerHTML = bodyHtml; return d; })(),
  1801. () => {}, { twoStep: nameToType, go });
  1802. }
  1803. // --- dialog -----------------------------------------------------------------------
  1804. let dlg = { ok: null, twoStep: null };
  1805. function openDialog(title, bodyHtml, go, danger) {
  1806. $("#dlg-title").textContent = title;
  1807. $("#dlg-body").innerHTML = bodyHtml;
  1808. const ok = $("#dlg-ok");
  1809. ok.style.display = "";
  1810. ok.textContent = "Confirm";
  1811. ok.className = "abtn" + (danger ? " danger" : "");
  1812. dlg = { ok: go, twoStep: null };
  1813. $("#dlg").hidden = false;
  1814. }
  1815. function openDialogHtml(title, bodyEl, go, opts) {
  1816. $("#dlg-title").textContent = title;
  1817. $("#dlg-body").innerHTML = "";
  1818. $("#dlg-body").appendChild(bodyEl);
  1819. const ok = $("#dlg-ok");
  1820. ok.style.display = "";
  1821. if (opts && opts.twoStep) {
  1822. ok.style.display = "none";
  1823. const wrap = document.createElement("div");
  1824. wrap.innerHTML = `<div style="font-size:12.5px;color:var(--muted)">Type <b>${esc(opts.twoStep)}</b> to confirm.</div>
  1825. <input id="dlg-type" placeholder="${esc(opts.twoStep)}" autocomplete="off">`; $("#dlg-body").appendChild(wrap);
  1826. const inp = wrap.querySelector("#dlg-type");
  1827. inp.oninput = () => {
  1828. if (inp.value === opts.twoStep) {
  1829. ok.style.display = "";
  1830. ok.textContent = "⚡ Confirm power action";
  1831. ok.className = "abtn danger";
  1832. } else { ok.style.display = "none"; }
  1833. };
  1834. dlg = { ok: null, twoStep: opts.twoStep, go };
  1835. } else {
  1836. dlg = { ok: go, twoStep: null };
  1837. }
  1838. $("#dlg").hidden = false;
  1839. }
  1840. $("#dlg-ok").onclick = () => {
  1841. const go = dlg.twoStep ? dlg.go : dlg.ok;
  1842. $("#dlg").hidden = true;
  1843. if (go) go();
  1844. };
  1845. $("#dlg-cancel").onclick = () => { $("#dlg").hidden = true; };
  1846. document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#dlg").hidden = true; });
  1847. // --- boot ---------------------------------------------------------------------------
  1848. function initManagedWindows() {
  1849. const W = innerWidth;
  1850. // vertical budget = the actual window layer (header/dock already excluded);
  1851. // +40 reserve for the dock, which appears when a window gets minimized
  1852. const L = Math.max(420, $("#winlayer").clientHeight);
  1853. // topology map: the primary window (left); state/jobs stack on the right
  1854. const tw = Math.max(560, Math.floor(W * 0.58));
  1855. const rx = tw + 30, rw = Math.max(340, W - rx - 12);
  1856. // the map is a fixed-aspect drawing: size its window to the drawing, or
  1857. // the SVG letterboxes itself with big empty bands above and below
  1858. const TH = Math.min(Math.round(tw * (TG_H / TG_W)) + 34, L - 260);
  1859. wins.topo = makeWin({
  1860. id: "win-topo", title: "Topology", x: 12, y: 12,
  1861. w: tw, h: TH,
  1862. bodyHtml: `<div id="topo"><div class="topo-empty">waiting for the first fleet scan…</div></div>`,
  1863. });
  1864. wins.jobs = makeWin({
  1865. id: "win-jobs", title: "Jobs", x: 12, y: TH + 24,
  1866. w: tw, h: L - TH - 24,
  1867. bodyHtml: `<div class="jobs" id="jobs"><div class="jobs-empty">no sm121 containers running, no actions yet — serve / stop / power started from this dashboard appear here</div></div>`,
  1868. });
  1869. wins.state = makeWin({
  1870. id: "win-state", title: "Cluster state", x: rx, y: 12,
  1871. w: rw, h: L - 12,
  1872. bodyHtml: `<div id="clusters"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div>`,
  1873. });
  1874. // last session's layout wins over the defaults
  1875. const st = readWinState();
  1876. restoring = true;
  1877. for (const [k, h] of Object.entries(wins)) {
  1878. const g = st[k];
  1879. if (!g) continue;
  1880. if (g.closed) { h.close(); continue; }
  1881. h.setGeometry(g);
  1882. if (g.max) h.maximize();
  1883. else if (g.min) h.minimize();
  1884. }
  1885. restoring = false;
  1886. fitToLayer();
  1887. renderDock();
  1888. }
  1889. async function boot() {
  1890. initManagedWindows();
  1891. try {
  1892. const res = await fetch("/api/state");
  1893. if (res.ok) {
  1894. const snap = await res.json();
  1895. onState(snap);
  1896. // Restore log windows from other tabs / previous session (idempotent
  1897. // attach: a live stream is reused, a finished one re-streams history).
  1898. for (const a of snap.attaches || []) {
  1899. if (![...logWins.values()].some((w) => w.cluster === a.cluster && w.node === a.node)) {
  1900. openLog(a.cluster, a.node, a.recipe);
  1901. }
  1902. }
  1903. }
  1904. } catch {}
  1905. try {
  1906. const res = await fetch("/api/recipes");
  1907. if (res.ok) {
  1908. const d = await res.json();
  1909. state.recipes = (d.recipes || []).sort((a, b) => a.name.localeCompare(b.name));
  1910. if (state.snap) onState(state.snap, true); // force: sameSnapshot would skip the identical fleet
  1911. if (state.onRecipesReady) { const f = state.onRecipesReady; delete state.onRecipesReady; f(); }
  1912. }
  1913. } catch {}
  1914. connectSSE();
  1915. // 30 s heartbeat refresh (mirrors the server poll; harmless if state is identical)
  1916. addEventListener("resize", fitToLayer);
  1917. // optional windows (their own SSE/API calls are cheap and idempotent)
  1918. restoreWindow("recipes", openRecipes);
  1919. restoreWindow("raw", openRaw);
  1920. setInterval(async () => {
  1921. try {
  1922. const res = await fetch("/api/state");
  1923. if (res.ok) onState(await res.json());
  1924. } catch {}
  1925. }, 30000);
  1926. }
  1927. boot();
  1928. </script>
  1929. </body>
  1930. </html>