Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

3241 lines
109KB

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>fm-rds-tx</title>
  7. <style>
  8. @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
  9. :root {
  10. --bg: #f7f8fb;
  11. --bg-2: #eef0f3;
  12. --surface: #ffffff;
  13. --surface2: #f4f6f8;
  14. --surface3: #e8ecf1;
  15. --border: #d7dcdf;
  16. --border-strong: #bcc5ce;
  17. --text: #111724;
  18. --text-dim: #4c5a6a;
  19. --text-muted: #6b7683;
  20. --accent: #1f4d9d;
  21. --accent-soft: rgba(31, 77, 157, 0.08);
  22. --green: #0d944a;
  23. --green-soft: rgba(13, 148, 74, 0.1);
  24. --amber: #b7791f;
  25. --amber-soft: rgba(183, 121, 31, 0.12);
  26. --red: #b03030;
  27. --red-soft: rgba(176, 48, 48, 0.1);
  28. --mono: 'JetBrains Mono', monospace;
  29. --display: 'Inter', sans-serif;
  30. --radius: 8px;
  31. --shadow: 0 8px 24px rgba(15,23,42,0.08);
  32. }
  33. * { box-sizing: border-box; margin: 0; padding: 0; }
  34. html { color-scheme: light; }
  35. body {
  36. background: linear-gradient(180deg, #fbfcfe 0%, var(--bg) 100%);
  37. color: var(--text);
  38. font-family: var(--display);
  39. font-size: 14px;
  40. line-height: 1.5;
  41. min-height: 100vh;
  42. overflow-x: hidden;
  43. }
  44. button, input { font: inherit; }
  45. button { user-select: none; }
  46. .app {
  47. max-width: 1200px;
  48. margin: 0 auto;
  49. padding: 24px;
  50. }
  51. .header {
  52. display: flex;
  53. align-items: flex-start;
  54. justify-content: space-between;
  55. gap: 18px;
  56. padding: 4px 0 20px;
  57. border-bottom: 1px solid var(--border);
  58. margin-bottom: 20px;
  59. }
  60. .header-main {
  61. display: flex;
  62. flex-direction: column;
  63. gap: 8px;
  64. }
  65. .header h1 {
  66. font-family: var(--display);
  67. font-size: 28px;
  68. font-weight: 800;
  69. letter-spacing: -0.03em;
  70. text-transform: none;
  71. color: var(--text);
  72. }
  73. .header-note {
  74. font-size: 13px;
  75. color: var(--text-muted);
  76. }
  77. .header-sub {
  78. display: flex;
  79. flex-wrap: wrap;
  80. gap: 8px;
  81. }
  82. .badge {
  83. display: inline-flex;
  84. align-items: center;
  85. gap: 8px;
  86. min-height: 28px;
  87. padding: 0 10px;
  88. border: 1px solid var(--border);
  89. border-radius: 999px;
  90. background: var(--surface2);
  91. color: var(--text-dim);
  92. font-size: 11px;
  93. text-transform: uppercase;
  94. letter-spacing: .08em;
  95. }
  96. .badge strong {
  97. color: var(--text);
  98. font-weight: 700;
  99. }
  100. .header-status {
  101. display: flex;
  102. align-items: center;
  103. gap: 10px;
  104. padding-top: 6px;
  105. }
  106. .led {
  107. width: 10px;
  108. height: 10px;
  109. border-radius: 50%;
  110. background: #333;
  111. box-shadow: none;
  112. transition: all .25s ease;
  113. flex-shrink: 0;
  114. }
  115. .led.on-green {
  116. background: var(--green);
  117. box-shadow: 0 0 0 3px rgba(13,148,74,0.16);
  118. }
  119. .led.on-red {
  120. background: var(--red);
  121. box-shadow: 0 0 0 3px rgba(176,48,48,0.14);
  122. }
  123. .led.on-amber {
  124. background: var(--amber);
  125. box-shadow: 0 0 0 3px rgba(183,121,31,0.14);
  126. }
  127. .led.on-blue {
  128. background: var(--accent);
  129. box-shadow: 0 0 0 3px rgba(31,77,157,0.14);
  130. }
  131. .status-text {
  132. font-size: 10px;
  133. color: var(--text-dim);
  134. text-transform: uppercase;
  135. letter-spacing: .08em;
  136. }
  137. .tab-bar {
  138. display: flex;
  139. gap: 6px;
  140. border-bottom: 1px solid var(--border);
  141. padding-bottom: 0;
  142. margin: 0 0 20px;
  143. flex-wrap: wrap;
  144. position: sticky;
  145. top: 0;
  146. background: rgba(247,248,251,.92);
  147. backdrop-filter: blur(8px);
  148. z-index: 10;
  149. }
  150. .tab-btn {
  151. border: none;
  152. border-bottom: 3px solid transparent;
  153. border-radius: 0;
  154. background: transparent;
  155. color: var(--text-dim);
  156. font-size: 13px;
  157. font-weight: 700;
  158. padding: 12px 16px 11px;
  159. cursor: pointer;
  160. transition: color .16s ease, border-color .16s ease, background-color .16s ease;
  161. }
  162. .tab-btn.active {
  163. border-bottom-color: var(--accent);
  164. background: transparent;
  165. color: var(--accent);
  166. }
  167. .tab-btn:focus-visible {
  168. outline: 2px solid var(--accent);
  169. outline-offset: 2px;
  170. }
  171. .tab-panels {
  172. display: flex;
  173. flex-direction: column;
  174. gap: 14px;
  175. }
  176. .tab-panel {
  177. display: none;
  178. }
  179. .tab-panel.active {
  180. display: block;
  181. }
  182. .tab-columns {
  183. display: grid;
  184. gap: 16px;
  185. }
  186. .tab-columns.two {
  187. grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.85fr);
  188. }
  189. .tab-columns.one {
  190. grid-template-columns: 1fr;
  191. }
  192. .stack { display: flex; flex-direction: column; gap: 12px; }
  193. .card {
  194. background: var(--surface);
  195. border: 1px solid var(--border);
  196. border-radius: var(--radius);
  197. box-shadow: var(--shadow);
  198. }
  199. .hero {
  200. padding: 16px;
  201. }
  202. .tx-bar {
  203. position: relative;
  204. z-index: 1;
  205. display: grid;
  206. grid-template-columns: minmax(180px, 250px) 1fr auto;
  207. gap: 14px;
  208. align-items: center;
  209. }
  210. .freq-display-wrap {
  211. display: flex;
  212. flex-direction: column;
  213. gap: 6px;
  214. }
  215. .freq-display-label {
  216. font-size: 10px;
  217. text-transform: uppercase;
  218. letter-spacing: .08em;
  219. color: var(--text-dim);
  220. }
  221. .freq-display {
  222. font-family: var(--display);
  223. font-size: 40px;
  224. color: var(--text);
  225. letter-spacing: -0.04em;
  226. line-height: 1;
  227. font-weight: 800;
  228. }
  229. .freq-display .unit {
  230. font-family: var(--mono);
  231. font-size: 14px;
  232. color: var(--text-dim);
  233. margin-left: 5px;
  234. }
  235. .freq-note {
  236. display: flex;
  237. gap: 12px;
  238. margin-top: 6px;
  239. font-size: 11px;
  240. color: var(--text-muted);
  241. text-transform: uppercase;
  242. letter-spacing: .08em;
  243. }
  244. .freq-note-item {
  245. display: inline-flex;
  246. align-items: center;
  247. gap: 4px;
  248. }
  249. .freq-note.mismatch .freq-note-item {
  250. color: var(--amber);
  251. }
  252. .tx-actions {
  253. display: flex;
  254. flex-wrap: wrap;
  255. gap: 10px;
  256. }
  257. .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn {
  258. min-height: 40px;
  259. padding: 0 18px;
  260. border-radius: var(--radius);
  261. border: 1px solid var(--border);
  262. background: var(--surface2);
  263. color: var(--text);
  264. cursor: pointer;
  265. font-size: 12px;
  266. font-weight: 700;
  267. text-transform: uppercase;
  268. letter-spacing: .08em;
  269. transition: all .16s ease;
  270. }
  271. .tx-btn:hover, .ghost-btn:hover, .apply-btn:hover, .preset-btn:hover, .danger-btn:hover {
  272. transform: translateY(-1px);
  273. border-color: var(--border-strong);
  274. }
  275. .tx-btn:disabled, .ghost-btn:disabled, .apply-btn:disabled, .preset-btn:disabled, .danger-btn:disabled {
  276. opacity: .45;
  277. cursor: not-allowed;
  278. transform: none;
  279. }
  280. .tx-btn.start { background: var(--green); color: #fff; border-color: transparent; }
  281. .tx-btn.start:hover:not(:disabled) { background: #0b7f40; }
  282. .tx-btn.stop { background: #fff; color: var(--red); border-color: rgba(176,48,48,.35); }
  283. .tx-btn.stop:hover:not(:disabled) { background: var(--red-soft); }
  284. .ghost-btn { color: var(--text-dim); }
  285. .danger-btn {
  286. border-color: rgba(176,48,48,.35);
  287. color: var(--red);
  288. background: var(--red-soft);
  289. }
  290. .danger-btn:hover:not(:disabled) {
  291. background: rgba(176,48,48,.16);
  292. }
  293. .tx-state-wrap {
  294. display: flex;
  295. flex-direction: column;
  296. align-items: flex-end;
  297. gap: 6px;
  298. }
  299. .tx-state {
  300. font-size: 11px;
  301. text-transform: uppercase;
  302. letter-spacing: 2px;
  303. color: var(--text-dim);
  304. }
  305. .tx-state.running { color: var(--green); }
  306. .tx-state.idle, .tx-state.stopped { color: var(--text-dim); }
  307. .tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); }
  308. .tx-state.error { color: var(--red); }
  309. .status-hint {
  310. font-size: 10px;
  311. color: var(--text-muted);
  312. text-align: right;
  313. }
  314. .quick-grid {
  315. position: relative;
  316. z-index: 1;
  317. display: grid;
  318. grid-template-columns: repeat(5, minmax(0, 1fr));
  319. gap: 10px;
  320. margin-top: 16px;
  321. }
  322. .quick-item {
  323. padding: 12px;
  324. border: 1px solid var(--border);
  325. border-radius: var(--radius);
  326. background: var(--surface2);
  327. }
  328. .quick-item .label {
  329. font-size: 9px;
  330. text-transform: uppercase;
  331. letter-spacing: .08em;
  332. color: var(--text-dim);
  333. margin-bottom: 6px;
  334. }
  335. .quick-item .value {
  336. font-size: 18px;
  337. font-weight: 700;
  338. color: var(--text);
  339. }
  340. .quick-item .value.warn { color: var(--amber); }
  341. .quick-item .value.err { color: var(--accent); }
  342. .quick-item .value.good { color: var(--green); }
  343. .signal-grid {
  344. position: relative;
  345. z-index: 1;
  346. display: grid;
  347. grid-template-columns: repeat(3, minmax(0, 1fr));
  348. gap: 10px;
  349. margin-top: 12px;
  350. }
  351. .signal-card {
  352. padding: 12px;
  353. border: 1px solid var(--border);
  354. border-radius: var(--radius);
  355. background: var(--surface2);
  356. }
  357. .signal-head {
  358. display: flex;
  359. align-items: center;
  360. justify-content: space-between;
  361. gap: 10px;
  362. margin-bottom: 8px;
  363. }
  364. .signal-title {
  365. font-size: 10px;
  366. color: var(--text-dim);
  367. text-transform: uppercase;
  368. letter-spacing: .08em;
  369. }
  370. .signal-value {
  371. font-size: 11px;
  372. color: var(--text);
  373. font-weight: 700;
  374. }
  375. .meter {
  376. width: 100%;
  377. height: 10px;
  378. border-radius: 999px;
  379. background: #e7ebf0;
  380. border: 1px solid var(--border);
  381. overflow: hidden;
  382. }
  383. .meter-fill {
  384. height: 100%;
  385. width: 0%;
  386. transition: width .25s ease, background-color .25s ease;
  387. background: linear-gradient(90deg, var(--green), #3dbd75);
  388. }
  389. .meter-fill.warn {
  390. background: linear-gradient(90deg, var(--amber), #d9a14a);
  391. }
  392. .meter-fill.err {
  393. background: linear-gradient(90deg, var(--red), #d85c5c);
  394. }
  395. .spark {
  396. width: 100%;
  397. height: 34px;
  398. margin-top: 10px;
  399. border-radius: 6px;
  400. background: #f7f9fb;
  401. border: 1px solid #e3e8ee;
  402. }
  403. .spark path.line {
  404. fill: none;
  405. stroke-width: 2;
  406. stroke-linecap: round;
  407. stroke-linejoin: round;
  408. }
  409. .spark path.area {
  410. opacity: .14;
  411. }
  412. .spark.good path.line { stroke: var(--green); }
  413. .spark.good path.area { fill: var(--green); }
  414. .spark.warn path.line { stroke: var(--amber); }
  415. .spark.warn path.area { fill: var(--amber); }
  416. .spark.err path.line { stroke: var(--accent); }
  417. .spark.err path.area { fill: var(--accent); }
  418. .panel {
  419. overflow: hidden;
  420. }
  421. .panel-head {
  422. display: flex;
  423. align-items: center;
  424. gap: 8px;
  425. padding: 12px 14px;
  426. border-bottom: 1px solid var(--border);
  427. background: var(--surface2);
  428. cursor: pointer;
  429. user-select: none;
  430. }
  431. .panel-head h2 {
  432. font-size: 11px;
  433. font-weight: 700;
  434. text-transform: uppercase;
  435. letter-spacing: 1.6px;
  436. color: var(--text-dim);
  437. }
  438. .panel-head .meta {
  439. margin-left: auto;
  440. margin-right: 8px;
  441. font-size: 10px;
  442. color: var(--text-muted);
  443. text-transform: uppercase;
  444. letter-spacing: 1px;
  445. }
  446. .panel-head .chevron {
  447. color: var(--text-dim);
  448. transition: transform .2s ease;
  449. font-size: 10px;
  450. }
  451. .panel-head.collapsed .chevron { transform: rotate(-90deg); }
  452. .panel-body { padding: 14px; }
  453. .panel-body.collapsed { display: none; }
  454. .section-note {
  455. font-size: 11px;
  456. color: var(--text-muted);
  457. margin-bottom: 12px;
  458. }
  459. .shortcuts-grid {
  460. display: grid;
  461. grid-template-columns: repeat(2, minmax(0, 1fr));
  462. gap: 8px 12px;
  463. }
  464. .shortcut-line {
  465. display: flex;
  466. align-items: center;
  467. justify-content: space-between;
  468. gap: 12px;
  469. padding: 7px 0;
  470. border-bottom: 1px solid #e6eaef;
  471. }
  472. .shortcut-line:last-child { border-bottom: none; }
  473. .shortcut-line .name { font-size: 11px; color: var(--text-dim); }
  474. .shortcut-line .keys {
  475. display: inline-flex;
  476. gap: 6px;
  477. flex-wrap: wrap;
  478. }
  479. .kbd {
  480. min-width: 28px;
  481. padding: 3px 7px;
  482. border: 1px solid var(--border);
  483. border-bottom-width: 2px;
  484. border-radius: 6px;
  485. background: var(--surface2);
  486. font-size: 10px;
  487. color: var(--text);
  488. text-align: center;
  489. }
  490. .preset-row {
  491. display: flex;
  492. flex-wrap: wrap;
  493. gap: 8px;
  494. margin-bottom: 12px;
  495. }
  496. .preset-btn {
  497. min-height: 34px;
  498. padding: 0 12px;
  499. font-size: 11px;
  500. letter-spacing: .08em;
  501. color: var(--text-dim);
  502. }
  503. .preset-btn.active {
  504. border-color: var(--accent);
  505. color: var(--accent);
  506. background: var(--accent-soft);
  507. }
  508. .preset-btn.rds {
  509. text-transform: none;
  510. font-weight: 600;
  511. }
  512. .ctrl-row {
  513. display: flex;
  514. align-items: center;
  515. gap: 12px;
  516. padding: 10px 0;
  517. border-bottom: 1px solid #e6eaef;
  518. }
  519. .ctrl-row:last-child { border-bottom: none; }
  520. .ctrl-label-wrap {
  521. min-width: 130px;
  522. display: flex;
  523. flex-direction: column;
  524. gap: 2px;
  525. }
  526. .ctrl-label {
  527. font-size: 11px;
  528. color: var(--text-dim);
  529. text-transform: uppercase;
  530. letter-spacing: .8px;
  531. }
  532. .ctrl-sub {
  533. font-size: 10px;
  534. color: var(--text-muted);
  535. }
  536. .ctrl-input {
  537. flex: 1;
  538. display: flex;
  539. align-items: center;
  540. gap: 10px;
  541. }
  542. input[type="range"] {
  543. -webkit-appearance: none;
  544. appearance: none;
  545. flex: 1;
  546. height: 6px;
  547. background: linear-gradient(90deg, var(--border), var(--surface3));
  548. border-radius: 999px;
  549. outline: none;
  550. }
  551. input[type="range"]::-webkit-slider-thumb {
  552. -webkit-appearance: none;
  553. width: 16px;
  554. height: 16px;
  555. border-radius: 50%;
  556. background: var(--text);
  557. border: 2px solid var(--bg);
  558. cursor: pointer;
  559. transition: background .15s ease, transform .15s ease;
  560. }
  561. input[type="range"]::-webkit-slider-thumb:hover {
  562. background: var(--accent);
  563. transform: scale(1.06);
  564. }
  565. input[type="number"], input[type="text"], select {
  566. background: #fff;
  567. border: 1px solid var(--border);
  568. border-radius: 6px;
  569. color: var(--text);
  570. padding: 8px 10px;
  571. outline: none;
  572. transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
  573. }
  574. input[type="number"] {
  575. width: 92px;
  576. text-align: right;
  577. }
  578. input[type="text"] { width: 100%; }
  579. select {
  580. min-width: 140px;
  581. width: 100%;
  582. }
  583. input:focus {
  584. border-color: var(--accent);
  585. box-shadow: 0 0 0 3px rgba(31,77,157,.12);
  586. }
  587. input.input-dirty {
  588. border-color: var(--amber);
  589. box-shadow: 0 0 0 3px rgba(183,121,31,.08);
  590. }
  591. input.input-error {
  592. border-color: var(--red);
  593. box-shadow: 0 0 0 3px rgba(176,48,48,.14);
  594. background: rgba(176,48,48,.04);
  595. }
  596. .val-display {
  597. min-width: 64px;
  598. text-align: right;
  599. font-size: 12px;
  600. font-weight: 700;
  601. color: var(--text);
  602. }
  603. .unit-label {
  604. font-size: 11px;
  605. color: var(--text-dim);
  606. min-width: 44px;
  607. }
  608. .field-error {
  609. display: none;
  610. margin-top: 8px;
  611. font-size: 11px;
  612. color: var(--accent);
  613. }
  614. .field-error.show { display: block; }
  615. .toggle-row {
  616. display: flex;
  617. align-items: center;
  618. justify-content: space-between;
  619. gap: 14px;
  620. padding: 12px 0;
  621. border-bottom: 1px solid #1a1a22;
  622. }
  623. .toggle-row:last-child { border-bottom: none; }
  624. .toggle-copy {
  625. display: flex;
  626. flex-direction: column;
  627. gap: 3px;
  628. }
  629. .toggle-copy .title {
  630. font-size: 12px;
  631. color: var(--text);
  632. font-weight: 700;
  633. }
  634. .toggle-copy .sub {
  635. font-size: 10px;
  636. color: var(--text-muted);
  637. }
  638. .toggle-ctl {
  639. display: flex;
  640. align-items: center;
  641. gap: 10px;
  642. }
  643. .toggle {
  644. position: relative;
  645. width: 42px;
  646. height: 24px;
  647. background: var(--border);
  648. border-radius: 999px;
  649. cursor: pointer;
  650. transition: all .2s ease;
  651. flex-shrink: 0;
  652. }
  653. .toggle::after {
  654. content: '';
  655. position: absolute;
  656. top: 3px;
  657. left: 3px;
  658. width: 18px;
  659. height: 18px;
  660. background: var(--text);
  661. border-radius: 50%;
  662. transition: transform .2s ease;
  663. }
  664. .toggle.on { background: var(--green); }
  665. .toggle.on::after { transform: translateX(18px); }
  666. .toggle.busy { opacity: .55; pointer-events: none; }
  667. .toggle-state {
  668. min-width: 52px;
  669. text-align: right;
  670. font-size: 11px;
  671. color: var(--text-dim);
  672. text-transform: uppercase;
  673. letter-spacing: 1px;
  674. }
  675. .rds-grid {
  676. display: grid;
  677. gap: 12px;
  678. }
  679. .rds-field {
  680. display: flex;
  681. flex-direction: column;
  682. gap: 6px;
  683. }
  684. .rds-input {
  685. width: 100%;
  686. background: #fff;
  687. border: 1px solid var(--border);
  688. border-radius: 6px;
  689. color: var(--accent);
  690. font-family: var(--mono);
  691. font-size: 15px;
  692. font-weight: 700;
  693. padding: 10px 12px;
  694. outline: none;
  695. letter-spacing: 2px;
  696. text-transform: uppercase;
  697. }
  698. .rds-input.rt {
  699. color: var(--text);
  700. text-transform: none;
  701. letter-spacing: .5px;
  702. font-size: 12px;
  703. font-weight: 500;
  704. }
  705. .rds-charcount {
  706. font-size: 10px;
  707. color: var(--text-dim);
  708. text-align: right;
  709. }
  710. .actions-row {
  711. display: flex;
  712. gap: 10px;
  713. flex-wrap: wrap;
  714. margin-top: 14px;
  715. }
  716. .ingest-grid {
  717. display: grid;
  718. grid-template-columns: repeat(2, minmax(0, 1fr));
  719. gap: 10px;
  720. }
  721. .ingest-grid .ctrl-row {
  722. align-items: flex-start;
  723. padding: 0;
  724. border-bottom: none;
  725. }
  726. .ingest-grid .ctrl-label-wrap {
  727. min-width: 0;
  728. gap: 4px;
  729. }
  730. .ingest-group {
  731. margin-top: 12px;
  732. padding: 10px;
  733. border: 1px solid var(--border);
  734. border-radius: 6px;
  735. background: var(--surface2);
  736. }
  737. .ingest-group-title {
  738. margin-bottom: 8px;
  739. font-size: 10px;
  740. color: var(--text-dim);
  741. text-transform: uppercase;
  742. letter-spacing: .08em;
  743. }
  744. .ingest-summary-card .sidebar-section {
  745. margin: 0;
  746. padding: 0;
  747. border: none;
  748. }
  749. .ingest-summary-kv .v {
  750. font-family: var(--mono);
  751. font-size: 11px;
  752. }
  753. .apply-btn {
  754. background: var(--accent);
  755. border-color: transparent;
  756. color: #fff;
  757. }
  758. .apply-btn.secondary {
  759. background: var(--surface2);
  760. color: var(--text-dim);
  761. border-color: var(--border);
  762. }
  763. .apply-btn.ok { background: var(--green); color: var(--bg); }
  764. .sidebar-card {
  765. padding: 14px;
  766. }
  767. .sidebar-section + .sidebar-section {
  768. margin-top: 14px;
  769. padding-top: 14px;
  770. border-top: 1px solid var(--border);
  771. }
  772. .sidebar-title {
  773. font-size: 11px;
  774. text-transform: uppercase;
  775. letter-spacing: .08em;
  776. color: var(--text-dim);
  777. margin-bottom: 10px;
  778. }
  779. .kv {
  780. display: grid;
  781. grid-template-columns: auto 1fr;
  782. gap: 8px 12px;
  783. align-items: start;
  784. }
  785. .kv .k {
  786. font-size: 10px;
  787. color: var(--text-muted);
  788. text-transform: uppercase;
  789. letter-spacing: .08em;
  790. }
  791. .kv .v {
  792. font-size: 12px;
  793. color: var(--text);
  794. word-break: break-word;
  795. }
  796. .health-line {
  797. display: flex;
  798. align-items: center;
  799. justify-content: space-between;
  800. gap: 10px;
  801. padding: 8px 0;
  802. border-bottom: 1px solid #1a1a22;
  803. }
  804. .health-line:last-child { border-bottom: none; }
  805. .health-line .name {
  806. font-size: 11px;
  807. color: var(--text-dim);
  808. }
  809. .health-line .val {
  810. font-size: 11px;
  811. color: var(--text);
  812. text-align: right;
  813. }
  814. .health-line .val.good { color: var(--green); }
  815. .health-line .val.warn { color: var(--amber); }
  816. .health-line .val.err { color: var(--accent); }
  817. .health-trend {
  818. margin-top: 10px;
  819. }
  820. .health-trend-label {
  821. font-size: 10px;
  822. text-transform: uppercase;
  823. letter-spacing: .08em;
  824. color: var(--text-muted);
  825. margin-bottom: 6px;
  826. }
  827. .fault-history {
  828. margin-top: 12px;
  829. padding: 10px;
  830. border: 1px solid var(--border);
  831. border-radius: 6px;
  832. background: #fbfcfd;
  833. font-size: 11px;
  834. max-height: 180px;
  835. overflow-y: auto;
  836. line-height: 1.3;
  837. }
  838. .fault-history-entry {
  839. display: flex;
  840. justify-content: space-between;
  841. gap: 10px;
  842. padding: 4px 0;
  843. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  844. }
  845. .fault-history-entry:last-child {
  846. border-bottom: none;
  847. }
  848. .fault-history-entry .fault-history-time {
  849. color: var(--text-dim);
  850. }
  851. .fault-history-entry.ok { color: var(--green); }
  852. .fault-history-entry.warn { color: var(--amber); }
  853. .fault-history-entry.err { color: var(--accent); }
  854. .fault-history-desc {
  855. font-size: 10px;
  856. flex: 1;
  857. text-transform: uppercase;
  858. letter-spacing: 0.5px;
  859. }
  860. .fault-history-empty {
  861. padding: 6px 0;
  862. color: var(--text-muted);
  863. font-size: 11px;
  864. }
  865. .transition-history {
  866. margin-top: 12px;
  867. padding: 10px;
  868. border: 1px solid var(--border);
  869. border-radius: 6px;
  870. background: var(--surface);
  871. font-size: 11px;
  872. max-height: 180px;
  873. overflow-y: auto;
  874. line-height: 1.3;
  875. }
  876. .transition-history-entry {
  877. display: flex;
  878. justify-content: space-between;
  879. gap: 10px;
  880. padding: 4px 0;
  881. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  882. }
  883. .transition-history-entry:last-child {
  884. border-bottom: none;
  885. }
  886. .transition-history-entry .transition-history-time {
  887. color: var(--text-dim);
  888. }
  889. .transition-history-entry.good { color: var(--green); }
  890. .transition-history-entry.warn { color: var(--amber); }
  891. .transition-history-entry.err { color: var(--accent); }
  892. .transition-history-entry.info { color: var(--text); }
  893. .transition-history-desc {
  894. font-size: 10px;
  895. flex: 1;
  896. text-transform: uppercase;
  897. letter-spacing: 0.5px;
  898. }
  899. .transition-history-empty {
  900. padding: 6px 0;
  901. color: var(--text-muted);
  902. font-size: 11px;
  903. }
  904. .section-note.reset-hint {
  905. font-size: 11px;
  906. color: var(--text-dim);
  907. margin-top: 10px;
  908. }
  909. .log {
  910. background: #fbfcfd;
  911. border: 1px solid var(--border);
  912. border-radius: 6px;
  913. padding: 10px;
  914. font-size: 11px;
  915. color: var(--text-dim);
  916. max-height: 220px;
  917. overflow-y: auto;
  918. white-space: pre-wrap;
  919. word-break: break-word;
  920. }
  921. .log .entry { padding: 3px 0; }
  922. .log .entry.err { color: var(--accent); }
  923. .log .entry.ok { color: var(--green); }
  924. .log .entry.warn { color: var(--amber); }
  925. .log .entry.info { color: var(--blue); }
  926. .empty-log {
  927. color: var(--text-muted);
  928. }
  929. .toast {
  930. position: fixed;
  931. right: 16px;
  932. bottom: 16px;
  933. max-width: min(420px, calc(100vw - 24px));
  934. padding: 12px 15px;
  935. border-radius: var(--radius);
  936. font-size: 12px;
  937. font-weight: 700;
  938. z-index: 2000;
  939. transform: translateY(60px);
  940. opacity: 0;
  941. transition: all .25s ease;
  942. box-shadow: var(--shadow);
  943. }
  944. .toast.show { transform: translateY(0); opacity: 1; }
  945. .toast.ok { background: var(--green); color: var(--bg); }
  946. .toast.err { background: var(--accent); color: #fff; }
  947. .toast.info { background: var(--blue); color: #fff; }
  948. .toast.warn { background: var(--amber); color: #141414; }
  949. @media (max-width: 980px) {
  950. .tab-columns.two {
  951. grid-template-columns: 1fr;
  952. }
  953. .tab-bar {
  954. margin: 0 -12px 18px;
  955. }
  956. .tx-bar {
  957. grid-template-columns: 1fr;
  958. align-items: stretch;
  959. }
  960. .tx-state-wrap {
  961. align-items: flex-start;
  962. }
  963. .status-hint { text-align: left; }
  964. .quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  965. .signal-grid { grid-template-columns: 1fr; }
  966. }
  967. @media (max-width: 640px) {
  968. .tab-columns.two {
  969. grid-template-columns: 1fr;
  970. }
  971. .tab-bar {
  972. margin: 0 -12px 14px;
  973. }
  974. .app { padding: 12px; }
  975. .header { flex-direction: column; align-items: stretch; gap: 10px; }
  976. .header h1 { font-size: 22px; }
  977. .header-sub { gap: 6px; }
  978. .badge { width: 100%; justify-content: space-between; }
  979. .quick-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
  980. .quick-item { padding: 10px; }
  981. .quick-item .value { font-size: 16px; }
  982. .ctrl-row { flex-direction: column; align-items: stretch; }
  983. .ctrl-label-wrap { min-width: auto; }
  984. .ctrl-input { flex-wrap: wrap; }
  985. .ingest-grid { grid-template-columns: 1fr; }
  986. input[type="number"] { width: 100%; text-align: left; }
  987. .actions-row, .tx-actions { flex-direction: column; }
  988. .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; }
  989. .panel-head { padding: 11px 12px; }
  990. .panel-body, .sidebar-card { padding: 12px; }
  991. .freq-display { font-size: 31px; }
  992. .preset-row { flex-direction: column; }
  993. .shortcuts-grid { grid-template-columns: 1fr; }
  994. }
  995. </style>
  996. </head>
  997. <body>
  998. <div class="app">
  999. <div class="header">
  1000. <div class="header-main">
  1001. <h1>FM-RDS-TX Control Plane</h1>
  1002. <div class="header-note">Overview first, controls second, diagnostics when needed.</div>
  1003. <div class="header-sub">
  1004. <div class="badge"><span>Backend</span><strong id="badge-backend">--</strong></div>
  1005. <div class="badge"><span>Mode</span><strong id="badge-mode">Control Plane</strong></div>
  1006. <div class="badge"><span>Live Config</span><strong id="badge-live">--</strong></div>
  1007. </div>
  1008. </div>
  1009. <div class="header-status">
  1010. <div class="led" id="led-conn"></div>
  1011. <div class="status-text" id="conn-label">connecting</div>
  1012. </div>
  1013. </div>
  1014. <div class="tab-bar">
  1015. <button class="tab-btn active" data-tab="overview" type="button">Overview</button>
  1016. <button class="tab-btn" data-tab="control" type="button">Transmission Control</button>
  1017. <button class="tab-btn" data-tab="ingest" type="button">Ingest</button>
  1018. <button class="tab-btn" data-tab="diagnostics" type="button">Diagnostics &amp; Health</button>
  1019. <button class="tab-btn" data-tab="activity" type="button">Activity &amp; Logs</button>
  1020. </div>
  1021. <div class="tab-panels">
  1022. <section class="tab-panel active" data-tab-panel="overview">
  1023. <div class="tab-columns two">
  1024. <div class="stack">
  1025. <div class="card hero" id="hero-card">
  1026. <div class="tx-bar">
  1027. <div class="freq-display-wrap">
  1028. <div class="freq-display-label">Carrier</div>
  1029. <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></div>
  1030. <div class="freq-note" id="freq-note">
  1031. <span class="freq-note-item" id="freq-applied">Applied: --</span>
  1032. <span class="freq-note-item" id="freq-desired">Desired: --</span>
  1033. </div>
  1034. </div>
  1035. <div class="tx-actions">
  1036. <button class="tx-btn start" id="btn-start" type="button">TX ON</button>
  1037. <button class="tx-btn stop" id="btn-stop" type="button">TX OFF</button>
  1038. <button class="ghost-btn" id="btn-refresh" type="button">Refresh</button>
  1039. </div>
  1040. <div class="tx-state-wrap">
  1041. <div class="tx-state idle" id="tx-state">IDLE</div>
  1042. <div class="status-hint" id="tx-hint">Awaiting runtime data</div>
  1043. </div>
  1044. </div>
  1045. <div class="quick-grid">
  1046. <div class="quick-item">
  1047. <div class="label">Chunks</div>
  1048. <div class="value" id="t-chunks">--</div>
  1049. </div>
  1050. <div class="quick-item">
  1051. <div class="label">Samples</div>
  1052. <div class="value" id="t-samples">--</div>
  1053. </div>
  1054. <div class="quick-item">
  1055. <div class="label">Underruns</div>
  1056. <div class="value" id="t-underruns">--</div>
  1057. </div>
  1058. <div class="quick-item">
  1059. <div class="label">Uptime</div>
  1060. <div class="value" id="t-uptime">--</div>
  1061. </div>
  1062. <div class="quick-item">
  1063. <div class="label">Rate</div>
  1064. <div class="value" id="t-rate">--</div>
  1065. </div>
  1066. </div>
  1067. <div class="signal-grid">
  1068. <div class="signal-card">
  1069. <div class="signal-head">
  1070. <div class="signal-title">Audio Buffer</div>
  1071. <div class="signal-value" id="meter-audio-text">--</div>
  1072. </div>
  1073. <div class="meter"><div class="meter-fill" id="meter-audio-fill"></div></div>
  1074. <svg class="spark good" id="spark-audio" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1075. </div>
  1076. <div class="signal-card">
  1077. <div class="signal-head">
  1078. <div class="signal-title">Stream Health</div>
  1079. <div class="signal-value" id="meter-stream-text">--</div>
  1080. </div>
  1081. <div class="meter"><div class="meter-fill" id="meter-stream-fill"></div></div>
  1082. <svg class="spark warn" id="spark-underruns" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1083. </div>
  1084. <div class="signal-card">
  1085. <div class="signal-head">
  1086. <div class="signal-title">TX Activity</div>
  1087. <div class="signal-value" id="meter-tx-text">--</div>
  1088. </div>
  1089. <div class="meter"><div class="meter-fill" id="meter-tx-fill"></div></div>
  1090. <svg class="spark good" id="spark-tx" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1091. </div>
  1092. </div>
  1093. </div>
  1094. </div>
  1095. <div class="stack">
  1096. <div class="card sidebar-card">
  1097. <div class="sidebar-section">
  1098. <div class="sidebar-title">Runtime Snapshot</div>
  1099. <div class="kv">
  1100. <div class="k">Backend</div><div class="v" id="info-backend">--</div>
  1101. <div class="k">Frequency</div><div class="v" id="info-freq">--</div>
  1102. <div class="k">Runtime Age</div><div class="v" id="info-runtime-age">--</div>
  1103. <div class="k">Last Alert</div><div class="v" id="info-last-alert">--</div>
  1104. <div class="k">Live Config</div><div class="v" id="info-live">--</div>
  1105. </div>
  1106. </div>
  1107. <div class="sidebar-section">
  1108. <div class="sidebar-title">Signal Notes</div>
  1109. <div class="section-note">Overview stays compact: primary state here, deep diagnostics in the dedicated tab.</div>
  1110. <div class="kv">
  1111. <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div>
  1112. <div class="k">FM Mod</div><div class="v" id="info-fmmod">--</div>
  1113. </div>
  1114. </div>
  1115. </div>
  1116. </div>
  1117. </div>
  1118. </section>
  1119. <section class="tab-panel" data-tab-panel="control">
  1120. <div class="tab-columns two">
  1121. <div class="stack">
  1122. <div class="card panel" data-panel-key="frequency">
  1123. <div class="panel-head" data-panel>
  1124. <div class="led on-green" style="width:6px;height:6px"></div>
  1125. <h2>Frequency</h2>
  1126. <div class="meta" id="freq-meta">Live-tunable</div>
  1127. <span class="chevron">▼</span>
  1128. </div>
  1129. <div class="panel-body">
  1130. <div class="section-note">Tune the RF carrier without restarting the control plane. Draft values stay local until you apply them.</div>
  1131. <div class="preset-row" id="freq-presets">
  1132. <button class="preset-btn" type="button" data-freq-preset="87.6">87.6 MHz</button>
  1133. <button class="preset-btn" type="button" data-freq-preset="94.5">94.5 MHz</button>
  1134. <button class="preset-btn" type="button" data-freq-preset="99.5">99.5 MHz</button>
  1135. <button class="preset-btn" type="button" data-freq-preset="100.0">100.0 MHz</button>
  1136. <button class="preset-btn" type="button" data-freq-preset="107.9">107.9 MHz</button>
  1137. </div>
  1138. <div class="ctrl-row">
  1139. <div class="ctrl-label-wrap">
  1140. <span class="ctrl-label">TX Freq</span>
  1141. <span class="ctrl-sub">Valid range 65–110 MHz</span>
  1142. </div>
  1143. <div class="ctrl-input">
  1144. <input type="range" min="65" max="110" step="0.1" id="freq-slider">
  1145. <input type="number" min="65" max="110" step="0.1" id="freq-num">
  1146. <span class="unit-label">MHz</span>
  1147. </div>
  1148. </div>
  1149. <div class="field-error" id="freq-error"></div>
  1150. <div class="actions-row">
  1151. <button class="apply-btn" id="freq-apply" type="button">Apply Frequency</button>
  1152. <button class="apply-btn secondary" id="freq-reset" type="button">Reset</button>
  1153. </div>
  1154. </div>
  1155. </div>
  1156. <div class="card panel" data-panel-key="switches">
  1157. <div class="panel-head" data-panel>
  1158. <div class="led on-green" style="width:6px;height:6px"></div>
  1159. <h2>Switches</h2>
  1160. <div class="meta">Live</div>
  1161. <span class="chevron">▼</span>
  1162. </div>
  1163. <div class="panel-body">
  1164. <div class="section-note">These switches apply immediately and show a busy state while the request is in flight.</div>
  1165. <div class="toggle-row">
  1166. <div class="toggle-copy">
  1167. <div class="title">Stereo</div>
  1168. <div class="sub">19 kHz pilot + 38 kHz DSB-SC</div>
  1169. </div>
  1170. <div class="toggle-ctl">
  1171. <div class="toggle" id="tog-stereo" data-toggle="stereoEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  1172. <div class="toggle-state" id="stereo-label">--</div>
  1173. </div>
  1174. </div>
  1175. <div class="toggle-row">
  1176. <div class="toggle-copy">
  1177. <div class="title">RDS</div>
  1178. <div class="sub">57 kHz subcarrier encoder</div>
  1179. </div>
  1180. <div class="toggle-ctl">
  1181. <div class="toggle" id="tog-rds" data-toggle="rdsEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  1182. <div class="toggle-state" id="rds-label">--</div>
  1183. </div>
  1184. </div>
  1185. <div class="toggle-row">
  1186. <div class="toggle-copy">
  1187. <div class="title">Limiter</div>
  1188. <div class="sub">MPX peak protection</div>
  1189. </div>
  1190. <div class="toggle-ctl">
  1191. <div class="toggle" id="tog-limiter" data-toggle="limiterEnabled" role="switch" aria-checked="false" tabindex="0"></div>
  1192. <div class="toggle-state" id="limiter-label">--</div>
  1193. </div>
  1194. </div>
  1195. </div>
  1196. </div>
  1197. <div class="card panel" data-panel-key="rds">
  1198. <div class="panel-head" data-panel>
  1199. <div class="led on-amber" style="width:6px;height:6px"></div>
  1200. <h2>RDS Text</h2>
  1201. <div class="meta" id="rds-meta">PS + RT</div>
  1202. <span class="chevron">▼</span>
  1203. </div>
  1204. <div class="panel-body">
  1205. <div class="section-note">Edit Program Service and RadioText without losing in-progress typing when the page refreshes itself.</div>
  1206. <div class="preset-row" id="rds-presets">
  1207. <button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
  1208. <button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
  1209. <button class="preset-btn rds" type="button" data-rds-ps="LIVE" data-rds-rt="Live set in progress">Live Set</button>
  1210. <button class="preset-btn rds" type="button" data-rds-ps="TEST" data-rds-rt="RDS test transmission">Test</button>
  1211. </div>
  1212. <div class="rds-grid">
  1213. <div class="rds-field">
  1214. <span class="ctrl-label">Program Service (PS)</span>
  1215. <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION" spellcheck="false">
  1216. <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
  1217. <div class="field-error" id="ps-error"></div>
  1218. </div>
  1219. <div class="rds-field">
  1220. <span class="ctrl-label">RadioText (RT)</span>
  1221. <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..." spellcheck="false">
  1222. <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
  1223. <div class="field-error" id="rt-error"></div>
  1224. </div>
  1225. </div>
  1226. <div class="actions-row">
  1227. <button class="apply-btn" id="rds-apply" type="button">Apply RDS Text</button>
  1228. <button class="apply-btn secondary" id="rds-reset" type="button">Reset</button>
  1229. </div>
  1230. </div>
  1231. </div>
  1232. </div>
  1233. <div class="stack">
  1234. <div class="card panel" data-panel-key="shortcuts">
  1235. <div class="panel-head" data-panel>
  1236. <h2>Shortcuts</h2>
  1237. <div class="meta">keyboard</div>
  1238. <span class="chevron">▼</span>
  1239. </div>
  1240. <div class="panel-body">
  1241. <div class="section-note">Fast control reference. Shortcuts stay out of the main operator path.</div>
  1242. <div class="shortcuts-grid">
  1243. <div>
  1244. <div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div>
  1245. <div class="shortcut-line"><span class="name">Stop TX</span><span class="keys"><span class="kbd">Shift</span><span class="kbd">t</span></span></div>
  1246. <div class="shortcut-line"><span class="name">Refresh</span><span class="keys"><span class="kbd">r</span></span></div>
  1247. </div>
  1248. <div>
  1249. <div class="shortcut-line"><span class="name">Next Freq Preset</span><span class="keys"><span class="kbd">]</span></span></div>
  1250. <div class="shortcut-line"><span class="name">Prev Freq Preset</span><span class="keys"><span class="kbd">[</span></span></div>
  1251. <div class="shortcut-line"><span class="name">Apply Draft</span><span class="keys"><span class="kbd">Enter</span></span></div>
  1252. </div>
  1253. </div>
  1254. </div>
  1255. </div>
  1256. <div class="card panel" data-panel-key="danger">
  1257. <div class="panel-head" data-panel>
  1258. <h2>Danger Zone</h2>
  1259. <div class="meta">tx control</div>
  1260. <span class="chevron">▼</span>
  1261. </div>
  1262. <div class="panel-body">
  1263. <div class="section-note">Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.</div>
  1264. <div class="actions-row" style="margin-top:0">
  1265. <button class="danger-btn" id="danger-stop" type="button">Emergency Stop TX</button>
  1266. <button class="danger-btn" id="danger-refresh" type="button">Hard Refresh Runtime</button>
  1267. <button class="danger-btn secondary" id="danger-reset-fault" type="button">Reset Fault</button>
  1268. </div>
  1269. <div class="section-note reset-hint" id="reset-hint">
  1270. Reset Fault moves the runtime back to DEGRADED while the queue settles before running again.
  1271. </div>
  1272. </div>
  1273. </div>
  1274. </div>
  1275. </div>
  1276. </section>
  1277. <section class="tab-panel" data-tab-panel="ingest">
  1278. <div class="tab-columns one">
  1279. <div class="stack">
  1280. <div class="card sidebar-card ingest-summary-card">
  1281. <div class="sidebar-section">
  1282. <div class="sidebar-title">Active Ingest Summary</div>
  1283. <div class="section-note">Runtime snapshot of active ingest state and source. Deep runtime metrics stay in Diagnostics.</div>
  1284. <div class="kv ingest-summary-kv">
  1285. <div class="k">State</div><div class="v" id="ingest-summary-state">--</div>
  1286. <div class="k">Source</div><div class="v" id="ingest-summary-source">--</div>
  1287. <div class="k">Signal</div><div class="v" id="ingest-summary-signal">--</div>
  1288. <div class="k">Detail</div><div class="v" id="ingest-summary-detail">--</div>
  1289. <div class="k">Origin</div><div class="v" id="ingest-summary-origin">--</div>
  1290. <div class="k">Last Chunk</div><div class="v" id="ingest-summary-last">--</div>
  1291. </div>
  1292. </div>
  1293. </div>
  1294. <div class="card panel" data-panel-key="ingest">
  1295. <div class="panel-head" data-panel>
  1296. <div class="led on-blue" style="width:6px;height:6px"></div>
  1297. <h2>Ingest Config</h2>
  1298. <div class="meta" id="ingest-meta">Saved config</div>
  1299. <span class="chevron">▼</span>
  1300. </div>
  1301. <div class="panel-body">
  1302. <div class="section-note">Edit ingest source settings, save to config file, then force a hard reload so the runtime restarts with the new ingest path.</div>
  1303. <div class="ingest-grid">
  1304. <div class="ctrl-row">
  1305. <div class="ctrl-label-wrap">
  1306. <span class="ctrl-label">Ingest Kind</span>
  1307. </div>
  1308. <div class="ctrl-input">
  1309. <select id="ing-kind" data-ingest-path="kind">
  1310. <option value="none">none</option>
  1311. <option value="icecast">icecast</option>
  1312. <option value="srt">srt</option>
  1313. <option value="aes67">aes67</option>
  1314. <option value="stdin">stdin</option>
  1315. <option value="http-raw">http-raw</option>
  1316. </select>
  1317. </div>
  1318. </div>
  1319. <div class="ctrl-row">
  1320. <div class="ctrl-label-wrap">
  1321. <span class="ctrl-label">Prebuffer</span>
  1322. <span class="ctrl-sub">ms</span>
  1323. </div>
  1324. <div class="ctrl-input">
  1325. <input type="number" id="ing-prebuffer" data-ingest-path="prebufferMs">
  1326. </div>
  1327. </div>
  1328. <div class="ctrl-row">
  1329. <div class="ctrl-label-wrap">
  1330. <span class="ctrl-label">Stall Timeout</span>
  1331. <span class="ctrl-sub">ms</span>
  1332. </div>
  1333. <div class="ctrl-input">
  1334. <input type="number" id="ing-stall-timeout" data-ingest-path="stallTimeoutMs">
  1335. </div>
  1336. </div>
  1337. <div class="ctrl-row">
  1338. <div class="ctrl-label-wrap">
  1339. <span class="ctrl-label">Reconnect</span>
  1340. </div>
  1341. <div class="ctrl-input">
  1342. <label><input type="checkbox" id="ing-reconnect-enabled" data-ingest-path="reconnect.enabled"> Enabled</label>
  1343. </div>
  1344. </div>
  1345. <div class="ctrl-row">
  1346. <div class="ctrl-label-wrap">
  1347. <span class="ctrl-label">Backoff Initial</span>
  1348. <span class="ctrl-sub">ms</span>
  1349. </div>
  1350. <div class="ctrl-input">
  1351. <input type="number" id="ing-reconnect-initial" data-ingest-path="reconnect.initialBackoffMs">
  1352. </div>
  1353. </div>
  1354. <div class="ctrl-row">
  1355. <div class="ctrl-label-wrap">
  1356. <span class="ctrl-label">Backoff Max</span>
  1357. <span class="ctrl-sub">ms</span>
  1358. </div>
  1359. <div class="ctrl-input">
  1360. <input type="number" id="ing-reconnect-max" data-ingest-path="reconnect.maxBackoffMs">
  1361. </div>
  1362. </div>
  1363. </div>
  1364. <div class="ingest-group" id="ing-group-icecast">
  1365. <div class="ingest-group-title">Icecast</div>
  1366. <div class="ingest-grid">
  1367. <div class="ctrl-row">
  1368. <div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div>
  1369. <div class="ctrl-input"><input type="text" id="ing-icecast-url" data-ingest-path="icecast.url" spellcheck="false"></div>
  1370. </div>
  1371. <div class="ctrl-row">
  1372. <div class="ctrl-label-wrap"><span class="ctrl-label">Decoder</span></div>
  1373. <div class="ctrl-input">
  1374. <select id="ing-icecast-decoder" data-ingest-path="icecast.decoder">
  1375. <option value="auto">auto</option>
  1376. <option value="native">native</option>
  1377. <option value="ffmpeg">ffmpeg</option>
  1378. <option value="fallback">fallback</option>
  1379. </select>
  1380. </div>
  1381. </div>
  1382. <div class="ctrl-row">
  1383. <div class="ctrl-label-wrap"><span class="ctrl-label">RadioText Relay</span></div>
  1384. <div class="ctrl-input">
  1385. <label><input type="checkbox" id="ing-icecast-rt-enabled" data-ingest-path="icecast.radioText.enabled"> Enabled</label>
  1386. </div>
  1387. </div>
  1388. <div class="ctrl-row">
  1389. <div class="ctrl-label-wrap"><span class="ctrl-label">RT Prefix</span></div>
  1390. <div class="ctrl-input"><input type="text" id="ing-icecast-rt-prefix" data-ingest-path="icecast.radioText.prefix"></div>
  1391. </div>
  1392. <div class="ctrl-row">
  1393. <div class="ctrl-label-wrap"><span class="ctrl-label">RT MaxLen</span></div>
  1394. <div class="ctrl-input"><input type="number" id="ing-icecast-rt-maxlen" data-ingest-path="icecast.radioText.maxLen"></div>
  1395. </div>
  1396. <div class="ctrl-row">
  1397. <div class="ctrl-label-wrap"><span class="ctrl-label">RT Only On Change</span></div>
  1398. <div class="ctrl-input">
  1399. <label><input type="checkbox" id="ing-icecast-rt-only-change" data-ingest-path="icecast.radioText.onlyOnChange"> Enabled</label>
  1400. </div>
  1401. </div>
  1402. </div>
  1403. </div>
  1404. <div class="ingest-group" id="ing-group-srt">
  1405. <div class="ingest-group-title">SRT</div>
  1406. <div class="ingest-grid">
  1407. <div class="ctrl-row">
  1408. <div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div>
  1409. <div class="ctrl-input"><input type="text" id="ing-srt-url" data-ingest-path="srt.url" spellcheck="false"></div>
  1410. </div>
  1411. <div class="ctrl-row">
  1412. <div class="ctrl-label-wrap"><span class="ctrl-label">Mode</span></div>
  1413. <div class="ctrl-input">
  1414. <select id="ing-srt-mode" data-ingest-path="srt.mode">
  1415. <option value="listener">listener</option>
  1416. <option value="caller">caller</option>
  1417. <option value="rendezvous">rendezvous</option>
  1418. </select>
  1419. </div>
  1420. </div>
  1421. <div class="ctrl-row">
  1422. <div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div>
  1423. <div class="ctrl-input"><input type="number" id="ing-srt-rate" data-ingest-path="srt.sampleRateHz"></div>
  1424. </div>
  1425. <div class="ctrl-row">
  1426. <div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div>
  1427. <div class="ctrl-input"><input type="number" id="ing-srt-channels" data-ingest-path="srt.channels"></div>
  1428. </div>
  1429. </div>
  1430. </div>
  1431. <div class="ingest-group" id="ing-group-aes67">
  1432. <div class="ingest-group-title">AES67</div>
  1433. <div class="ingest-grid">
  1434. <div class="ctrl-row">
  1435. <div class="ctrl-label-wrap"><span class="ctrl-label">SDP Path</span></div>
  1436. <div class="ctrl-input"><input type="text" id="ing-aes67-sdppath" data-ingest-path="aes67.sdpPath" spellcheck="false"></div>
  1437. </div>
  1438. <div class="ctrl-row">
  1439. <div class="ctrl-label-wrap"><span class="ctrl-label">SDP Inline</span></div>
  1440. <div class="ctrl-input"><input type="text" id="ing-aes67-sdp" data-ingest-path="aes67.sdp" spellcheck="false"></div>
  1441. </div>
  1442. <div class="ctrl-row">
  1443. <div class="ctrl-label-wrap"><span class="ctrl-label">Multicast Group</span></div>
  1444. <div class="ctrl-input"><input type="text" id="ing-aes67-group" data-ingest-path="aes67.multicastGroup" spellcheck="false"></div>
  1445. </div>
  1446. <div class="ctrl-row">
  1447. <div class="ctrl-label-wrap"><span class="ctrl-label">Port</span></div>
  1448. <div class="ctrl-input"><input type="number" id="ing-aes67-port" data-ingest-path="aes67.port"></div>
  1449. </div>
  1450. <div class="ctrl-row">
  1451. <div class="ctrl-label-wrap"><span class="ctrl-label">Payload Type</span></div>
  1452. <div class="ctrl-input"><input type="number" id="ing-aes67-pt" data-ingest-path="aes67.payloadType"></div>
  1453. </div>
  1454. <div class="ctrl-row">
  1455. <div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div>
  1456. <div class="ctrl-input"><input type="number" id="ing-aes67-rate" data-ingest-path="aes67.sampleRateHz"></div>
  1457. </div>
  1458. <div class="ctrl-row">
  1459. <div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div>
  1460. <div class="ctrl-input"><input type="number" id="ing-aes67-channels" data-ingest-path="aes67.channels"></div>
  1461. </div>
  1462. <div class="ctrl-row">
  1463. <div class="ctrl-label-wrap"><span class="ctrl-label">Encoding</span></div>
  1464. <div class="ctrl-input"><input type="text" id="ing-aes67-encoding" data-ingest-path="aes67.encoding"></div>
  1465. </div>
  1466. <div class="ctrl-row">
  1467. <div class="ctrl-label-wrap"><span class="ctrl-label">Packet Time</span></div>
  1468. <div class="ctrl-input"><input type="number" id="ing-aes67-ptime" data-ingest-path="aes67.packetTimeMs"></div>
  1469. </div>
  1470. <div class="ctrl-row">
  1471. <div class="ctrl-label-wrap"><span class="ctrl-label">Jitter Depth</span></div>
  1472. <div class="ctrl-input"><input type="number" id="ing-aes67-jitter" data-ingest-path="aes67.jitterDepthPackets"></div>
  1473. </div>
  1474. <div class="ctrl-row">
  1475. <div class="ctrl-label-wrap"><span class="ctrl-label">Read Buffer</span></div>
  1476. <div class="ctrl-input"><input type="number" id="ing-aes67-readbuf" data-ingest-path="aes67.readBufferBytes"></div>
  1477. </div>
  1478. <div class="ctrl-row">
  1479. <div class="ctrl-label-wrap"><span class="ctrl-label">Discovery</span></div>
  1480. <div class="ctrl-input"><label><input type="checkbox" id="ing-aes67-discovery-enabled" data-ingest-path="aes67.discovery.enabled"> Enabled</label></div>
  1481. </div>
  1482. <div class="ctrl-row">
  1483. <div class="ctrl-label-wrap"><span class="ctrl-label">Discovery Name</span></div>
  1484. <div class="ctrl-input"><input type="text" id="ing-aes67-discovery-name" data-ingest-path="aes67.discovery.streamName"></div>
  1485. </div>
  1486. <div class="ctrl-row">
  1487. <div class="ctrl-label-wrap"><span class="ctrl-label">Discovery Timeout</span></div>
  1488. <div class="ctrl-input"><input type="number" id="ing-aes67-discovery-timeout" data-ingest-path="aes67.discovery.timeoutMs"></div>
  1489. </div>
  1490. <div class="ctrl-row">
  1491. <div class="ctrl-label-wrap"><span class="ctrl-label">SAP Group</span></div>
  1492. <div class="ctrl-input"><input type="text" id="ing-aes67-discovery-group" data-ingest-path="aes67.discovery.sapGroup"></div>
  1493. </div>
  1494. <div class="ctrl-row">
  1495. <div class="ctrl-label-wrap"><span class="ctrl-label">SAP Port</span></div>
  1496. <div class="ctrl-input"><input type="number" id="ing-aes67-discovery-port" data-ingest-path="aes67.discovery.sapPort"></div>
  1497. </div>
  1498. </div>
  1499. </div>
  1500. <div class="field-error" id="ingest-error"></div>
  1501. <div class="actions-row">
  1502. <button class="apply-btn" id="ingest-save-reload" type="button">Save + Hard Reload</button>
  1503. <button class="apply-btn secondary" id="ingest-reset" type="button">Reset Draft</button>
  1504. </div>
  1505. </div>
  1506. </div>
  1507. </div>
  1508. </div>
  1509. </section>
  1510. <section class="tab-panel" data-tab-panel="diagnostics">
  1511. <div class="tab-columns two">
  1512. <div class="stack">
  1513. <div class="card sidebar-card">
  1514. <div class="sidebar-section">
  1515. <div class="sidebar-title">Health</div>
  1516. <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
  1517. <div class="health-line"><div class="name">Runtime</div><div class="val" id="health-runtime">--</div></div>
  1518. <div class="health-line"><div class="name">State Age</div><div class="val" id="health-state-age">--</div></div>
  1519. <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
  1520. <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
  1521. <div class="health-line"><div class="name">Transitions (D/M/F)</div><div class="val" id="health-transitions">--</div></div>
  1522. <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
  1523. <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
  1524. <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
  1525. <div class="health-line"><div class="name">Buffer Duration</div><div class="val" id="health-buffer-duration">--</div></div>
  1526. <div class="health-line"><div class="name">High Watermark</div><div class="val" id="health-buffer-highwater">--</div></div>
  1527. <div class="health-line"><div class="name">Queue Fill</div><div class="val" id="health-queue-fill">--</div></div>
  1528. <div class="health-line"><div class="name">Underrun Streak</div><div class="val" id="health-underrun-streak">--</div></div>
  1529. <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
  1530. <div class="health-trend">
  1531. <div class="health-trend-label">High Watermark Trend</div>
  1532. <svg class="spark warn" id="spark-high-watermark" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1533. </div>
  1534. <div class="health-trend">
  1535. <div class="health-trend-label">Queue Fill Trend</div>
  1536. <svg class="spark good" id="spark-queue-fill" viewBox="0 0 160 34" preserveAspectRatio="none"></svg>
  1537. </div>
  1538. </div>
  1539. </div>
  1540. <div class="card sidebar-card">
  1541. <div class="sidebar-section">
  1542. <div class="sidebar-title">Control Audit</div>
  1543. <div class="section-note">Counts of 4xx rejects recorded by the control plane APIs.</div>
  1544. <div class="kv">
  1545. <div class="k">Rejects total</div><div class="v" id="audit-total">--</div>
  1546. <div class="k">405 Method Not Allowed</div><div class="v" id="audit-methodNotAllowed">--</div>
  1547. <div class="k">415 Unsupported Media Type</div><div class="v" id="audit-unsupportedMediaType">--</div>
  1548. <div class="k">413 Request Too Large</div><div class="v" id="audit-bodyTooLarge">--</div>
  1549. <div class="k">400 Unexpected Body</div><div class="v" id="audit-unexpectedBody">--</div>
  1550. </div>
  1551. </div>
  1552. </div>
  1553. </div>
  1554. <div class="stack">
  1555. <div class="card panel" data-panel-key="transition-history">
  1556. <div class="panel-head" data-panel>
  1557. <h2>Transition History</h2>
  1558. <div class="meta">recent state shifts</div>
  1559. <span class="chevron"></span>
  1560. </div>
  1561. <div class="panel-body">
  1562. <div class="section-note">Keeps runtime escalations visible without scrolling the activity log.</div>
  1563. <div class="transition-history" id="transition-history">
  1564. <div class="transition-history-empty">No transitions yet.</div>
  1565. </div>
  1566. </div>
  1567. </div>
  1568. <div class="card panel" data-panel-key="fault-history">
  1569. <div class="panel-head" data-panel>
  1570. <h2>Fault History</h2>
  1571. <div class="meta">recent faults</div>
  1572. <span class="chevron">▼</span>
  1573. </div>
  1574. <div class="panel-body">
  1575. <div class="section-note">Recent fault events for quick ops situational awareness.</div>
  1576. <div class="fault-history" id="fault-history">
  1577. <div class="fault-history-empty">No faults yet.</div>
  1578. </div>
  1579. </div>
  1580. </div>
  1581. </div>
  1582. </div>
  1583. </section>
  1584. <section class="tab-panel" data-tab-panel="activity">
  1585. <div class="tab-columns one">
  1586. <div class="stack">
  1587. <div class="card panel" data-panel-key="log">
  1588. <div class="panel-head" data-panel>
  1589. <h2>Activity Log</h2>
  1590. <div class="meta" id="log-meta">recent events</div>
  1591. <span class="chevron">▼</span>
  1592. </div>
  1593. <div class="panel-body">
  1594. <div class="actions-row" style="margin-top:0;margin-bottom:12px">
  1595. <button class="ghost-btn" id="btn-clear-log" type="button">Clear Log</button>
  1596. </div>
  1597. <div class="log" id="log"><div class="empty-log">No events yet.</div></div>
  1598. </div>
  1599. </div>
  1600. </div>
  1601. </div>
  1602. </section>
  1603. </div>
  1604. </div>
  1605. <div class="toast" id="toast"></div>
  1606. <script>
  1607. const $ = (id) => document.getElementById(id);
  1608. const runtimePollMs = 1000;
  1609. const configPollMs = 8000;
  1610. const mobileMq = window.matchMedia('(max-width: 640px)');
  1611. const freqPresetValues = [87.6, 94.5, 99.5, 100.0, 107.9];
  1612. const sparkHistoryLimit = 40;
  1613. const transitionHistoryLimit = 6;
  1614. const state = {
  1615. server: {
  1616. config: null,
  1617. runtime: null,
  1618. lastConfigAt: 0,
  1619. lastRuntimeAt: 0,
  1620. configOk: false,
  1621. runtimeOk: false,
  1622. },
  1623. lastRuntimeState: '',
  1624. draft: {
  1625. frequencyMHz: undefined,
  1626. ps: undefined,
  1627. radioText: undefined,
  1628. },
  1629. errors: {
  1630. frequencyMHz: '',
  1631. ps: '',
  1632. radioText: '',
  1633. },
  1634. dirty: new Set(),
  1635. pendingRequests: 0,
  1636. txBusy: false,
  1637. faultResetBusy: false,
  1638. toggleBusy: {},
  1639. pollersStarted: false,
  1640. mobilePanelsApplied: false,
  1641. ingestDraft: null,
  1642. ingestDirty: false,
  1643. ingestSaving: false,
  1644. ingestError: '',
  1645. charts: {
  1646. audio: [],
  1647. underruns: [],
  1648. tx: [],
  1649. highWatermark: [],
  1650. queueFill: [],
  1651. },
  1652. runtimeTransitions: [],
  1653. freqPresetIndex: 0,
  1654. };
  1655. const fields = {
  1656. frequencyMHz: { section: 'freq', equal: (a,b) => nearlyEqual(a,b,0.0001) },
  1657. ps: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
  1658. radioText: { section: 'rds', equal: (a,b) => String(a ?? '') === String(b ?? '') },
  1659. };
  1660. function nearlyEqual(a, b, eps = 1e-9) {
  1661. if (a == null && b == null) return true;
  1662. if (a == null || b == null) return false;
  1663. return Math.abs(Number(a) - Number(b)) <= eps;
  1664. }
  1665. function nowTs() { return Date.now(); }
  1666. function deepClone(obj) {
  1667. return JSON.parse(JSON.stringify(obj ?? {}));
  1668. }
  1669. function getPathValue(obj, path) {
  1670. if (!obj) return undefined;
  1671. const parts = String(path || '').split('.');
  1672. let cur = obj;
  1673. for (const part of parts) {
  1674. if (!part) continue;
  1675. if (cur == null || typeof cur !== 'object') return undefined;
  1676. cur = cur[part];
  1677. }
  1678. return cur;
  1679. }
  1680. function setPathValue(obj, path, value) {
  1681. const parts = String(path || '').split('.');
  1682. let cur = obj;
  1683. for (let i = 0; i < parts.length; i += 1) {
  1684. const part = parts[i];
  1685. if (!part) continue;
  1686. if (i === parts.length - 1) {
  1687. cur[part] = value;
  1688. return;
  1689. }
  1690. if (!cur[part] || typeof cur[part] !== 'object') cur[part] = {};
  1691. cur = cur[part];
  1692. }
  1693. }
  1694. function ingestFromServer() {
  1695. return state.server.config?.ingest || {};
  1696. }
  1697. function updateIngestDirtyFromServer() {
  1698. const serverRaw = ingestFromServer();
  1699. const draftRaw = state.ingestDraft || {};
  1700. state.ingestDirty = JSON.stringify(draftRaw) !== JSON.stringify(serverRaw);
  1701. }
  1702. function syncIngestDraftFromConfig(force = false) {
  1703. if (!state.server.config) return;
  1704. if (!state.ingestDraft || force || !state.ingestDirty) {
  1705. state.ingestDraft = deepClone(ingestFromServer());
  1706. state.ingestError = '';
  1707. }
  1708. updateIngestDirtyFromServer();
  1709. }
  1710. function ingestFieldValue(path) {
  1711. return getPathValue(state.ingestDraft || {}, path);
  1712. }
  1713. function setIngestField(path, value) {
  1714. if (!state.ingestDraft) state.ingestDraft = deepClone(ingestFromServer());
  1715. setPathValue(state.ingestDraft, path, value);
  1716. updateIngestDirtyFromServer();
  1717. state.ingestError = '';
  1718. render();
  1719. }
  1720. function serverValue(key) {
  1721. const cfg = state.server.config;
  1722. if (!cfg) return undefined;
  1723. switch (key) {
  1724. case 'frequencyMHz': return cfg.fm?.frequencyMHz;
  1725. case 'ps': return cfg.rds?.ps ?? '';
  1726. case 'radioText': return cfg.rds?.radioText ?? '';
  1727. case 'stereoEnabled': return cfg.fm?.stereoEnabled;
  1728. case 'rdsEnabled': return cfg.rds?.enabled;
  1729. case 'limiterEnabled': return cfg.fm?.limiterEnabled;
  1730. default: return undefined;
  1731. }
  1732. }
  1733. function effectiveValue(key) {
  1734. return state.dirty.has(key) ? state.draft[key] : serverValue(key);
  1735. }
  1736. function validateField(key, value) {
  1737. switch (key) {
  1738. case 'frequencyMHz': {
  1739. if (value == null || Number.isNaN(Number(value))) return 'Enter a valid number.';
  1740. const num = Number(value);
  1741. if (num < 65 || num > 110) return 'Frequency must stay between 65 and 110 MHz.';
  1742. return '';
  1743. }
  1744. case 'ps': {
  1745. const text = String(value ?? '');
  1746. if (text.length > 8) return 'PS is limited to 8 characters.';
  1747. return '';
  1748. }
  1749. case 'radioText': {
  1750. const text = String(value ?? '');
  1751. if (text.length > 64) return 'RadioText is limited to 64 characters.';
  1752. return '';
  1753. }
  1754. default:
  1755. return '';
  1756. }
  1757. }
  1758. function sectionHasErrors(section) {
  1759. return Object.entries(fields).some(([key, meta]) => meta.section === section && state.errors[key]);
  1760. }
  1761. function setDirty(key, value) {
  1762. state.draft[key] = value;
  1763. state.errors[key] = validateField(key, value);
  1764. const current = serverValue(key);
  1765. const equal = !state.errors[key] && fields[key].equal(value, current);
  1766. if (equal) {
  1767. state.dirty.delete(key);
  1768. state.draft[key] = undefined;
  1769. } else {
  1770. state.dirty.add(key);
  1771. }
  1772. if (key === 'frequencyMHz' && typeof value === 'number') syncFreqPresetIndex(value);
  1773. render();
  1774. }
  1775. function clearDirty(keys) {
  1776. for (const key of keys) {
  1777. state.dirty.delete(key);
  1778. state.draft[key] = undefined;
  1779. state.errors[key] = '';
  1780. }
  1781. render();
  1782. }
  1783. function isDirtySection(section) {
  1784. for (const key of state.dirty) {
  1785. if (fields[key]?.section === section) return true;
  1786. }
  1787. return false;
  1788. }
  1789. function getSectionPatch(section) {
  1790. const patch = {};
  1791. for (const key of state.dirty) {
  1792. if (fields[key]?.section === section && !state.errors[key]) patch[key] = state.draft[key];
  1793. }
  1794. return patch;
  1795. }
  1796. async function api(path, opts) {
  1797. const response = await fetch(path, opts);
  1798. const text = await response.text();
  1799. if (!response.ok) {
  1800. throw new Error(text.trim() || `HTTP ${response.status}`);
  1801. }
  1802. if (!text) return {};
  1803. try {
  1804. return JSON.parse(text);
  1805. } catch {
  1806. return { ok: true, raw: text };
  1807. }
  1808. }
  1809. function setConnection(ok, mode) {
  1810. const led = $('led-conn');
  1811. const label = $('conn-label');
  1812. if (ok) {
  1813. led.className = 'led on-green';
  1814. label.textContent = mode || 'connected';
  1815. } else {
  1816. led.className = 'led on-red';
  1817. label.textContent = mode || 'offline';
  1818. }
  1819. }
  1820. async function loadConfig({ silent = false } = {}) {
  1821. try {
  1822. const cfg = await api('/config');
  1823. state.server.config = cfg;
  1824. state.server.configOk = true;
  1825. state.server.lastConfigAt = nowTs();
  1826. syncIngestDraftFromConfig();
  1827. syncFreqPresetIndex(cfg.fm?.frequencyMHz);
  1828. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1829. render();
  1830. if (!silent) log('Config synced from server', 'info');
  1831. return cfg;
  1832. } catch (error) {
  1833. state.server.configOk = false;
  1834. if (!state.server.runtimeOk) setConnection(false, 'offline');
  1835. render();
  1836. if (!silent) log('Config load failed: ' + error.message, 'err');
  1837. throw error;
  1838. }
  1839. }
  1840. async function loadRuntime({ silent = true } = {}) {
  1841. try {
  1842. const runtime = await api('/runtime');
  1843. state.server.runtime = runtime;
  1844. state.server.runtimeOk = true;
  1845. state.server.lastRuntimeAt = nowTs();
  1846. const syncedTransitions = syncTransitionHistoryFromEngine(runtime.engine);
  1847. notifyRuntimeTransition(runtime.engine, !syncedTransitions);
  1848. pushHistory(runtime);
  1849. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1850. render();
  1851. return runtime;
  1852. } catch (error) {
  1853. state.server.runtimeOk = false;
  1854. if (!state.server.configOk) setConnection(false, 'offline');
  1855. render();
  1856. if (!silent) log('Runtime load failed: ' + error.message, 'err');
  1857. throw error;
  1858. }
  1859. }
  1860. function pushHistory(runtime) {
  1861. const engine = runtime.engine || {};
  1862. const driver = runtime.driver || {};
  1863. const audio = runtime.audioStream || {};
  1864. pushChart(state.charts.audio, typeof audio.buffered === 'number' ? audio.buffered : 0);
  1865. const highWatermarkDurationSeconds = Number(audio.highWatermarkDurationSeconds);
  1866. const normalizedHighWatermark = Number.isFinite(highWatermarkDurationSeconds) ? highWatermarkDurationSeconds : 0;
  1867. pushChart(state.charts.highWatermark, normalizedHighWatermark);
  1868. const queueFill = Number(engine.queue?.fillLevel ?? 0);
  1869. pushChart(state.charts.queueFill, Number.isFinite(queueFill) ? queueFill : 0);
  1870. pushChart(state.charts.underruns, Number(engine.underruns ?? driver.underruns ?? 0));
  1871. const txState = String(engine.state || 'idle').toLowerCase();
  1872. pushChart(state.charts.tx, txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.05);
  1873. }
  1874. function pushTransitionHistory(from, to, severity) {
  1875. if (!from || !to) return;
  1876. const entry = {
  1877. from: normalizeRuntimeState(from),
  1878. to: normalizeRuntimeState(to),
  1879. severity: severity || 'info',
  1880. time: nowTs(),
  1881. };
  1882. state.runtimeTransitions.unshift(entry);
  1883. if (state.runtimeTransitions.length > transitionHistoryLimit) {
  1884. state.runtimeTransitions.splice(transitionHistoryLimit);
  1885. }
  1886. updateTransitionHistory();
  1887. }
  1888. function transitionEntryTime(value) {
  1889. if (value == null) return nowTs();
  1890. if (typeof value === 'number') return value;
  1891. const parsed = Date.parse(String(value));
  1892. return Number.isNaN(parsed) ? nowTs() : parsed;
  1893. }
  1894. function syncTransitionHistoryFromEngine(engine) {
  1895. const entries = Array.isArray(engine?.transitionHistory) ? engine.transitionHistory : null;
  1896. if (!entries) return false;
  1897. const sliceStart = Math.max(0, entries.length - transitionHistoryLimit);
  1898. const trimmed = entries.slice(sliceStart);
  1899. const normalized = trimmed.map((entry) => ({
  1900. from: normalizeRuntimeState(entry?.from),
  1901. to: normalizeRuntimeState(entry?.to),
  1902. severity: String(entry?.severity || 'info').toLowerCase(),
  1903. time: transitionEntryTime(entry?.time),
  1904. }));
  1905. normalized.reverse();
  1906. state.runtimeTransitions = normalized;
  1907. updateTransitionHistory();
  1908. return true;
  1909. }
  1910. function transitionSeverityClass(severity) {
  1911. switch (String(severity || '').toLowerCase()) {
  1912. case 'err':
  1913. return 'err';
  1914. case 'warn':
  1915. return 'warn';
  1916. case 'ok':
  1917. case 'good':
  1918. return 'good';
  1919. default:
  1920. return 'info';
  1921. }
  1922. }
  1923. function updateTransitionHistory() {
  1924. const container = $('transition-history');
  1925. if (!container) return;
  1926. if (!state.runtimeTransitions.length) {
  1927. container.innerHTML = '<div class="transition-history-empty">No transitions yet.</div>';
  1928. return;
  1929. }
  1930. const rows = state.runtimeTransitions.map((entry) => {
  1931. const when = entry?.time ? new Date(entry.time) : null;
  1932. const timeLabel = when && !Number.isNaN(when.getTime()) ? when.toLocaleTimeString() : '--:--';
  1933. const desc = `${entry.from.toUpperCase()} → ${entry.to.toUpperCase()}`;
  1934. const severityClass = transitionSeverityClass(entry.severity);
  1935. return `<div class="transition-history-entry ${severityClass}"><span class="transition-history-time">${timeLabel}</span><span class="transition-history-desc">${desc}</span></div>`;
  1936. });
  1937. container.innerHTML = rows.join('');
  1938. }
  1939. function pushChart(arr, value) {
  1940. arr.push(Number.isFinite(value) ? value : 0);
  1941. if (arr.length > sparkHistoryLimit) arr.splice(0, arr.length - sparkHistoryLimit);
  1942. }
  1943. function setConnectionBusy() {
  1944. setConnection(true, 'busy');
  1945. }
  1946. function beginRequest() {
  1947. state.pendingRequests += 1;
  1948. setConnectionBusy();
  1949. render();
  1950. }
  1951. function endRequest() {
  1952. state.pendingRequests = Math.max(0, state.pendingRequests - 1);
  1953. if (state.server.configOk || state.server.runtimeOk) {
  1954. setConnection(true, state.pendingRequests > 0 ? 'busy' : 'connected');
  1955. }
  1956. render();
  1957. }
  1958. async function sendPatch(patch, { successMessage = 'Applied', clearKeys = [] } = {}) {
  1959. beginRequest();
  1960. try {
  1961. const result = await api('/config', {
  1962. method: 'POST',
  1963. headers: { 'Content-Type': 'application/json' },
  1964. body: JSON.stringify(patch),
  1965. });
  1966. if (state.server.config) mergeLocalConfigPatch(patch);
  1967. clearDirty(clearKeys);
  1968. render();
  1969. toast(successMessage + (result.live ? ' · live' : ''), 'ok');
  1970. log('PATCH ' + JSON.stringify(patch) + (result.live ? ' [live]' : ' [saved]'), 'ok');
  1971. await Promise.allSettled([
  1972. loadConfig({ silent: true }),
  1973. loadRuntime({ silent: true }),
  1974. ]);
  1975. return result;
  1976. } catch (error) {
  1977. toast(error.message, 'err');
  1978. log('PATCH failed: ' + error.message, 'err');
  1979. throw error;
  1980. } finally {
  1981. endRequest();
  1982. }
  1983. }
  1984. function mergeLocalConfigPatch(patch) {
  1985. const cfg = state.server.config || {};
  1986. cfg.fm ||= {};
  1987. cfg.rds ||= {};
  1988. for (const [key, value] of Object.entries(patch)) {
  1989. switch (key) {
  1990. case 'frequencyMHz': cfg.fm.frequencyMHz = value; break;
  1991. case 'stereoEnabled': cfg.fm.stereoEnabled = value; break;
  1992. case 'limiterEnabled': cfg.fm.limiterEnabled = value; break;
  1993. case 'rdsEnabled': cfg.rds.enabled = value; break;
  1994. case 'ps': cfg.rds.ps = value; break;
  1995. case 'radioText': cfg.rds.radioText = value; break;
  1996. }
  1997. }
  1998. }
  1999. async function applySection(section) {
  2000. if (sectionHasErrors(section)) {
  2001. toast('Fix the highlighted fields first', 'warn');
  2002. log(section.toUpperCase() + ' apply blocked by validation errors', 'warn');
  2003. render();
  2004. return;
  2005. }
  2006. const patch = getSectionPatch(section);
  2007. const keys = Object.keys(patch);
  2008. if (!keys.length) {
  2009. toast('No changes to apply', 'info');
  2010. return;
  2011. }
  2012. let message = 'Applied';
  2013. if (section === 'freq') message = 'Frequency updated';
  2014. if (section === 'rds') message = 'RDS text updated';
  2015. await sendPatch(patch, { successMessage: message, clearKeys: keys });
  2016. }
  2017. function resetSection(section) {
  2018. const keys = Object.keys(fields).filter((key) => fields[key].section === section);
  2019. clearDirty(keys);
  2020. log(section.toUpperCase() + ' draft reset', 'warn');
  2021. toast('Draft reset', 'info');
  2022. }
  2023. async function saveIngestConfig() {
  2024. if (state.ingestSaving) return;
  2025. if (!state.ingestDirty) {
  2026. toast('No ingest changes to save', 'info');
  2027. return;
  2028. }
  2029. if (!state.ingestDraft) {
  2030. toast('Ingest draft not ready yet', 'warn');
  2031. return;
  2032. }
  2033. state.ingestSaving = true;
  2034. state.ingestError = '';
  2035. beginRequest();
  2036. render();
  2037. try {
  2038. const result = await api('/config/ingest/save', {
  2039. method: 'POST',
  2040. headers: { 'Content-Type': 'application/json' },
  2041. body: JSON.stringify({ ingest: state.ingestDraft }),
  2042. });
  2043. state.ingestDirty = false;
  2044. toast(result.reloadScheduled ? 'Ingest saved, hard reload scheduled' : 'Ingest saved', 'ok');
  2045. log('INGEST save accepted' + (result.reloadScheduled ? ' [hard-reload]' : ''), 'ok');
  2046. if (result.reloadScheduled) {
  2047. setTimeout(() => {
  2048. window.location.reload();
  2049. }, 1500);
  2050. }
  2051. } catch (error) {
  2052. state.ingestError = error.message;
  2053. toast(error.message, 'err');
  2054. log('INGEST save failed: ' + error.message, 'err');
  2055. } finally {
  2056. state.ingestSaving = false;
  2057. endRequest();
  2058. render();
  2059. }
  2060. }
  2061. function resetIngestDraft() {
  2062. syncIngestDraftFromConfig(true);
  2063. toast('Ingest draft reset', 'info');
  2064. log('INGEST draft reset', 'warn');
  2065. render();
  2066. }
  2067. async function setToggle(key, nextValue) {
  2068. if (state.toggleBusy[key]) return;
  2069. state.toggleBusy[key] = true;
  2070. render();
  2071. try {
  2072. await sendPatch({ [key]: nextValue }, {
  2073. successMessage: key.replace(/Enabled$/, '') + ' ' + (nextValue ? 'enabled' : 'disabled'),
  2074. clearKeys: [],
  2075. });
  2076. } finally {
  2077. state.toggleBusy[key] = false;
  2078. render();
  2079. }
  2080. }
  2081. async function txAction(action) {
  2082. if (state.txBusy) return;
  2083. state.txBusy = true;
  2084. render();
  2085. beginRequest();
  2086. try {
  2087. await api(`/tx/${action}`, { method: 'POST' });
  2088. toast(action === 'start' ? 'TX started' : 'TX stopped', 'ok');
  2089. log('TX ' + action + ' request accepted', 'ok');
  2090. await Promise.allSettled([
  2091. loadRuntime({ silent: true }),
  2092. loadConfig({ silent: true }),
  2093. ]);
  2094. } catch (error) {
  2095. toast(error.message, 'err');
  2096. log('TX ' + action + ' failed: ' + error.message, 'err');
  2097. } finally {
  2098. state.txBusy = false;
  2099. endRequest();
  2100. render();
  2101. }
  2102. }
  2103. async function resetFaultAction() {
  2104. if (state.faultResetBusy) return;
  2105. state.faultResetBusy = true;
  2106. render();
  2107. beginRequest();
  2108. try {
  2109. await api('/runtime/fault/reset', { method: 'POST' });
  2110. toast('Fault reset', 'ok');
  2111. log('Fault reset request accepted', 'ok');
  2112. await loadRuntime({ silent: true });
  2113. } catch (error) {
  2114. toast(error.message, 'err');
  2115. log('Fault reset failed: ' + error.message, 'err');
  2116. } finally {
  2117. state.faultResetBusy = false;
  2118. endRequest();
  2119. render();
  2120. }
  2121. }
  2122. function fmt(n) {
  2123. if (n == null) return '--';
  2124. if (n >= 1e9) return (n / 1e9).toFixed(2) + 'G';
  2125. if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
  2126. if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
  2127. return String(n);
  2128. }
  2129. function fmtTime(seconds) {
  2130. if (!seconds || seconds <= 0) return '--';
  2131. const h = Math.floor(seconds / 3600);
  2132. const m = Math.floor((seconds % 3600) / 60);
  2133. const s = Math.floor(seconds % 60);
  2134. if (h > 0) return `${h}h ${m}m`;
  2135. if (m > 0) return `${m}m ${s}s`;
  2136. return `${s}s`;
  2137. }
  2138. function fmtDurationSeconds(value) {
  2139. if (!Number.isFinite(value) || value < 0) return '--';
  2140. if (value >= 1) return `${value.toFixed(2)} s`;
  2141. return `${(value * 1000).toFixed(0)} ms`;
  2142. }
  2143. function fmtBool(v) {
  2144. return v == null ? '--' : (v ? 'ON' : 'OFF');
  2145. }
  2146. function fmtFreq(v) {
  2147. return typeof v === 'number' ? v.toFixed(1) + ' MHz' : '--';
  2148. }
  2149. function fmtPercent(v) {
  2150. if (typeof v !== 'number') return '--';
  2151. return (v * 100).toFixed(0) + '%';
  2152. }
  2153. function ageString(ts) {
  2154. if (!ts) return '--';
  2155. const diff = Math.max(0, Math.floor((nowTs() - ts) / 1000));
  2156. if (diff < 2) return 'just now';
  2157. if (diff < 60) return diff + 's ago';
  2158. const m = Math.floor(diff / 60);
  2159. if (m < 60) return m + 'm ago';
  2160. const h = Math.floor(m / 60);
  2161. return h + 'h ago';
  2162. }
  2163. function ageFromTimestamp(value) {
  2164. if (!value) return '--';
  2165. if (typeof value === 'number') return ageString(value);
  2166. const ts = Date.parse(String(value));
  2167. if (Number.isNaN(ts)) return '--';
  2168. return ageString(ts);
  2169. }
  2170. function joinSummaryParts(parts) {
  2171. return parts.filter((part) => String(part || '').trim() !== '').join(' · ');
  2172. }
  2173. function updateText(id, text) {
  2174. const el = $(id);
  2175. if (el && el.textContent !== String(text)) el.textContent = text;
  2176. }
  2177. function updateHTML(id, html) {
  2178. const el = $(id);
  2179. if (el && el.innerHTML !== html) el.innerHTML = html;
  2180. }
  2181. function syncDirtyInput(id, key, transform = (v) => v) {
  2182. const el = $(id);
  2183. if (!el) return;
  2184. const dirty = state.dirty.has(key);
  2185. const desired = dirty ? transform(state.draft[key]) : transform(serverValue(key));
  2186. const asString = desired == null ? '' : String(desired);
  2187. const isFocused = document.activeElement === el;
  2188. if (!isFocused || !dirty) {
  2189. if (el.value !== asString) el.value = asString;
  2190. }
  2191. el.classList.toggle('input-dirty', dirty && !state.errors[key]);
  2192. el.classList.toggle('input-error', !!state.errors[key]);
  2193. }
  2194. function syncIngestInput(id, path, transform = (v) => v) {
  2195. const el = $(id);
  2196. if (!el) return;
  2197. const value = transform(ingestFieldValue(path));
  2198. const asString = value == null ? '' : String(value);
  2199. const isFocused = document.activeElement === el;
  2200. if (!isFocused && el.value !== asString) el.value = asString;
  2201. }
  2202. function syncIngestCheckbox(id, path) {
  2203. const el = $(id);
  2204. if (!el) return;
  2205. el.checked = !!ingestFieldValue(path);
  2206. }
  2207. function renderFieldErrors() {
  2208. renderFieldError('freq-error', state.errors.frequencyMHz);
  2209. renderFieldError('ps-error', state.errors.ps);
  2210. renderFieldError('rt-error', state.errors.radioText);
  2211. }
  2212. function renderFieldError(id, message) {
  2213. const el = $(id);
  2214. el.textContent = message || '';
  2215. el.classList.toggle('show', !!message);
  2216. }
  2217. function setMeter(fillId, textId, ratio, text, mode = 'good') {
  2218. const fill = $(fillId);
  2219. const pct = Math.max(0, Math.min(100, Math.round((ratio ?? 0) * 100)));
  2220. fill.style.width = pct + '%';
  2221. fill.className = 'meter-fill' + (mode === 'warn' ? ' warn' : mode === 'err' ? ' err' : '');
  2222. updateText(textId, text);
  2223. }
  2224. function applyMobilePanelDefaults() {
  2225. if (!mobileMq.matches || state.mobilePanelsApplied) return;
  2226. state.mobilePanelsApplied = true;
  2227. document.querySelectorAll('.panel[data-panel-key]').forEach((panel) => {
  2228. const key = panel.dataset.panelKey;
  2229. const head = panel.querySelector('.panel-head');
  2230. const body = panel.querySelector('.panel-body');
  2231. const keepOpen = key === 'frequency' || key === 'danger';
  2232. head.classList.toggle('collapsed', !keepOpen);
  2233. body.classList.toggle('collapsed', !keepOpen);
  2234. });
  2235. }
  2236. function syncFreqPresetIndex(value) {
  2237. if (typeof value !== 'number') return;
  2238. let closestIndex = 0;
  2239. let closestDistance = Infinity;
  2240. freqPresetValues.forEach((freq, idx) => {
  2241. const dist = Math.abs(freq - value);
  2242. if (dist < closestDistance) {
  2243. closestDistance = dist;
  2244. closestIndex = idx;
  2245. }
  2246. });
  2247. state.freqPresetIndex = closestIndex;
  2248. }
  2249. function cycleFreqPreset(direction) {
  2250. state.freqPresetIndex = (state.freqPresetIndex + direction + freqPresetValues.length) % freqPresetValues.length;
  2251. const next = freqPresetValues[state.freqPresetIndex];
  2252. setDirty('frequencyMHz', next);
  2253. toast(`Preset ${next.toFixed(1)} MHz loaded`, 'info');
  2254. }
  2255. function refreshPresetButtons() {
  2256. const effectiveFreq = effectiveValue('frequencyMHz');
  2257. document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
  2258. const value = Number(btn.dataset.freqPreset);
  2259. btn.classList.toggle('active', typeof effectiveFreq === 'number' && nearlyEqual(value, effectiveFreq, 0.05));
  2260. });
  2261. }
  2262. function updateHeroState(engineState) {
  2263. const hero = $('hero-card');
  2264. hero.classList.remove('tx-live', 'tx-busy');
  2265. if (state.txBusy || ['starting', 'stopping'].includes(engineState)) {
  2266. hero.classList.add('tx-busy');
  2267. } else if (engineState === 'running') {
  2268. hero.classList.add('tx-live');
  2269. }
  2270. }
  2271. function drawSparkline(svgId, values, mode = 'good', maxOverride = null) {
  2272. const svg = $(svgId);
  2273. if (!svg) return;
  2274. svg.className = `spark ${mode}`;
  2275. const width = 160;
  2276. const height = 34;
  2277. const points = values.length ? values : [0, 0];
  2278. const max = maxOverride != null ? maxOverride : Math.max(...points, 1);
  2279. const min = 0;
  2280. const step = points.length <= 1 ? width : width / (points.length - 1);
  2281. const coords = points.map((value, i) => {
  2282. const x = i * step;
  2283. const norm = max === min ? 0 : (value - min) / (max - min || 1);
  2284. const y = height - 4 - norm * (height - 8);
  2285. return [x, y];
  2286. });
  2287. const line = coords.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
  2288. const area = `${line} L ${width},${height} L 0,${height} Z`;
  2289. svg.innerHTML = `<path class="area" d="${area}"></path><path class="line" d="${line}"></path>`;
  2290. }
  2291. function render() {
  2292. const cfg = state.server.config || {};
  2293. const runtime = state.server.runtime || {};
  2294. const engine = runtime.engine || {};
  2295. const driver = runtime.driver || {};
  2296. const audioStream = runtime.audioStream || null;
  2297. const hasIngestRuntime = !!runtime.ingest;
  2298. const ingest = runtime.ingest || {};
  2299. const ingestActive = ingest.active || {};
  2300. const ingestSource = ingest.source || {};
  2301. const ingestRuntime = ingest.runtime || {};
  2302. const appliedRaw = engine.appliedFrequencyMHz;
  2303. const appliedFreq = Number.isFinite(Number(appliedRaw)) ? Number(appliedRaw) : null;
  2304. const desiredRaw = cfg.fm?.frequencyMHz;
  2305. const desiredFreq = Number.isFinite(Number(desiredRaw)) ? Number(desiredRaw) : null;
  2306. const displayFreq = appliedFreq ?? effectiveValue('frequencyMHz') ?? desiredFreq;
  2307. updateHTML('freq-display', `${typeof displayFreq === 'number' ? displayFreq.toFixed(1) : '---.-'}<span class="unit">MHz</span>`);
  2308. const appliedLabel = appliedFreq != null ? `Applied ${appliedFreq.toFixed(1)} MHz` : 'Applied --';
  2309. const desiredLabel = desiredFreq != null ? `Desired ${desiredFreq.toFixed(1)} MHz` : 'Desired --';
  2310. updateText('freq-applied', appliedLabel);
  2311. updateText('freq-desired', desiredLabel);
  2312. const noteEl = $('freq-note');
  2313. if (noteEl) {
  2314. const mismatch = appliedFreq != null && desiredFreq != null && !nearlyEqual(appliedFreq, desiredFreq, 0.001);
  2315. noteEl.classList.toggle('mismatch', mismatch);
  2316. }
  2317. updateText('badge-backend', cfg.backend?.kind || cfg.backend || '--');
  2318. updateText('badge-mode', engine.state && engine.state !== 'idle' ? 'TX Active' : 'Control Plane');
  2319. updateText('badge-live', state.server.runtimeOk ? 'Connected' : 'Waiting');
  2320. updateText('t-chunks', fmt(engine.chunksProduced));
  2321. updateText('t-samples', fmt(engine.totalSamples));
  2322. updateText('t-uptime', fmtTime(engine.uptimeSeconds));
  2323. updateText('t-rate', driver.effectiveSampleRateHz ? (driver.effectiveSampleRateHz / 1000).toFixed(0) + 'k' : '--');
  2324. const underruns = engine.underruns ?? driver.underruns;
  2325. const underrunEl = $('t-underruns');
  2326. underrunEl.textContent = underruns == null ? '--' : String(underruns);
  2327. underrunEl.className = 'value' + (underruns > 0 ? ' err' : underruns === 0 ? ' good' : '');
  2328. const txStateValue = String(engine.state || 'idle').toLowerCase();
  2329. const txState = state.txBusy ? 'WORKING…' : txStateValue.toUpperCase();
  2330. const txClass = state.txBusy ? 'working' : txStateValue;
  2331. $('tx-state').textContent = txState;
  2332. $('tx-state').className = 'tx-state ' + txClass;
  2333. updateText('tx-hint', engine.lastError ? `Last error: ${engine.lastError}` : (state.txBusy ? 'Command in progress' : 'Runtime polled every 1s'));
  2334. updateHeroState(txStateValue);
  2335. const startDisabled = state.txBusy || txStateValue === 'running';
  2336. const stopDisabled = state.txBusy || ['idle', 'stopped', ''].includes(txStateValue);
  2337. $('btn-start').disabled = startDisabled;
  2338. $('btn-stop').disabled = stopDisabled;
  2339. $('btn-refresh').disabled = state.pendingRequests > 0;
  2340. $('danger-stop').disabled = stopDisabled;
  2341. $('danger-refresh').disabled = state.pendingRequests > 0;
  2342. const resetFaultBtn = $('danger-reset-fault');
  2343. if (resetFaultBtn) {
  2344. const resetDisabled = state.faultResetBusy || !state.server.runtimeOk;
  2345. resetFaultBtn.disabled = resetDisabled;
  2346. resetFaultBtn.textContent = state.faultResetBusy ? 'Resetting…' : 'Reset Fault';
  2347. }
  2348. syncDirtyInput('freq-slider', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
  2349. syncDirtyInput('freq-num', 'frequencyMHz', (v) => typeof v === 'number' ? v.toFixed(1) : '100.0');
  2350. syncDirtyInput('rds-ps', 'ps', (v) => String(v ?? ''));
  2351. syncDirtyInput('rds-rt', 'radioText', (v) => String(v ?? ''));
  2352. syncIngestInput('ing-kind', 'kind', (v) => String(v ?? 'none'));
  2353. syncIngestInput('ing-prebuffer', 'prebufferMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0);
  2354. syncIngestInput('ing-stall-timeout', 'stallTimeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0);
  2355. syncIngestCheckbox('ing-reconnect-enabled', 'reconnect.enabled');
  2356. syncIngestInput('ing-reconnect-initial', 'reconnect.initialBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0);
  2357. syncIngestInput('ing-reconnect-max', 'reconnect.maxBackoffMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 0);
  2358. syncIngestInput('ing-icecast-url', 'icecast.url', (v) => String(v ?? ''));
  2359. syncIngestInput('ing-icecast-decoder', 'icecast.decoder', (v) => String(v ?? 'auto'));
  2360. syncIngestCheckbox('ing-icecast-rt-enabled', 'icecast.radioText.enabled');
  2361. syncIngestInput('ing-icecast-rt-prefix', 'icecast.radioText.prefix', (v) => String(v ?? ''));
  2362. syncIngestInput('ing-icecast-rt-maxlen', 'icecast.radioText.maxLen', (v) => Number.isFinite(Number(v)) ? Number(v) : 64);
  2363. syncIngestCheckbox('ing-icecast-rt-only-change', 'icecast.radioText.onlyOnChange');
  2364. syncIngestInput('ing-srt-url', 'srt.url', (v) => String(v ?? ''));
  2365. syncIngestInput('ing-srt-mode', 'srt.mode', (v) => String(v ?? 'listener'));
  2366. syncIngestInput('ing-srt-rate', 'srt.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000);
  2367. syncIngestInput('ing-srt-channels', 'srt.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2);
  2368. syncIngestInput('ing-aes67-sdppath', 'aes67.sdpPath', (v) => String(v ?? ''));
  2369. syncIngestInput('ing-aes67-sdp', 'aes67.sdp', (v) => String(v ?? ''));
  2370. syncIngestInput('ing-aes67-group', 'aes67.multicastGroup', (v) => String(v ?? ''));
  2371. syncIngestInput('ing-aes67-port', 'aes67.port', (v) => Number.isFinite(Number(v)) ? Number(v) : 5004);
  2372. syncIngestInput('ing-aes67-pt', 'aes67.payloadType', (v) => Number.isFinite(Number(v)) ? Number(v) : 97);
  2373. syncIngestInput('ing-aes67-rate', 'aes67.sampleRateHz', (v) => Number.isFinite(Number(v)) ? Number(v) : 48000);
  2374. syncIngestInput('ing-aes67-channels', 'aes67.channels', (v) => Number.isFinite(Number(v)) ? Number(v) : 2);
  2375. syncIngestInput('ing-aes67-encoding', 'aes67.encoding', (v) => String(v ?? 'L24'));
  2376. syncIngestInput('ing-aes67-ptime', 'aes67.packetTimeMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 1);
  2377. syncIngestInput('ing-aes67-jitter', 'aes67.jitterDepthPackets', (v) => Number.isFinite(Number(v)) ? Number(v) : 8);
  2378. syncIngestInput('ing-aes67-readbuf', 'aes67.readBufferBytes', (v) => Number.isFinite(Number(v)) ? Number(v) : 1048576);
  2379. syncIngestCheckbox('ing-aes67-discovery-enabled', 'aes67.discovery.enabled');
  2380. syncIngestInput('ing-aes67-discovery-name', 'aes67.discovery.streamName', (v) => String(v ?? ''));
  2381. syncIngestInput('ing-aes67-discovery-timeout', 'aes67.discovery.timeoutMs', (v) => Number.isFinite(Number(v)) ? Number(v) : 3000);
  2382. syncIngestInput('ing-aes67-discovery-group', 'aes67.discovery.sapGroup', (v) => String(v ?? ''));
  2383. syncIngestInput('ing-aes67-discovery-port', 'aes67.discovery.sapPort', (v) => Number.isFinite(Number(v)) ? Number(v) : 0);
  2384. const psValue = String(effectiveValue('ps') ?? cfg.rds?.ps ?? '');
  2385. const rtValue = String(effectiveValue('radioText') ?? cfg.rds?.radioText ?? '');
  2386. updateText('ps-count', psValue.length);
  2387. updateText('rt-count', rtValue.length);
  2388. renderFieldErrors();
  2389. refreshPresetButtons();
  2390. renderToggle('stereoEnabled', 'tog-stereo', 'stereo-label');
  2391. renderToggle('rdsEnabled', 'tog-rds', 'rds-label');
  2392. renderToggle('limiterEnabled', 'tog-limiter', 'limiter-label');
  2393. const freqDirty = isDirtySection('freq');
  2394. $('freq-apply').disabled = !freqDirty || sectionHasErrors('freq');
  2395. $('freq-reset').disabled = !freqDirty;
  2396. const rdsDirty = isDirtySection('rds');
  2397. $('rds-apply').disabled = !rdsDirty || sectionHasErrors('rds');
  2398. $('rds-reset').disabled = !rdsDirty;
  2399. const ingestKind = String(ingestFieldValue('kind') || 'none').toLowerCase();
  2400. $('ing-group-icecast').style.display = ingestKind === 'icecast' ? '' : 'none';
  2401. $('ing-group-srt').style.display = ingestKind === 'srt' ? '' : 'none';
  2402. $('ing-group-aes67').style.display = ingestKind === 'aes67' ? '' : 'none';
  2403. $('ingest-save-reload').disabled = !state.ingestDirty || state.ingestSaving || !state.server.configOk;
  2404. $('ingest-save-reload').textContent = state.ingestSaving ? 'Saving...' : 'Save + Hard Reload';
  2405. $('ingest-reset').disabled = !state.ingestDirty || state.ingestSaving;
  2406. const ingestErr = $('ingest-error');
  2407. ingestErr.textContent = state.ingestError || '';
  2408. ingestErr.classList.toggle('show', !!state.ingestError);
  2409. updateText('freq-meta', sectionHasErrors('freq') ? 'Validation error' : (freqDirty ? 'Unsaved changes' : 'Live-tunable'));
  2410. updateText('rds-meta', sectionHasErrors('rds') ? 'Validation error' : (rdsDirty ? `${Object.keys(getSectionPatch('rds')).length} unsaved` : 'PS + RT'));
  2411. updateText('ingest-meta', state.ingestSaving ? 'Saving' : (state.ingestDirty ? 'Unsaved changes' : 'Saved config'));
  2412. const configuredKind = String(cfg.ingest?.kind || ingestFieldValue('kind') || 'none').toLowerCase();
  2413. const activeKind = String(ingestActive.kind || configuredKind || 'none').toLowerCase();
  2414. const runtimeStateLabel = String(ingestRuntime.state || '').toLowerCase();
  2415. const sourceStateLabel = String(ingestSource.state || '').toLowerCase();
  2416. const stateSummary = hasIngestRuntime ? (joinSummaryParts([
  2417. runtimeStateLabel ? runtimeStateLabel.toUpperCase() : '',
  2418. sourceStateLabel ? `source ${sourceStateLabel.toUpperCase()}` : '',
  2419. ingestRuntime.prebuffering ? 'PREBUFFERING' : '',
  2420. ingestRuntime.writeBlocked ? 'WRITE-BLOCKED' : '',
  2421. ]) || '--') : '--';
  2422. const sourceSummary = joinSummaryParts([
  2423. activeKind || 'none',
  2424. ingestActive.transport || '',
  2425. ingestActive.codec || '',
  2426. Number.isFinite(Number(ingestActive.sampleRateHz)) ? `${Number(ingestActive.sampleRateHz)} Hz` : '',
  2427. Number.isFinite(Number(ingestActive.channels)) ? `${Number(ingestActive.channels)} ch` : '',
  2428. ]) || '--';
  2429. const signalSummary = hasIngestRuntime ? (joinSummaryParts([
  2430. ingestSource.connected ? 'connected' : 'disconnected',
  2431. Number.isFinite(Number(ingestSource.bufferedSeconds)) ? `${Number(ingestSource.bufferedSeconds).toFixed(2)}s buffered` : '',
  2432. Number.isFinite(Number(ingestSource.reconnects)) ? `${Number(ingestSource.reconnects)} reconnects` : '',
  2433. ]) || '--') : '--';
  2434. const detailSummary = hasIngestRuntime ? (ingestSource.streamTitle || ingestActive.detail || ingestSource.lastError || '--') : '--';
  2435. const origin = ingestActive.origin || {};
  2436. const originSummary = hasIngestRuntime ? (joinSummaryParts([
  2437. origin.kind || '',
  2438. origin.endpoint || '',
  2439. origin.streamName || '',
  2440. origin.mode || '',
  2441. origin.sdpPath || '',
  2442. ]) || '--') : '--';
  2443. const lastChunkSummary = hasIngestRuntime ? ageFromTimestamp(ingestRuntime.lastChunkAt || ingestSource.lastChunkAt) : '--';
  2444. updateText('ingest-summary-state', stateSummary);
  2445. updateText('ingest-summary-source', sourceSummary);
  2446. updateText('ingest-summary-signal', signalSummary);
  2447. updateText('ingest-summary-detail', detailSummary);
  2448. updateText('ingest-summary-origin', originSummary);
  2449. updateText('ingest-summary-last', lastChunkSummary);
  2450. updateText('info-backend', cfg.backend?.kind || cfg.backend || '--');
  2451. updateText('info-freq', fmtFreq(cfg.fm?.frequencyMHz));
  2452. updateText('info-preemph', cfg.fm?.preEmphasisTauUS ? `${cfg.fm.preEmphasisTauUS} µs` : 'Off');
  2453. updateText('info-fmmod', fmtBool(cfg.fm?.fmModulationEnabled));
  2454. updateText('info-runtime-age', ageString(state.server.lastRuntimeAt));
  2455. updateText('info-last-alert', engine.runtimeAlert ? engine.runtimeAlert : (engine.lastError ? engine.lastError : 'None'));
  2456. updateText('info-live', engine.state ? `${String(engine.state).toUpperCase()} / ${state.server.runtimeOk ? 'runtime ok' : 'runtime pending'}` : (state.server.configOk ? 'config only' : '--'));
  2457. updateHealth(engine, driver, audioStream);
  2458. updateControlAudit(runtime.controlAudit);
  2459. updateFaultHistory(engine);
  2460. updateTransitionHistory();
  2461. updateResetHint(engine);
  2462. updateMeters(engine, driver, audioStream);
  2463. const highWatermarkDurationSecondsRaw = audioStream?.highWatermarkDurationSeconds;
  2464. const highWatermarkDurationSeconds = Number(highWatermarkDurationSecondsRaw);
  2465. const highWatermarkFramesRaw = audioStream?.highWatermark;
  2466. const highWatermarkFrames = Number.isFinite(Number(highWatermarkFramesRaw)) ? Number(highWatermarkFramesRaw) : 0;
  2467. const capacityRaw = audioStream?.capacity;
  2468. const capacity = Number.isFinite(Number(capacityRaw)) ? Number(capacityRaw) : 0;
  2469. const bufferedDurationSecondsRaw = audioStream?.bufferedDurationSeconds;
  2470. const bufferedDurationSeconds = Number(bufferedDurationSecondsRaw);
  2471. const hasBufferedDuration = Number.isFinite(bufferedDurationSeconds);
  2472. const hasHighWatermarkDuration = Number.isFinite(highWatermarkDurationSeconds);
  2473. const highWatermarkRatio = capacity > 0 ? Math.min(1, highWatermarkFrames / capacity) : 0;
  2474. let highWatermarkMode = 'good';
  2475. if (highWatermarkRatio >= 0.95) highWatermarkMode = 'err';
  2476. else if (highWatermarkRatio >= 0.65) highWatermarkMode = 'warn';
  2477. const sparkHighWatermarkMax = Math.max(
  2478. 1,
  2479. hasHighWatermarkDuration ? highWatermarkDurationSeconds : 0,
  2480. hasBufferedDuration ? bufferedDurationSeconds : 0
  2481. );
  2482. const queueHealthRaw = String(engine.queue?.health || '').toLowerCase();
  2483. let queueSparkMode = 'good';
  2484. if (queueHealthRaw === 'critical') queueSparkMode = 'err';
  2485. else if (queueHealthRaw === 'low') queueSparkMode = 'warn';
  2486. drawSparkline('spark-audio', state.charts.audio, 'good', 1);
  2487. drawSparkline('spark-high-watermark', state.charts.highWatermark, highWatermarkMode, sparkHighWatermarkMax);
  2488. drawSparkline('spark-queue-fill', state.charts.queueFill, queueSparkMode, 1);
  2489. drawSparkline('spark-underruns', state.charts.underruns, underruns > 0 ? 'err' : 'warn');
  2490. drawSparkline('spark-tx', state.charts.tx, txStateValue === 'running' ? 'good' : 'warn', 1);
  2491. applyMobilePanelDefaults();
  2492. }
  2493. function renderToggle(key, toggleId, labelId) {
  2494. const on = !!serverValue(key);
  2495. const busy = !!state.toggleBusy[key];
  2496. const el = $(toggleId);
  2497. el.className = 'toggle' + (on ? ' on' : '') + (busy ? ' busy' : '');
  2498. el.setAttribute('aria-checked', on ? 'true' : 'false');
  2499. updateText(labelId, busy ? '...' : (on ? 'ON' : 'OFF'));
  2500. }
  2501. function runtimeStateClass(engineState) {
  2502. const normalized = String(engineState || '').toLowerCase();
  2503. if (!normalized) {
  2504. return 'warn';
  2505. }
  2506. switch (normalized) {
  2507. case 'faulted':
  2508. return 'err';
  2509. case 'muted':
  2510. case 'degraded':
  2511. case 'prebuffering':
  2512. case 'arming':
  2513. case 'stopping':
  2514. case 'idle':
  2515. case 'unknown':
  2516. return 'warn';
  2517. default:
  2518. return 'good';
  2519. }
  2520. }
  2521. function normalizeRuntimeState(stateName) {
  2522. const normalized = (typeof stateName === 'string' ? stateName.trim().toLowerCase() : '');
  2523. return normalized || 'idle';
  2524. }
  2525. function runtimeStateSeverity(stateName) {
  2526. const normalized = normalizeRuntimeState(stateName);
  2527. switch (normalized) {
  2528. case 'running':
  2529. return 'ok';
  2530. case 'degraded':
  2531. case 'muted':
  2532. return 'warn';
  2533. case 'faulted':
  2534. return 'err';
  2535. default:
  2536. return 'info';
  2537. }
  2538. }
  2539. function notifyRuntimeTransition(engine, pushHistory = true) {
  2540. if (!engine) return;
  2541. const next = normalizeRuntimeState(engine.state);
  2542. const prev = state.lastRuntimeState;
  2543. state.lastRuntimeState = next;
  2544. if (!prev || prev === next) return;
  2545. const severity = runtimeStateSeverity(next);
  2546. if (pushHistory) {
  2547. pushTransitionHistory(prev, next, severity);
  2548. }
  2549. const message = `Runtime ${prev.toUpperCase()} → ${next.toUpperCase()}`;
  2550. const logLevel = severity === 'err' ? 'err' : (severity === 'warn' ? 'warn' : 'info');
  2551. toast(message, severity);
  2552. log(message, logLevel);
  2553. }
  2554. function updateHealth(engine, driver, audioStream) {
  2555. engine = engine || {};
  2556. driver = driver || {};
  2557. updateText('health-http', state.server.configOk ? 'OK' : 'OFFLINE');
  2558. $('health-http').className = 'val ' + (state.server.configOk ? 'good' : 'err');
  2559. let runtimeLabel = 'WAITING';
  2560. let runtimeClass = 'warn';
  2561. if (state.server.runtimeOk) {
  2562. const engineStateName = String(engine.state || 'unknown');
  2563. runtimeLabel = engineStateName.toUpperCase();
  2564. runtimeClass = runtimeStateClass(engineStateName);
  2565. }
  2566. updateText('health-runtime', runtimeLabel);
  2567. $('health-runtime').className = 'val ' + runtimeClass;
  2568. const durationSeconds = Number(engine.runtimeStateDurationSeconds);
  2569. const durationLabel = Number.isFinite(durationSeconds) && durationSeconds > 0 ? fmtTime(durationSeconds) : '--';
  2570. updateText('health-state-age', durationLabel);
  2571. const stateAgeEl = $('health-state-age');
  2572. if (stateAgeEl) {
  2573. stateAgeEl.className = 'val ' + runtimeClass;
  2574. }
  2575. const runtimeIndicator = engine.runtimeIndicator;
  2576. const indicatorLabels = {
  2577. normal: 'Normal',
  2578. degraded: 'Degraded',
  2579. queueCritical: 'Queue critical',
  2580. };
  2581. const indicatorText = indicatorLabels[runtimeIndicator] || (runtimeIndicator ? runtimeIndicator : '--');
  2582. let indicatorSeverity = '';
  2583. if (runtimeIndicator === 'queueCritical') indicatorSeverity = 'err';
  2584. else if (runtimeIndicator === 'degraded') indicatorSeverity = 'warn';
  2585. else if (runtimeIndicator === 'normal') indicatorSeverity = 'good';
  2586. const indicatorEl = $('health-indicator');
  2587. if (indicatorEl) {
  2588. indicatorEl.className = 'val' + (indicatorSeverity ? ' ' + indicatorSeverity : '');
  2589. }
  2590. updateText('health-indicator', indicatorText);
  2591. const runtimeAlertRaw = (engine.runtimeAlert || '').trim();
  2592. const hasAlert = !!runtimeAlertRaw;
  2593. const alertEl = $('health-alert');
  2594. if (alertEl) {
  2595. alertEl.className = 'val ' + (hasAlert ? 'warn' : 'good');
  2596. }
  2597. updateText('health-alert', hasAlert ? runtimeAlertRaw : 'None');
  2598. let audioLabel = 'N/A';
  2599. let audioClass = 'val';
  2600. if (audioStream) {
  2601. const fill = typeof audioStream.buffered === 'number' ? audioStream.buffered : null;
  2602. audioLabel = fill == null ? 'Unknown' : fmtPercent(fill);
  2603. if (fill == null) audioClass = 'val';
  2604. else if (fill < 0.05) audioClass = 'val err';
  2605. else if (fill < 0.2) audioClass = 'val warn';
  2606. else audioClass = 'val good';
  2607. }
  2608. updateText('health-audio', audioLabel);
  2609. $('health-audio').className = audioClass;
  2610. const bufferedDurationSeconds = Number(audioStream?.bufferedDurationSeconds);
  2611. updateText('health-buffer-duration', fmtDurationSeconds(bufferedDurationSeconds));
  2612. const highWatermarkRaw = audioStream?.highWatermark;
  2613. const highWatermarkFrames = Number.isFinite(Number(highWatermarkRaw)) ? Number(highWatermarkRaw) : null;
  2614. const highWatermarkDurationRaw = audioStream?.highWatermarkDurationSeconds;
  2615. const highWatermarkDuration = Number.isFinite(Number(highWatermarkDurationRaw)) ? Number(highWatermarkDurationRaw) : null;
  2616. let highWatermarkLabel = '--';
  2617. if (highWatermarkDuration !== null) {
  2618. highWatermarkLabel = fmtDurationSeconds(highWatermarkDuration);
  2619. if (highWatermarkFrames !== null) {
  2620. highWatermarkLabel += ` (${highWatermarkFrames} frames)`;
  2621. }
  2622. } else if (highWatermarkFrames !== null) {
  2623. highWatermarkLabel = `${highWatermarkFrames} frames`;
  2624. }
  2625. updateText('health-buffer-highwater', highWatermarkLabel);
  2626. const queueFill = Number(engine.queue?.fillLevel);
  2627. const queueHealthRaw = String(engine.queue?.health || '').toLowerCase();
  2628. const queueHealthLabel = queueHealthRaw ? queueHealthRaw[0].toUpperCase() + queueHealthRaw.slice(1) : '';
  2629. let queueFillLabel = '--';
  2630. if (Number.isFinite(queueFill)) {
  2631. queueFillLabel = fmtPercent(queueFill);
  2632. if (queueHealthLabel) queueFillLabel += ` · ${queueHealthLabel}`;
  2633. } else if (queueHealthLabel) {
  2634. queueFillLabel = queueHealthLabel;
  2635. }
  2636. updateText('health-queue-fill', queueFillLabel);
  2637. const queueFillEl = $('health-queue-fill');
  2638. if (queueFillEl) {
  2639. let queueFillClass = 'good';
  2640. if (queueHealthRaw === 'critical') queueFillClass = 'err';
  2641. else if (queueHealthRaw === 'low') queueFillClass = 'warn';
  2642. queueFillEl.className = 'val ' + queueFillClass;
  2643. }
  2644. const streakEl = $('health-underrun-streak');
  2645. if (streakEl) {
  2646. const streakRaw = driver?.underrunStreak;
  2647. const streakMaxRaw = driver?.maxUnderrunStreak;
  2648. const streakCurrent = Number.isFinite(Number(streakRaw)) ? Number(streakRaw) : null;
  2649. const streakMax = Number.isFinite(Number(streakMaxRaw)) ? Number(streakMaxRaw) : null;
  2650. let streakLabel = '--';
  2651. if (streakCurrent != null) {
  2652. streakLabel = String(streakCurrent);
  2653. if (streakMax != null) {
  2654. streakLabel += ` (max ${streakMax})`;
  2655. }
  2656. } else if (streakMax != null) {
  2657. streakLabel = `Max ${streakMax}`;
  2658. }
  2659. let streakSeverity = '';
  2660. if (streakCurrent != null || streakMax != null) {
  2661. const highestStreak = Math.max(
  2662. streakCurrent != null ? streakCurrent : 0,
  2663. streakMax != null ? streakMax : 0
  2664. );
  2665. if (highestStreak >= 6) streakSeverity = ' err';
  2666. else if (highestStreak > 0) streakSeverity = ' warn';
  2667. else streakSeverity = ' good';
  2668. }
  2669. streakEl.textContent = streakLabel;
  2670. streakEl.className = 'val' + streakSeverity;
  2671. }
  2672. const last = Math.max(state.server.lastConfigAt || 0, state.server.lastRuntimeAt || 0);
  2673. updateText('health-last', ageString(last));
  2674. const transitionsAvailable = engine.degradedTransitions != null || engine.mutedTransitions != null || engine.faultedTransitions != null;
  2675. const transitionsText = transitionsAvailable ? `${Number(engine.degradedTransitions ?? 0)} / ${Number(engine.mutedTransitions ?? 0)} / ${Number(engine.faultedTransitions ?? 0)}` : '--';
  2676. updateText('health-transitions', transitionsText);
  2677. const faultCountValue = engine.faultCount != null ? Number(engine.faultCount) : 0;
  2678. const hasFaultCount = engine.faultCount != null;
  2679. updateText('health-fault-count', hasFaultCount ? String(faultCountValue) : '--');
  2680. const faultCountEl = $('health-fault-count');
  2681. if (faultCountEl) {
  2682. faultCountEl.className = 'val' + (hasFaultCount ? (faultCountValue > 0 ? ' warn' : ' good') : '');
  2683. }
  2684. const lastFaultEl = $('health-last-fault');
  2685. const lastFault = engine.lastFault;
  2686. if (lastFaultEl) {
  2687. if (lastFault) {
  2688. const severity = String(lastFault.severity || '').toLowerCase();
  2689. const severityClass = severity === 'faulted' ? 'err' : 'warn';
  2690. const severityLabel = (lastFault.severity || 'Fault').toUpperCase();
  2691. const reasonLabel = lastFault.reason ? ` ${lastFault.reason}` : '';
  2692. const messageLabel = lastFault.message ? ` - ${lastFault.message}` : '';
  2693. let whenLabel = '';
  2694. if (lastFault.time) {
  2695. const parsed = new Date(lastFault.time);
  2696. if (!Number.isNaN(parsed.getTime())) {
  2697. whenLabel = ` @ ${parsed.toLocaleTimeString()}`;
  2698. }
  2699. }
  2700. const title = `${severityLabel}${reasonLabel}`;
  2701. updateText('health-last-fault', `${title}${messageLabel}${whenLabel}`);
  2702. lastFaultEl.className = 'val ' + severityClass;
  2703. } else {
  2704. lastFaultEl.className = 'val good';
  2705. updateText('health-last-fault', 'None');
  2706. }
  2707. }
  2708. }
  2709. function updateControlAudit(audit) {
  2710. const entries = [
  2711. { key: 'methodNotAllowed', id: 'audit-methodNotAllowed' },
  2712. { key: 'unsupportedMediaType', id: 'audit-unsupportedMediaType' },
  2713. { key: 'bodyTooLarge', id: 'audit-bodyTooLarge' },
  2714. { key: 'unexpectedBody', id: 'audit-unexpectedBody' },
  2715. ];
  2716. let total = 0;
  2717. let hasData = false;
  2718. entries.forEach(({ key, id }) => {
  2719. const raw = audit && typeof audit[key] !== 'undefined' ? Number(audit[key]) : NaN;
  2720. const value = Number.isFinite(raw) ? raw : null;
  2721. if (value != null) {
  2722. hasData = true;
  2723. total += value;
  2724. }
  2725. setAuditValue(id, value);
  2726. });
  2727. setAuditValue('audit-total', hasData ? total : null);
  2728. }
  2729. function setAuditValue(id, count) {
  2730. const el = $(id);
  2731. if (!el) return;
  2732. if (count == null) {
  2733. el.textContent = '--';
  2734. el.className = 'val';
  2735. return;
  2736. }
  2737. el.textContent = String(count);
  2738. el.className = 'val ' + (count > 0 ? 'warn' : 'good');
  2739. }
  2740. function updateFaultHistory(engine) {
  2741. const container = $('fault-history');
  2742. if (!container) return;
  2743. const history = Array.isArray(engine?.faultHistory) ? engine.faultHistory : [];
  2744. if (!history.length) {
  2745. container.innerHTML = '<div class="fault-history-empty">No faults recorded yet.</div>';
  2746. return;
  2747. }
  2748. const rows = history.slice().reverse().map((entry) => {
  2749. const when = entry?.time ? new Date(entry.time) : null;
  2750. const timeLabel = when && !Number.isNaN(when.getTime()) ? when.toLocaleTimeString() : '--:--';
  2751. const severity = String(entry?.severity || 'warn').toLowerCase();
  2752. const severityLabel = String(entry?.severity || 'Fault').toUpperCase();
  2753. const reasonLabel = entry?.reason ? ` ${entry.reason}` : '';
  2754. const messageLabel = entry?.message ? ` · ${entry.message}` : '';
  2755. return `<div class="fault-history-entry ${severity}"><span class="fault-history-time">${timeLabel}</span><span class="fault-history-desc">${severityLabel}${reasonLabel}${messageLabel}</span></div>`;
  2756. });
  2757. container.innerHTML = rows.join('');
  2758. }
  2759. function updateResetHint(engine) {
  2760. const hint = $('reset-hint');
  2761. if (!hint) return;
  2762. const stateName = String(engine?.state || '').toLowerCase();
  2763. let text = 'Manual fault reset drops runtime to DEGRADED while the queue recovers.';
  2764. if (stateName === 'faulted') {
  2765. text = 'Faulted: reset moves runtime back to DEGRADED until the queue settles.';
  2766. } else if (stateName === 'muted' || stateName === 'degraded') {
  2767. text = 'Reset Fault keeps the runtime in DEGRADED so the queue can recover before running again.';
  2768. }
  2769. const durationSeconds = Number(engine?.runtimeStateDurationSeconds);
  2770. const durationLabel = Number.isFinite(durationSeconds) && durationSeconds > 0 ? fmtTime(durationSeconds) : null;
  2771. const ageHint = durationLabel ? ` State age ${durationLabel}.` : '';
  2772. hint.textContent = text + ageHint;
  2773. }
  2774. function updateMeters(engine, driver, audioStream) {
  2775. if (audioStream && typeof audioStream.buffered === 'number') {
  2776. const ratio = Math.max(0, Math.min(1, audioStream.buffered));
  2777. const mode = ratio < 0.05 ? 'err' : ratio < 0.2 ? 'warn' : 'good';
  2778. setMeter('meter-audio-fill', 'meter-audio-text', ratio, fmtPercent(ratio), mode);
  2779. } else {
  2780. setMeter('meter-audio-fill', 'meter-audio-text', 0, 'N/A', 'warn');
  2781. }
  2782. const underruns = Number(engine.underruns ?? driver.underruns ?? 0);
  2783. const healthRatio = underruns <= 0 ? 1 : Math.max(0, 1 - Math.min(underruns, 10) / 10);
  2784. const healthMode = underruns === 0 ? 'good' : underruns < 3 ? 'warn' : 'err';
  2785. setMeter('meter-stream-fill', 'meter-stream-text', healthRatio, underruns === 0 ? 'Clean' : `${underruns} underrun${underruns === 1 ? '' : 's'}`, healthMode);
  2786. const txState = String(engine.state || 'idle').toLowerCase();
  2787. const activeRatio = txState === 'running' ? 1 : state.txBusy ? 0.55 : 0.08;
  2788. const activeMode = txState === 'running' ? 'good' : state.txBusy ? 'warn' : 'err';
  2789. const activeText = txState === 'running' ? 'Live' : state.txBusy ? 'Working' : 'Idle';
  2790. setMeter('meter-tx-fill', 'meter-tx-text', activeRatio, activeText, activeMode);
  2791. }
  2792. function toast(msg, type = 'info') {
  2793. const t = $('toast');
  2794. t.textContent = msg;
  2795. t.className = 'toast ' + type + ' show';
  2796. clearTimeout(t._timer);
  2797. t._timer = setTimeout(() => t.classList.remove('show'), 2600);
  2798. }
  2799. function log(message, type = '') {
  2800. const logEl = $('log');
  2801. const empty = logEl.querySelector('.empty-log');
  2802. if (empty) empty.remove();
  2803. const row = document.createElement('div');
  2804. row.className = 'entry ' + type;
  2805. row.textContent = `${new Date().toLocaleTimeString()} ${message}`;
  2806. logEl.appendChild(row);
  2807. while (logEl.children.length > 250) logEl.removeChild(logEl.firstChild);
  2808. logEl.scrollTop = logEl.scrollHeight;
  2809. }
  2810. function bindTabs() {
  2811. const buttons = Array.from(document.querySelectorAll('.tab-btn[data-tab]'));
  2812. const panels = Array.from(document.querySelectorAll('.tab-panel[data-tab-panel]'));
  2813. const activate = (tab) => {
  2814. buttons.forEach((btn) => btn.classList.toggle('active', btn.dataset.tab === tab));
  2815. panels.forEach((panel) => panel.classList.toggle('active', panel.dataset.tabPanel === tab));
  2816. };
  2817. buttons.forEach((btn) => btn.addEventListener('click', () => activate(btn.dataset.tab)));
  2818. }
  2819. function bindPanels() {
  2820. document.querySelectorAll('[data-panel]').forEach((head) => {
  2821. head.addEventListener('click', () => {
  2822. head.classList.toggle('collapsed');
  2823. head.nextElementSibling.classList.toggle('collapsed');
  2824. });
  2825. });
  2826. }
  2827. function bindInputs() {
  2828. $('freq-slider').addEventListener('input', (e) => {
  2829. const value = Number(e.target.value);
  2830. setDirty('frequencyMHz', value);
  2831. });
  2832. $('freq-num').addEventListener('input', (e) => {
  2833. const value = Number(e.target.value);
  2834. if (!Number.isNaN(value)) setDirty('frequencyMHz', value);
  2835. else {
  2836. state.errors.frequencyMHz = 'Enter a valid number.';
  2837. render();
  2838. }
  2839. });
  2840. $('rds-ps').addEventListener('input', (e) => setDirty('ps', e.target.value.toUpperCase().slice(0, 8)));
  2841. $('rds-rt').addEventListener('input', (e) => setDirty('radioText', e.target.value.slice(0, 64)));
  2842. $('freq-apply').addEventListener('click', () => applySection('freq'));
  2843. $('rds-apply').addEventListener('click', () => applySection('rds'));
  2844. $('freq-reset').addEventListener('click', () => resetSection('freq'));
  2845. $('rds-reset').addEventListener('click', () => resetSection('rds'));
  2846. $('ingest-save-reload').addEventListener('click', () => saveIngestConfig());
  2847. $('ingest-reset').addEventListener('click', () => resetIngestDraft());
  2848. document.querySelectorAll('[data-ingest-path]').forEach((el) => {
  2849. const path = el.dataset.ingestPath;
  2850. const tag = (el.tagName || '').toLowerCase();
  2851. const type = String(el.getAttribute('type') || '').toLowerCase();
  2852. const isCheckbox = type === 'checkbox';
  2853. const isNumber = type === 'number';
  2854. const evt = isCheckbox ? 'change' : 'input';
  2855. el.addEventListener(evt, () => {
  2856. if (isCheckbox) {
  2857. setIngestField(path, !!el.checked);
  2858. return;
  2859. }
  2860. if (isNumber) {
  2861. const n = Number(el.value);
  2862. setIngestField(path, Number.isFinite(n) ? Math.trunc(n) : 0);
  2863. return;
  2864. }
  2865. if (tag === 'select') {
  2866. setIngestField(path, String(el.value || ''));
  2867. return;
  2868. }
  2869. setIngestField(path, String(el.value || ''));
  2870. });
  2871. });
  2872. $('btn-start').addEventListener('click', () => txAction('start'));
  2873. $('btn-stop').addEventListener('click', () => txAction('stop'));
  2874. $('danger-stop').addEventListener('click', () => txAction('stop'));
  2875. $('btn-refresh').addEventListener('click', manualRefresh);
  2876. $('danger-refresh').addEventListener('click', manualRefresh);
  2877. $('danger-reset-fault').addEventListener('click', () => resetFaultAction());
  2878. document.querySelectorAll('.toggle[data-toggle]').forEach((toggle) => {
  2879. const key = toggle.dataset.toggle;
  2880. const handler = () => setToggle(key, !serverValue(key));
  2881. toggle.addEventListener('click', handler);
  2882. toggle.addEventListener('keydown', (e) => {
  2883. if (e.key === 'Enter' || e.key === ' ') {
  2884. e.preventDefault();
  2885. handler();
  2886. }
  2887. });
  2888. });
  2889. document.querySelectorAll('[data-freq-preset]').forEach((btn) => {
  2890. btn.addEventListener('click', () => {
  2891. const value = Number(btn.dataset.freqPreset);
  2892. setDirty('frequencyMHz', value);
  2893. toast(`Frequency preset ${value.toFixed(1)} MHz loaded`, 'info');
  2894. });
  2895. });
  2896. document.querySelectorAll('[data-rds-ps]').forEach((btn) => {
  2897. btn.addEventListener('click', () => {
  2898. setDirty('ps', btn.dataset.rdsPs || '');
  2899. setDirty('radioText', btn.dataset.rdsRt || '');
  2900. toast('RDS preset loaded', 'info');
  2901. });
  2902. });
  2903. $('btn-clear-log').addEventListener('click', () => {
  2904. $('log').innerHTML = '<div class="empty-log">No events yet.</div>';
  2905. toast('Log cleared', 'info');
  2906. });
  2907. }
  2908. async function manualRefresh() {
  2909. beginRequest();
  2910. try {
  2911. await Promise.allSettled([
  2912. loadConfig({ silent: true }),
  2913. loadRuntime({ silent: true }),
  2914. ]);
  2915. toast('Refreshed', 'info');
  2916. log('Manual refresh completed', 'info');
  2917. } finally {
  2918. endRequest();
  2919. }
  2920. }
  2921. function startPollers() {
  2922. if (state.pollersStarted) return;
  2923. state.pollersStarted = true;
  2924. setInterval(() => { loadRuntime({ silent: true }); }, runtimePollMs);
  2925. setInterval(() => { loadConfig({ silent: true }); }, configPollMs);
  2926. }
  2927. function bindResponsiveBehavior() {
  2928. const listener = () => {
  2929. if (!mobileMq.matches) {
  2930. state.mobilePanelsApplied = false;
  2931. return;
  2932. }
  2933. applyMobilePanelDefaults();
  2934. };
  2935. if (mobileMq.addEventListener) mobileMq.addEventListener('change', listener);
  2936. else mobileMq.addListener(listener);
  2937. }
  2938. function isTypingContext(target) {
  2939. if (!target) return false;
  2940. const tag = (target.tagName || '').toLowerCase();
  2941. return tag === 'input' || tag === 'textarea' || target.isContentEditable;
  2942. }
  2943. function bindKeyboardShortcuts() {
  2944. window.addEventListener('keydown', (e) => {
  2945. if (isTypingContext(e.target)) {
  2946. if (e.key !== 'Enter') return;
  2947. if (state.dirty.has('frequencyMHz')) {
  2948. e.preventDefault();
  2949. applySection('freq');
  2950. } else if (isDirtySection('rds')) {
  2951. e.preventDefault();
  2952. applySection('rds');
  2953. }
  2954. return;
  2955. }
  2956. if (e.key === 't' && !e.shiftKey) {
  2957. e.preventDefault();
  2958. txAction('start');
  2959. return;
  2960. }
  2961. if ((e.key === 'T') || (e.key === 't' && e.shiftKey)) {
  2962. e.preventDefault();
  2963. txAction('stop');
  2964. return;
  2965. }
  2966. if (e.key.toLowerCase() === 'r') {
  2967. e.preventDefault();
  2968. manualRefresh();
  2969. return;
  2970. }
  2971. if (e.key === '[') {
  2972. e.preventDefault();
  2973. cycleFreqPreset(-1);
  2974. return;
  2975. }
  2976. if (e.key === ']') {
  2977. e.preventDefault();
  2978. cycleFreqPreset(1);
  2979. return;
  2980. }
  2981. if (e.key === 'Enter') {
  2982. e.preventDefault();
  2983. if (state.dirty.has('frequencyMHz')) applySection('freq');
  2984. else if (isDirtySection('rds')) applySection('rds');
  2985. }
  2986. });
  2987. }
  2988. async function init() {
  2989. bindTabs();
  2990. bindPanels();
  2991. bindInputs();
  2992. bindResponsiveBehavior();
  2993. bindKeyboardShortcuts();
  2994. render();
  2995. log('fm-rds-tx control UI booting', 'info');
  2996. await Promise.allSettled([
  2997. loadConfig({ silent: false }),
  2998. loadRuntime({ silent: true }),
  2999. ]);
  3000. render();
  3001. startPollers();
  3002. log('Polling active: runtime 1s, config 8s', 'ok');
  3003. log('Keyboard shortcuts armed', 'info');
  3004. }
  3005. init();
  3006. </script>
  3007. </body>
  3008. </html>