Implemented and fixed many issues
This commit is contained in:
@@ -9,6 +9,9 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"marte2/common/wshub"
|
||||
)
|
||||
@@ -21,20 +24,56 @@ var staticFiles embed.FS
|
||||
// multiFlag allows a flag to be repeated: --source a --source b
|
||||
type multiFlag []string
|
||||
|
||||
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
|
||||
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
|
||||
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
|
||||
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
|
||||
|
||||
// defaultHistoryDir is where samples are archived unless -history-dir says
|
||||
// otherwise. History is on by default because it is what holds a trigger
|
||||
// capture at full resolution: the in-memory rings roll past a captured window
|
||||
// within seconds of it being taken, and a zoom after that has nothing but the
|
||||
// capture's own decimated copy to draw. Per-signal files are bounded by
|
||||
// -history-max-mpts, so the default costs a fixed amount of space.
|
||||
func defaultHistoryDir() string {
|
||||
return filepath.Join(os.TempDir(), "udpstreamer-history")
|
||||
}
|
||||
|
||||
func main() {
|
||||
var sourceArgs multiFlag
|
||||
flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`)
|
||||
sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)")
|
||||
listenAddr := flag.String("addr", ":8080", "HTTP listen address")
|
||||
histDir := flag.String("history-dir", defaultHistoryDir(), "Directory for disk-backed signal history (empty disables it)")
|
||||
histWindow := flag.Float64("history-window-sec", 0, "Timespan the history files hold before any client says what it displays (0 keeps the 10 s default); the hub re-sizes them to the live or trigger window afterwards")
|
||||
histDecim := flag.Int("history-decimation", 1, "Keep every Nth sample in the history files")
|
||||
histFlush := flag.Int("history-flush-sec", 5, "Seconds between history header flushes")
|
||||
histMinFree := flag.Int("history-min-free-mb", 500, "Pause history writing below this much free disk (negative disables the check)")
|
||||
histMaxMPts := flag.Float64("history-max-mpts", 0, "Per-signal history budget in millions of points, also settable in the web UI (0 keeps the 16 MPts / 256 MB default)")
|
||||
ringMPts := flag.Float64("ring-mpts", 0, "Per-signal in-memory buffer in millions of points (0 keeps the 10 MPts / 160 MB default)")
|
||||
flag.Parse()
|
||||
|
||||
hub := wshub.NewHub()
|
||||
// The budget bounds memory, not the window: a window too long to hold at the
|
||||
// source rate is buffered as min/max pairs rather than truncated to the tail.
|
||||
hub.SetRingBudget(int(*ringMPts * 1e6))
|
||||
sm := wshub.NewSourceManager(hub, *sourcesFile)
|
||||
hub.SetSourceManager(sm)
|
||||
|
||||
if err := hub.EnableHistory(wshub.HistoryConfig{
|
||||
Directory: *histDir,
|
||||
WindowSec: *histWindow,
|
||||
Decimation: *histDecim,
|
||||
FlushIntervalSec: *histFlush,
|
||||
MinDiskFreeMB: *histMinFree,
|
||||
MaxPointsPerSignal: int(*histMaxMPts * 1e6),
|
||||
}); err != nil {
|
||||
log.Fatalf("history: %v", err)
|
||||
}
|
||||
if *histDir == "" {
|
||||
log.Print("history disabled: zooming into a trigger capture will fall back " +
|
||||
"to the capture's own decimated copy once the rings roll past it")
|
||||
} else {
|
||||
log.Printf("history: %s", *histDir)
|
||||
}
|
||||
go hub.Run()
|
||||
|
||||
// Load sources from file first (if specified), then add any CLI --source flags.
|
||||
@@ -60,7 +99,21 @@ func main() {
|
||||
})
|
||||
|
||||
log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion)
|
||||
if err := http.ListenAndServe(*listenAddr, nil); err != nil {
|
||||
|
||||
// Serve in the background so Ctrl-C can flush the history files: the
|
||||
// samples written since the last periodic flush are on disk but are not
|
||||
// yet accounted for in the file headers, so exiting outright loses them.
|
||||
srvErr := make(chan error, 1)
|
||||
go func() { srvErr <- http.ListenAndServe(*listenAddr, nil) }()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case err := <-srvErr:
|
||||
hub.CloseHistory()
|
||||
log.Fatalf("http: %v", err)
|
||||
case s := <-sig:
|
||||
log.Printf("received %s, flushing history", s)
|
||||
hub.CloseHistory()
|
||||
}
|
||||
}
|
||||
|
||||
+794
-262
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
// Min/max (peak-envelope) decimation — O(n). Runs off-main-thread to avoid
|
||||
// blocking the render loop.
|
||||
//
|
||||
// The range is split into threshold/2 equal buckets and each contributes its
|
||||
// smallest and largest sample, in the order the two occurred — the way an
|
||||
// oscilloscope draws a trace it cannot show pixel-for-pixel.
|
||||
//
|
||||
// This replaced LTTB, which picks the sample forming the largest triangle with
|
||||
// its neighbours: a plausible-looking shape, but it silently drops a one-sample
|
||||
// spike whenever a smoother neighbour scores higher — exactly the sample worth
|
||||
// looking at. The envelope cannot drop it, because a spike is by definition its
|
||||
// bucket's min or max. Every output point is a real sample at its real
|
||||
// timestamp; nothing is interpolated or averaged.
|
||||
//
|
||||
// Kept identical to minMaxDecimate() in Common/Client/go/wshub/hub.go and to
|
||||
// decimate() in app.js, so a trace looks the same whichever thinned it.
|
||||
function decimate(t, v, threshold) {
|
||||
const len = t.length;
|
||||
if (len <= threshold || threshold < 4) {
|
||||
// Copy to new arrays so we can transfer them back without detaching the input.
|
||||
return { t: new Float64Array(t), v: new Float64Array(v) };
|
||||
}
|
||||
const buckets = threshold >> 1;
|
||||
const outT = new Float64Array(threshold);
|
||||
const outV = new Float64Array(threshold);
|
||||
let n = 0;
|
||||
for (let b = 0; b < buckets; b++) {
|
||||
const lo = Math.floor(b * len / buckets);
|
||||
const hi = (b === buckets - 1) ? len : Math.floor((b + 1) * len / buckets);
|
||||
if (lo >= hi) continue;
|
||||
let iMin = lo, iMax = lo;
|
||||
for (let j = lo + 1; j < hi; j++) {
|
||||
if (v[j] < v[iMin]) iMin = j;
|
||||
if (v[j] > v[iMax]) iMax = j;
|
||||
}
|
||||
// Emit in time order so the result plots as one ascending trace.
|
||||
if (iMin > iMax) { const s = iMin; iMin = iMax; iMax = s; }
|
||||
outT[n] = t[iMin]; outV[n] = v[iMin]; n++;
|
||||
// A bucket whose samples are all equal has one extreme, not two.
|
||||
if (iMax !== iMin) { outT[n] = t[iMax]; outV[n] = v[iMax]; n++; }
|
||||
}
|
||||
// slice() so the transferred buffers are exactly the used length.
|
||||
return { t: outT.slice(0, n), v: outV.slice(0, n) };
|
||||
}
|
||||
|
||||
self.onmessage = function({ data: { id, t, v, threshold } }) {
|
||||
const result = decimate(t, v, threshold);
|
||||
// Transfer the output buffers back to the main thread zero-copy.
|
||||
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
|
||||
};
|
||||
@@ -30,11 +30,14 @@
|
||||
</div>
|
||||
<span class="ctrl-label" id="lbl-window">Window:</span>
|
||||
<select id="window-select" class="ctrl-select">
|
||||
<option value="1">1 s</option><option value="5" selected>5 s</option>
|
||||
<option value="10">10 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option>
|
||||
<option value="1">1 s</option><option value="2">2 s</option>
|
||||
<option value="5" selected>5 s</option><option value="10">10 s</option>
|
||||
<option value="15">15 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option><option value="120">2 min</option>
|
||||
<option value="300">5 min</option><option value="600">10 min</option>
|
||||
</select>
|
||||
<button id="btn-cursor" class="ctrl-btn">Cursors</button>
|
||||
<button id="btn-cursor-reset" class="ctrl-btn" style="display:none" title="Bring cursors A/B back into the visible window">↔ Reset</button>
|
||||
<button id="btn-ruler" class="ctrl-btn" title="Horizontal value rulers">Rulers</button>
|
||||
<button id="btn-zoom-back" class="ctrl-btn" style="display:none">← Back</button>
|
||||
<button id="btn-zoom-fit" class="ctrl-btn">Fit</button>
|
||||
@@ -43,7 +46,8 @@
|
||||
<button id="btn-trigger" class="ctrl-btn">⚡ Trigger</button>
|
||||
<button id="btn-pause-global" class="ctrl-btn">⏸ Pause</button>
|
||||
<label class="ctrl-check" title="Snap jittery inter-frame timestamps to ideal spacing (eliminates overlaps/gaps from software-dispatch jitter)">
|
||||
<input type="checkbox" id="cb-monotonic"> Sync TS
|
||||
<input type="checkbox" id="cb-monotonic">
|
||||
Sync TS
|
||||
</label>
|
||||
</div>
|
||||
<!-- ── Trigger bar ───────────────────────────────────────────── -->
|
||||
@@ -70,10 +74,19 @@
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Window</span>
|
||||
<select id="trig-window" class="trig-select">
|
||||
<option value="0.0001">100 μs</option><option value="0.001">1 ms</option>
|
||||
<option value="0.01">10 ms</option><option value="0.1">100 ms</option>
|
||||
<option value="0.5">500 ms</option><option value="1" selected>1 s</option>
|
||||
<option value="0.0001">100 μs</option><option value="0.0002">200 μs</option>
|
||||
<option value="0.0005">500 μs</option><option value="0.001">1 ms</option>
|
||||
<option value="0.002">2 ms</option><option value="0.005">5 ms</option>
|
||||
<option value="0.01">10 ms</option><option value="0.02">20 ms</option>
|
||||
<option value="0.05">50 ms</option><option value="0.1">100 ms</option>
|
||||
<option value="0.2">200 ms</option><option value="0.5">500 ms</option>
|
||||
<option value="1" selected>1 s</option><option value="2">2 s</option>
|
||||
<option value="5">5 s</option><option value="10">10 s</option>
|
||||
<option value="20">20 s</option><option value="30">30 s</option>
|
||||
<option value="60">60 s</option>
|
||||
<option value="120">2 m</option>
|
||||
<option value="300">5 m</option>
|
||||
<option value="600">10 m</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
@@ -83,6 +96,11 @@
|
||||
<span class="trig-range-val" id="trig-pre-val">20%</span>
|
||||
</div>
|
||||
<div class="trig-sep"></div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label" title="Re-arm delay after a capture — prevents double triggering">Holdoff</span>
|
||||
<input id="trig-holdoff" class="trig-input" type="number" min="0" max="60" step="0.01" value="0.2">
|
||||
<span class="trig-label">s</span>
|
||||
</div>
|
||||
<div class="trig-group">
|
||||
<span class="trig-label">Mode</span>
|
||||
<select id="trig-mode" class="trig-select">
|
||||
@@ -124,10 +142,30 @@
|
||||
<span id="status-text">Disconnected</span>
|
||||
<span id="sb-tsage"></span>
|
||||
<button id="btn-stats" class="ctrl-btn" style="height:16px;padding:0 7px;font-size:10px;line-height:1">📊 Stats</button>
|
||||
<span id="history-badge" style="display:none;font-size:10px;color:#f9e2af;margin-left:8px"></span>
|
||||
<button id="history-badge" style="display:none" title="Disk history — click to set the per-signal budget"></button>
|
||||
</div>
|
||||
<span id="build-version"></span>
|
||||
</div>
|
||||
<!-- ── History budget popup ──────────────────────────────────── -->
|
||||
<div id="history-panel" style="display:none">
|
||||
<div class="ctx-menu-header">Disk history budget</div>
|
||||
<div class="ctx-row">
|
||||
<label>Budget</label>
|
||||
<input type="number" id="hist-budget" class="ctx-num" min="0.001" step="1">
|
||||
<span class="ctx-range-val">MPts/signal</span>
|
||||
</div>
|
||||
<div class="hist-note">
|
||||
The budget buys resolution, not duration: a signal too fast to store
|
||||
sample-for-sample is archived as a min/max envelope wide enough to fit,
|
||||
so the configured window is always covered.
|
||||
</div>
|
||||
<div id="hist-signal-res"></div>
|
||||
<div class="hist-note hist-warn">Applying re-creates the history files — archived data is lost.</div>
|
||||
<div class="ctx-row" style="margin:0;justify-content:flex-end">
|
||||
<button class="ctx-btn" id="btn-hist-cancel">Cancel</button>
|
||||
<button class="ctx-btn" id="btn-hist-apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="layout-menu"></div>
|
||||
<!-- ── Signal style context menu ─────────────────────────────── -->
|
||||
<div id="sig-ctx-menu" style="display:none">
|
||||
@@ -172,7 +210,8 @@
|
||||
</div>
|
||||
<!-- ── Array index picker (trigger signal) ──────────────────────── -->
|
||||
<div id="array-idx-picker" style="display:none">
|
||||
<div class="ctx-menu-header">Element index: <span id="aip-sig" class="ctx-menu-key"></span></div>
|
||||
<div class="ctx-menu-header">Element index:
|
||||
<span id="aip-sig" class="ctx-menu-key"></span></div>
|
||||
<div class="ctx-row">
|
||||
<label>Index</label>
|
||||
<input type="number" id="aip-idx" class="ctx-num" min="0" step="1" value="0">
|
||||
@@ -186,7 +225,8 @@
|
||||
<!-- ── VScale toolbar (moved into plot card when active) ─────────── -->
|
||||
<div id="vscale-menu" style="display:none">
|
||||
<div class="vstb-header">
|
||||
<span class="vstb-label">V-Scale: <span id="vscale-menu-key" class="ctx-menu-key"></span></span>
|
||||
<span class="vstb-label"><span id="vscale-menu-title">V-Scale</span>:
|
||||
<span id="vscale-menu-key" class="ctx-menu-key"></span></span>
|
||||
<div class="ctx-btns" id="vscale-mode-btns">
|
||||
<button class="ctx-btn active" data-mode="auto">Auto</button>
|
||||
<button class="ctx-btn" data-mode="range">Range</button>
|
||||
@@ -200,10 +240,6 @@
|
||||
<label class="vstb-lbl" title="Raw value at screen centre — unbounded, may lie outside the plotted range">Offset</label>
|
||||
<input type="number" id="vscale-offset" class="ctx-num" step="any" value="0">
|
||||
</div>
|
||||
<div id="vscale-pos-row" style="display:none;align-items:center;gap:4px">
|
||||
<label class="vstb-lbl">Pos</label>
|
||||
<input type="number" id="vscale-pos" class="ctx-num" step="0.1" value="0">
|
||||
</div>
|
||||
<div id="vscale-type-row" style="display:none;align-items:center;gap:4px">
|
||||
<label class="vstb-lbl">Type</label>
|
||||
<div class="ctx-btns" id="vscale-type-btns">
|
||||
@@ -213,8 +249,7 @@
|
||||
</div>
|
||||
<div class="vstb-sep"></div>
|
||||
<div id="vscale-cal-row" style="display:flex;align-items:center;gap:4px">
|
||||
<label class="vstb-lbl" id="vscale-cal-lbl"
|
||||
title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
|
||||
<label class="vstb-lbl" id="vscale-cal-lbl" title="Data calibration: value = raw × Scale + Offset. Applies to the plot, cursors, hover readout, CSV export and trigger threshold.">Cal</label>
|
||||
<label class="vstb-lbl">Scale</label>
|
||||
<input type="number" id="vscale-cal-scale" class="ctx-num ctx-num-sm" step="any" value="1">
|
||||
<label class="vstb-lbl">Offset</label>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
'use strict';
|
||||
// LTTB (Largest Triangle Three Buckets) decimation — O(n).
|
||||
// Runs off-main-thread to avoid blocking the render loop.
|
||||
function lttb(t, v, threshold) {
|
||||
const len = t.length;
|
||||
if (len <= threshold) {
|
||||
// Copy to new arrays so we can transfer them back without detaching the input.
|
||||
return { t: new Float64Array(t), v: new Float64Array(v) };
|
||||
}
|
||||
const outT = new Float64Array(threshold);
|
||||
const outV = new Float64Array(threshold);
|
||||
outT[0] = t[0]; outV[0] = v[0];
|
||||
outT[threshold - 1] = t[len - 1]; outV[threshold - 1] = v[len - 1];
|
||||
const every = (len - 2) / (threshold - 2);
|
||||
let a = 0;
|
||||
for (let i = 0; i < threshold - 2; i++) {
|
||||
const avgS = Math.floor((i + 1) * every) + 1;
|
||||
const avgE = Math.min(Math.floor((i + 2) * every) + 1, len);
|
||||
let avgT = 0, avgV = 0, n = 0;
|
||||
for (let j = avgS; j < avgE; j++) { avgT += t[j]; avgV += v[j]; n++; }
|
||||
if (n) { avgT /= n; avgV /= n; }
|
||||
const rS = Math.floor(i * every) + 1;
|
||||
const rE = Math.min(Math.floor((i + 1) * every) + 1, len);
|
||||
let maxA = -1, next = rS;
|
||||
const aT = t[a], aV = v[a];
|
||||
for (let j = rS; j < rE; j++) {
|
||||
const area = Math.abs((aT - avgT) * (v[j] - aV) - (aT - t[j]) * (avgV - aV));
|
||||
if (area > maxA) { maxA = area; next = j; }
|
||||
}
|
||||
outT[i + 1] = t[next]; outV[i + 1] = v[next]; a = next;
|
||||
}
|
||||
return { t: outT, v: outV };
|
||||
}
|
||||
|
||||
self.onmessage = function({ data: { id, t, v, threshold } }) {
|
||||
const result = lttb(t, v, threshold);
|
||||
// Transfer the output buffers back to the main thread zero-copy.
|
||||
self.postMessage({ id, t: result.t, v: result.v }, [result.t.buffer, result.v.buffer]);
|
||||
};
|
||||
@@ -141,10 +141,17 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
#trig-status-badge.armed { background:rgba(166,227,161,0.12); border-color:var(--green); color:var(--green); }
|
||||
#trig-status-badge.waiting { background:rgba(249,226,175,0.12); border-color:var(--yellow); color:var(--yellow); }
|
||||
#trig-status-badge.triggered { background:rgba(203,166,247,0.15); border-color:var(--mauve); color:var(--mauve); }
|
||||
#btn-trig-rearm, #btn-trig-stop {
|
||||
#btn-trig-force, #btn-trig-rearm, #btn-trig-stop {
|
||||
border:none; border-radius:5px;
|
||||
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer; display:none;
|
||||
padding:4px 12px; font-size:12px; font-weight:600; cursor:pointer;
|
||||
}
|
||||
#btn-trig-rearm, #btn-trig-stop { display:none; }
|
||||
#btn-trig-force {
|
||||
background:var(--surface0); color:var(--text);
|
||||
border:1px solid var(--surface1);
|
||||
transition:background var(--transition),border-color var(--transition),color var(--transition);
|
||||
}
|
||||
#btn-trig-force:hover { background:var(--surface1); border-color:var(--mauve); color:var(--mauve); }
|
||||
#btn-trig-rearm { background:var(--mauve); color:var(--crust); }
|
||||
#btn-trig-stop { background:var(--surface1); color:var(--yellow); border:1px solid var(--yellow); }
|
||||
#btn-trig-rearm:hover, #btn-trig-stop:hover { opacity:0.85; }
|
||||
@@ -192,23 +199,6 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
.sig-name { flex:1; font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sig-unit { font-size:11px; color:var(--subtext0); font-style:italic; }
|
||||
.type-badge { font-size:10px; background:var(--surface1); color:var(--subtext1); padding:1px 5px; border-radius:3px; white-space:nowrap; }
|
||||
.array-group {}
|
||||
.array-header {
|
||||
padding:6px 14px 6px 10px; cursor:pointer; border-radius:6px; margin:1px 6px;
|
||||
transition:background var(--transition); display:flex; align-items:center; gap:6px; user-select:none;
|
||||
}
|
||||
.array-header:hover { background:var(--surface0); }
|
||||
.array-arrow { font-size:10px; color:var(--subtext0); transition:transform var(--transition); display:inline-block; }
|
||||
.array-header.open .array-arrow { transform:rotate(90deg); }
|
||||
.array-children { display:none; padding-left:16px; }
|
||||
.array-header.open + .array-children { display:block; }
|
||||
.array-child {
|
||||
padding:4px 14px 4px 8px; cursor:grab; border-radius:6px; margin:1px 6px;
|
||||
transition:background var(--transition); display:flex; align-items:center; gap:8px;
|
||||
user-select:none; color:var(--subtext1); font-size:12px;
|
||||
}
|
||||
.array-child:hover { background:var(--surface0); }
|
||||
.array-child:active { cursor:grabbing; }
|
||||
|
||||
/* ── Main area ────────────────────────────────────────────────── */
|
||||
#main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
|
||||
@@ -324,6 +314,32 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
border:1px solid var(--mauve);
|
||||
}
|
||||
|
||||
/* ── History budget ───────────────────────────────────────────── */
|
||||
#history-badge {
|
||||
font-size:10px; color:var(--yellow); margin-left:8px; cursor:pointer;
|
||||
background:transparent; border:1px solid transparent; border-radius:4px;
|
||||
padding:1px 5px; white-space:nowrap;
|
||||
}
|
||||
#history-badge:hover { border-color:var(--yellow); background:rgba(249,226,175,0.10); }
|
||||
#history-panel {
|
||||
position:fixed; z-index:300;
|
||||
background:var(--mantle); border:1px solid var(--surface1); border-radius:var(--radius);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,0.6); padding:10px; width:290px;
|
||||
}
|
||||
.hist-note { font-size:10px; color:var(--overlay0); line-height:1.4; margin:6px 0; }
|
||||
.hist-warn { color:var(--peach); }
|
||||
#hist-signal-res {
|
||||
font-size:10px; font-family:monospace; color:var(--subtext0);
|
||||
max-height:120px; overflow-y:auto;
|
||||
border-top:1px solid var(--surface0); border-bottom:1px solid var(--surface0);
|
||||
padding:5px 0;
|
||||
}
|
||||
.hist-res-row { display:flex; justify-content:space-between; gap:8px; }
|
||||
.hist-res-row .hist-res-key {
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--subtext1);
|
||||
}
|
||||
.hist-res-row .hist-res-val { color:var(--mauve); flex-shrink:0; }
|
||||
|
||||
/* ── Signal style context menu ────────────────────────────────── */
|
||||
#sig-ctx-menu {
|
||||
position:fixed; z-index:300;
|
||||
|
||||
Reference in New Issue
Block a user