Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3185688f9 |
@@ -1,139 +0,0 @@
|
||||
# Safe CPU, memory, and kernel measurements for Odin
|
||||
|
||||
Research for [Establish safe CPU, memory, and kernel measurements](https://git.bongbetic.com/xavierk/odin/issues/2), part of [Find the way to Odin’s build-ready specification](https://git.bongbetic.com/xavierk/odin/issues/1).
|
||||
|
||||
**Accessed:** 25 September 2026. **Status:** recommendations for later decisions, not a selected workload suite. No benchmarks, stress tests, privilege changes, or host configuration changes were performed.
|
||||
|
||||
## Findings that shape the decision
|
||||
|
||||
Odin can reuse established workloads and Linux interfaces, but no single tool measures system usability, peak performance, and hardware health. Keep four outcomes distinct:
|
||||
|
||||
- **Performance measurement:** completed work per second or elapsed time under a specified workload.
|
||||
- **Responsiveness:** foreground request or wakeup latency, including its tail, under a specified competing load.
|
||||
- **Pressure observation:** time lost to CPU, memory, or I/O contention during that interval.
|
||||
- **Health finding:** a detected verification failure or reported hardware error, with the observation's coverage.
|
||||
|
||||
This separation follows the tools' actual contracts: sysbench benchmarks operations; schbench measures artificial requests and scheduling delays; PSI measures stalls; memtester checks memory contents. Stress-ng explicitly says it was **not intended as a precise benchmark suite**. Successful stress completion establishes only that the chosen work completed without detected failures under those conditions. [sysbench][sysbench] [schbench][schbench] [PSI][psi] [memtester][memtester-man] [stress-ng][stress-readme]
|
||||
|
||||
## Candidate comparison
|
||||
|
||||
Maintenance observations describe inspected releases or repository activity, not a guarantee of future support. GPL notices and third-party dependencies need checking again for the exact distributable artifacts. Perf is distributed in the Linux source tree, whose COPYING specifies GPL-2.0-only with the syscall exception and notes that other licenses may also apply; preserve the selected tool and dependency notices. [Linux COPYING][kernel-license]
|
||||
|
||||
| Candidate | Useful measurement and limitations | Controls, output, portability, maintenance |
|
||||
|---|---|---|
|
||||
| **sysbench CPU / memory** | CPU events are a small prime-search workload, not a general application-performance model. The memory test defaults to a **1 KiB block**; its `100G` default is cumulative transfer size, not RAM allocation. A default run therefore cannot stand for DRAM bandwidth. | Thread, event, duration, warmup and percentile controls; human-readable built-in reports require a pinned parser. Upstream advertises x86_64 and aarch64 packages. GPL-2.0-or-later. Latest published release inspected: 1.0.20, April 2020; repository push activity March 2025. Void still packages 1.0.20. [README][sysbench] [CPU source][sysbench-cpu] [memory source][sysbench-memory] [release][sysbench-release] [metadata][sysbench-meta] [Void][void-sysbench] |
|
||||
| **schbench** | Reports synthetic request latency, wakeup latency, and requests/second. Closer to responsiveness than peak throughput. Its server-inspired matrix workload deliberately penalizes preemption using per-CPU locks; this is not a desktop interaction model. | Runtime, worker/message counts, work size, request rate, affinity and JSON percentiles. Source has x86 and aarch64 paths; Linux pthread/futex interfaces, no normal root requirement found. Musl behavior remains unverified. GPLv2. Inspected upstream commit `6300b8f`, June 2025. [methodology][schbench] [source][schbench-source] |
|
||||
| **stress-ng** | Appropriate candidate for controlled background load and selected verification diagnostics. Bogo operations are unsuitable as Odin's cross-test score foundation. | Explicit byte/worker/time caps, verification, YAML and differentiated exit codes. Upstream documents musl builds and testing on ARM64/x86-64. GPL-2.0-or-later; release 0.22.01, September 2026. Void's recipe has explicit musl handling. [README][stress-readme] [manual][stress-man] [release][stress-release] [Void][void-stress] |
|
||||
| **STREAM** | Established sustainable memory-bandwidth kernels: Copy, Scale, Add, Triad. No memory-latency or comprehensive error-detection result. Each array must exceed cache requirements; a small cache-resident run is not a compliant STREAM result. | Portable C; multicore uses OpenMP and its runtime. Array size is a build parameter in reference 5.10. Text output and numerical validation. Reference source dates to 2013: stable method, not evidence of a modern portability test matrix. Custom license permits use/redistribution but imposes result-naming/run-rule conditions. [source and license][stream-source] [run rules][stream-rules] |
|
||||
| **lmbench `lat_mem_rd`** | Pointer-chain latency over sizes/strides exposes cache, memory and TLB behavior. Its manual acknowledges vulnerability to stride-sensitive prefetchers; do not present this as an architecture-independent “true RAM latency.” | Warmup, repetitions, bounded size, text pairs. GPLv2 COPYING inspected; Intel's repository is active but contains old documentation. Portability and selected-file licensing need validation before adoption; not a recommended mandatory dependency yet. [manual][lmbench-memory] [README][lmbench-readme] [COPYING][lmbench-license] [metadata][lmbench-meta] |
|
||||
| **cyclictest / perf** | Cyclictest measures timer wakeup latency; perf supplies diagnostic counters. Neither is a substitute for foreground application response. | Cyclictest offers duration, histogram and JSON; source is GPL-2.0-only, current rt-tests release 2.11. Its startup tests permission to enter SCHED_FIFO even when ordinary policy is selected. Perf depends on kernel/PMU access and can emit JSON. Treat both as optional diagnostic coverage. [cyclictest][cyclictest] [privilege check][rt-utils] [rt-tests release][rt-release] [perf][perf-stat] |
|
||||
| **memtester** | Online checking of allocated memory, not all installed RAM. Failures can involve memory, CPU, temperature or power; the result does not identify a replaceable DIMM by itself. | Byte size and finite iteration count; default iterations are infinite. Text plus exit-bit mask. Record actual allocation and locking, not only exit status. GPL-2.0-only. Version 4.7.1's December 2024 fix addresses stricter C23/GCC 15 compilation; Void recipe inspected still selects 4.6.0. [manual][memtester-man] [source][memtester-source] [changelog][memtester-changelog] [Void][void-memtester] |
|
||||
| **Memtest86+** | Offline, bootable diagnostics reach almost all memory without the resident OS. They cannot run as an ordinary in-terminal stage. | GPLv2. Stable v8.10, May 2026, lists x86, x86-64 and LoongArch64. Current main additionally lists AArch64 with UEFI boot. This is a **stable/development difference**, not certified aarch64 coverage. [stable README][memtest-stable] [development README][memtest-main] [release][memtest-release] |
|
||||
| **EDAC / rasdaemon** | Hardware-error telemetry, complementary to active tests. Availability depends on hardware, firmware, drivers and exposed events. | Read EDAC counters where accessible; optionally consume an existing rasdaemon history. Rasdaemon monitors kernel trace events and has database backends; its repository shows September 2026 activity and GPLv2 metadata. Starting it is a separate privileged monitoring action, not necessary to read available counters. [EDAC ABI][edac-abi] [rasdaemon][rasdaemon] [metadata][ras-meta] |
|
||||
|
||||
## Measuring usability under contention
|
||||
|
||||
**Recommendation:** evaluate paired idle and loaded foreground-request measurements as a first-class candidate. Run the same bounded foreground work alone, with a fixed CPU background load, and with separately controlled memory pressure. Preserve normal scheduling policy, work size, thread count, placement, requested arrival rate, actual throughput, sample count, and latency distribution. Report absolute latency and degradation relative to idle; a fast idle result can coexist with poor responsiveness under contention.
|
||||
|
||||
Existing tools can supply the observations. Schbench records both request and wakeup latency, supports a fixed request rate, and can coexist with an independently bounded stress-ng background worker. Sysbench's rate-limited engine is another candidate: its source adds measured queue time to event duration and reports queue length. These remain synthetic proxies for foreground work; neither measures keyboard-to-pixel delay, terminal rendering, browser interaction, or application launch by itself. Those need their own workload definitions. [schbench methodology][schbench] [schbench source][schbench-source] [sysbench queue][sysbench-core] [sysbench timer][sysbench-timer]
|
||||
|
||||
Schbench needs qualification before selection. Its README says warmup defaults to five seconds, while the inspected source defaults to zero and bypasses its warmup reset in request-rate mode. It uses `gettimeofday`, so clock adjustments can contaminate timing. Set options explicitly, retain the revision, validate timing conditions, and decide whether its deliberate preemption penalty fits Odin's goal. Do not adapt work size independently on every system and then compare the resulting latency as equal work. [source][schbench-source]
|
||||
|
||||
Cyclictest is a useful separate scheduler diagnostic. Its default behavior can hold `/dev/cpu_dma_latency` at zero and suppress deep idle states; `--default-system` avoids that tuning. The source's unconditional real-time privilege check prevents assuming that an ordinary-policy configuration is universally unprivileged. Its timer latency, especially under SCHED_FIFO, is a different measurement from a normal foreground application's response. [manual][cyclictest] [source][cyclictest-source] [privileges][rt-utils]
|
||||
|
||||
## Kernel telemetry and compatibility
|
||||
|
||||
**PSI:** Read system and, where available, workload-cgroup `cpu`, `memory`, and `io` pressure. `some` is time when at least some tasks are stalled; `full` is time when all non-idle tasks are stalled together. The cumulative `total` counter permits interval deltas; rolling 10/60/300-second averages can smear a short benchmark across adjacent phases. **System-wide CPU `full` is undefined and exposed as zero for compatibility**—zero there cannot mean perfect responsiveness. PSI exists in the inspected Linux 4.20 source, but requires `CONFIG_PSI` and may be disabled by default pending `psi=1`. Probe actual files and readability, not only kernel version. [PSI][psi] [4.20 documentation][psi-420] [Kconfig][kconfig]
|
||||
|
||||
**Capacity and pressure:** Record usable RAM, `MemAvailable`, swap capacity/usage, process or cgroup memory, major faults, and swap/reclaim activity where exposed. `MemAvailable` is an estimate of memory available without swapping, not an allocation guarantee. Swap occupancy alone does not establish current pressure. `/proc/stat` supplies CPU time and steal time, but kernel documentation explicitly warns that `iowait` is unreliable. Correlate these observations with latency and PSI; do not derive a definitive bottleneck from CPU utilization or a single counter. [proc documentation][proc]
|
||||
|
||||
**Cgroups:** Observe effective CPU affinity/cpuset, CPU quota, memory/swap limits and relevant ancestors. Host RAM and online CPU counts may exceed what the benchmark is allowed to use. Cgroup v2 `cpu.stat` records throttling; `memory.events` separates high-limit reclaim, OOM conditions and kills. Parse by key: the kernel explicitly allows new `memory.stat` entries in the middle. These interfaces are independent of a particular init system, but writable delegation and enabled controllers are not guaranteed on Void, deb, rpm, containers, or user sessions. [cgroup v2][cgroup]
|
||||
|
||||
**Perf:** Treat hardware counters as enrichment. `CONFIG_PERF_EVENTS`, CPU PMU support, virtualization, `perf_event_paranoid`, capabilities and distribution policy can limit access. Kernel documentation recommends `CAP_PERFMON` over broad `CAP_SYS_ADMIN`; Odin should describe missing access rather than lowering system security settings. Record event identity and time-running percentage when multiplexing occurs. PMU-specific cache and pipeline events are not universal normalized scores. Perf's manual also warns of overhead at short sampling intervals, particularly below 100 ms. [security][perf-security] [Kconfig][kconfig] [perf stat][perf-stat]
|
||||
|
||||
**Architecture/libc:** Proc/sysfs/cgroup interfaces offer the strongest common layer across x86_64/aarch64 and glibc/musl. Workload binaries still need distinct, verified artifacts and recorded toolchain flags. Upstream stress-ng explicitly documents musl; sysbench's advertised architectures and Void recipes are useful evidence, but none of the inspected material certifies Odin's whole four-way architecture/libc matrix. STREAM additionally needs a compatible OpenMP runtime; rt-tests has library dependencies. A package recipe proves availability intent, not successful operation. Missing checks should carry reasons such as unsupported, permission denied, unavailable dependency, or insufficient safe resources. [stress-ng][stress-readme] [sysbench][sysbench] [Void recipes][void-sysbench] [STREAM][stream-source] [rt-tests Makefile][rt-makefile]
|
||||
|
||||
## Resource safety and cancellation
|
||||
|
||||
The following is a proposed execution contract, with exact caps left to the safety decision:
|
||||
|
||||
1. Calculate a conservative working budget from current `MemAvailable`, effective cgroup/ancestor headroom, expected tool/runtime overhead, and a retained reserve. Recheck while running. There is no sourced universal percentage that guarantees safety when other programs allocate concurrently.
|
||||
2. Where delegated cgroup v2 control exists, put disposable workers in their own subtree and keep the supervisor outside that subtree. `memory.high` induces reclaim/throttling and **is not a hard cap**; `memory.max` bounds charged memory and can invoke OOM inside the cgroup. `memory.swap.max` separately controls swap. Use deliberate limits and record them because they change results. Caps reduce risk; they cannot guarantee that an unrelated system-wide shortage never kills a process. [cgroup v2][cgroup]
|
||||
3. Reserve intentional pressure for explicitly selected, isolated work. Without reliable containment or enough reserve, recommend pressure **observation** and small bounded workloads, and report unavailable active-pressure coverage. Allocation success alone is insufficient: memtester's own manual warns about overcommit, swapping and OOM affecting other programs. [memtester][memtester-man]
|
||||
4. Never expose memtester's physical-address/device modes in the normal benchmark path. They overwrite the mapped region and can crash the system when it belongs to another process or the kernel. For ordinary allocations, verify locked bytes and completed patterns/iterations. Linux permits unprivileged locking up to `RLIMIT_MEMLOCK`; larger locking requires suitable privilege, commonly `CAP_IPC_LOCK`. The tool's “run as root” advice should not force the entire TUI to run as root. [manual][memtester-man] [Linux mlock][mlock]
|
||||
5. Use finite work/time limits and a supervisor deadline. For stress-ng, enable only reviewed stressors, verification where supported, and no OOM respawn (`--oomable`). Its `--oom-avoid` is a heuristic with measurement overhead, not containment. In 0.22.01, `--vm-bytes` describes a total across VM workers; other stressors have different allocation semantics, so retain the exact version and options. [stress-ng manual][stress-man]
|
||||
6. Cancel the worker process group gracefully, then terminate remaining descendants after a defined grace period; use `cgroup.kill` when accessible. Preserve a cancelled/partial result. Stress-ng documents SIGINT cleanup, but its timeout can overrun during uninterruptible calls or cleanup. No userspace deadline guarantees immediate cancellation of an uninterruptible kernel task. [manual][stress-man] [cgroup kill][cgroup]
|
||||
|
||||
## Repetition, kernel settings, and TUI overhead
|
||||
|
||||
**Recommendation:** record warmup separately, repeat bounded measurements, retain all repetitions and dispersion, and report a median only at a clearly defined level. STREAM's official report takes the **best** iteration after discarding the first; a median of repeated STREAM run results is a different statistic. Do not silently relabel its internal minimum as a median, combine raw milliseconds with MB/s, or replace an unavailable result with zero. Overall score normalization belongs to the scoring decision. [STREAM source][stream-source]
|
||||
|
||||
Record kernel/build identity, visible preemption/scheduler settings, CPU topology and allowed CPUs, NUMA placement, THP policy, libc, workload/compiler version and flags, governor/driver, boost, power source and temperature observations. NUMA placement and THP policy affect what memory workload is actually measured. Kernel CPUFreq documentation explains that `scaling_cur_freq` can be a requested state rather than measured frequency, and boost depends on thermal/power conditions and package load. A governor name or falling frequency alone does not prove thermal throttling. Correlate sustained performance with available temperatures, thermal trip/cooling states, and power/frequency evidence; unavailable sensors remain unavailable. [CPUFreq][cpufreq] [NUMA][numa] [THP][thp] [thermal interfaces][thermal]
|
||||
|
||||
For “single core,” specify whether the worker is pinned and how the core is chosen on heterogeneous CPUs. For “multicore,” specify workers relative to allowed CPUs, SMT and quota; do not silently change those rules between systems. Preserve the machine's existing configuration for the baseline. Potential governor, scheduler, THP, affinity or kernel changes should be advice or separately labelled experiments, not automatic optimization before measuring.
|
||||
|
||||
The TUI competes for CPU time, memory bandwidth, cache and terminal I/O. Recommend throttled graph updates, buffered logs, no expensive animation during timed sections, and a quiet measurement mode that preserves cancellation. Avoid hiding this by reserving a core without recording it: that reduces tested capacity. Later validation should compare quiet versus normal rendering on the slowest supported machines and establish an overhead budget. This is a proposed qualification experiment, not evidence that a particular redraw rate is already safe. Lmbench explicitly warns about competing cache/CPU work; PSI's own Kconfig notes overhead can show up in synthetic scheduler stress tests. [lmbench][lmbench-readme] [Kconfig][kconfig]
|
||||
|
||||
## Memory errors, VM coverage, and remaining decisions
|
||||
|
||||
Online memtester cannot touch RAM occupied by the kernel or other processes. It may allocate less than requested and may continue unlocked; inspected 4.7.1 source can then still exit zero if its pattern checks succeed. Parse allocation/locking evidence alongside exit bits and report “no errors detected in the tested allocation,” with size, iterations and duration. A mismatch warrants investigation, not an automatic RAM-replacement diagnosis. [manual][memtester-man] [source][memtester-source]
|
||||
|
||||
EDAC counters reset at driver initialization or explicit reset; preserve counter baselines and `seconds_since_reset` without resetting them. Corrected errors merit attention, but uncorrected errors may cause a panic before a counter increments. DIMM labels can depend on board-specific userspace mapping. Therefore missing EDAC nodes, zero observed deltas, and an empty rasdaemon history cannot certify error-free RAM. Offer offline follow-up where supported; decide how development-only AArch64 Memtest86+ support should be presented. [EDAC ABI][edac-abi] [EDAC model][edac] [Memtest86+ stable][memtest-stable] [development][memtest-main]
|
||||
|
||||
VMs can validate packaging, libc/architecture execution, permissions, telemetry fallbacks, cgroup containment and result handling. Guest CPU/memory scores describe the guest allocation and host scheduling conditions; steal time is useful context. They do not certify the host's DIMMs, ECC pipeline, cooling, physical memory-channel bandwidth, or representative bare-metal scheduler tails. Rasdaemon's upstream QEMU tests intentionally inject virtual nonfatal events: useful for exercising decoding, not proving physical hardware health. [proc][proc] [rasdaemon CI description][rasdaemon]
|
||||
|
||||
**Recommended next decision:** shortlist sysbench for a narrow CPU baseline, STREAM for bandwidth, schbench for responsiveness qualification, selected stress-ng workers for bounded load, and memtester plus available EDAC/RAS for diagnostics. Keep perf/cyclictest optional; defer mandatory memory-latency scoring until a candidate is validated. Final selection remains open.
|
||||
|
||||
The human-facing decisions still needed are the foreground workload's meaning; fixed versus relative background load; inclusion of responsiveness in the median score; safe resource reserves and privileges; repetition/time allocation within the 10–20 minute standard run; treatment of heterogeneous cores and missing coverage; and whether offline/development-tool guidance belongs in the first release.
|
||||
|
||||
**Evidence limits:** no candidate was built or executed across the target matrix. Context7 resolved Linux kernel, sysbench, memtester and rt-tests documentation. Stress-ng, STREAM and schbench searches returned unrelated libraries, so no false library match was used; their owning sources were inspected directly. Memtester's upstream HTTPS site failed certificate validation; the report uses the original source/manpage/changelog preserved by Debian, cross-checked against the Void 4.6.0 source archive checksum, and identifies the version difference. Exact artifact compatibility, parser contracts, resource budgets, and score repeatability require later qualification.
|
||||
|
||||
[sysbench]: https://github.com/akopytov/sysbench/blob/master/README.md
|
||||
[sysbench-cpu]: https://github.com/akopytov/sysbench/blob/master/src/tests/cpu/sb_cpu.c
|
||||
[sysbench-memory]: https://github.com/akopytov/sysbench/blob/master/src/tests/memory/sb_memory.c
|
||||
[sysbench-core]: https://github.com/akopytov/sysbench/blob/master/src/sysbench.c
|
||||
[sysbench-timer]: https://github.com/akopytov/sysbench/blob/master/src/sb_timer.h
|
||||
[sysbench-release]: https://github.com/akopytov/sysbench/releases/tag/1.0.20
|
||||
[sysbench-meta]: https://api.github.com/repos/akopytov/sysbench
|
||||
[void-sysbench]: https://github.com/void-linux/void-packages/blob/master/srcpkgs/sysbench/template
|
||||
[schbench]: https://kernel.googlesource.com/pub/scm/linux/kernel/git/mason/schbench/+/6300b8f3a8922c61ea6bb2cdfa1901a42c0cc6fc/README.md
|
||||
[schbench-source]: https://kernel.googlesource.com/pub/scm/linux/kernel/git/mason/schbench/+/6300b8f3a8922c61ea6bb2cdfa1901a42c0cc6fc/schbench.c
|
||||
[stress-readme]: https://github.com/ColinIanKing/stress-ng/blob/V0.22.01/README.md
|
||||
[stress-man]: https://github.com/ColinIanKing/stress-ng/blob/V0.22.01/stress-ng.1
|
||||
[stress-release]: https://github.com/ColinIanKing/stress-ng/releases/tag/V0.22.01
|
||||
[void-stress]: https://github.com/void-linux/void-packages/blob/master/srcpkgs/stress-ng/template
|
||||
[stream-source]: https://www.cs.virginia.edu/stream/FTP/Code/stream.c
|
||||
[stream-rules]: https://www.cs.virginia.edu/stream/ref.html
|
||||
[lmbench-memory]: https://github.com/intel/lmbench/blob/master/doc/lat_mem_rd.8
|
||||
[lmbench-readme]: https://github.com/intel/lmbench/blob/master/README
|
||||
[lmbench-license]: https://github.com/intel/lmbench/blob/master/COPYING
|
||||
[lmbench-meta]: https://api.github.com/repos/intel/lmbench
|
||||
[cyclictest]: https://kernel.googlesource.com/pub/scm/utils/rt-tests/rt-tests/+/62da2befac98f811af8e56f2b7992fb09faa33d6/src/cyclictest/cyclictest.8
|
||||
[cyclictest-source]: https://kernel.googlesource.com/pub/scm/utils/rt-tests/rt-tests/+/62da2befac98f811af8e56f2b7992fb09faa33d6/src/cyclictest/cyclictest.c
|
||||
[rt-utils]: https://kernel.googlesource.com/pub/scm/utils/rt-tests/rt-tests/+/62da2befac98f811af8e56f2b7992fb09faa33d6/src/lib/rt-utils.c
|
||||
[rt-release]: https://kernel.googlesource.com/pub/scm/utils/rt-tests/rt-tests/+/62da2befac98f811af8e56f2b7992fb09faa33d6
|
||||
[rt-makefile]: https://kernel.googlesource.com/pub/scm/utils/rt-tests/rt-tests/+/62da2befac98f811af8e56f2b7992fb09faa33d6/Makefile
|
||||
[perf-stat]: https://github.com/torvalds/linux/blob/master/tools/perf/Documentation/perf-stat.txt
|
||||
[kernel-license]: https://github.com/torvalds/linux/blob/master/COPYING
|
||||
[perf-security]: https://docs.kernel.org/admin-guide/perf-security.html
|
||||
[memtester-man]: https://sources.debian.org/data/main/m/memtester/4.7.1-1/memtester.8
|
||||
[memtester-source]: https://sources.debian.org/data/main/m/memtester/4.7.1-1/memtester.c
|
||||
[memtester-changelog]: https://sources.debian.org/data/main/m/memtester/4.7.1-1/CHANGELOG
|
||||
[void-memtester]: https://github.com/void-linux/void-packages/blob/master/srcpkgs/memtester/template
|
||||
[mlock]: https://man7.org/linux/man-pages/man2/mlock.2.html
|
||||
[memtest-stable]: https://github.com/memtest86plus/memtest86plus/blob/v8.10/README.md
|
||||
[memtest-main]: https://github.com/memtest86plus/memtest86plus/blob/main/README.md
|
||||
[memtest-release]: https://github.com/memtest86plus/memtest86plus/releases/tag/v8.10
|
||||
[edac-abi]: https://github.com/torvalds/linux/blob/master/Documentation/ABI/testing/sysfs-devices-edac
|
||||
[edac]: https://docs.kernel.org/driver-api/edac.html
|
||||
[rasdaemon]: https://github.com/mchehab/rasdaemon/blob/master/README.rst
|
||||
[ras-meta]: https://api.github.com/repos/mchehab/rasdaemon
|
||||
[psi]: https://docs.kernel.org/accounting/psi.html
|
||||
[psi-420]: https://github.com/torvalds/linux/blob/v4.20/Documentation/accounting/psi.txt
|
||||
[kconfig]: https://github.com/torvalds/linux/blob/master/init/Kconfig
|
||||
[proc]: https://docs.kernel.org/filesystems/proc.html
|
||||
[cgroup]: https://docs.kernel.org/admin-guide/cgroup-v2.html
|
||||
[cpufreq]: https://docs.kernel.org/admin-guide/pm/cpufreq.html
|
||||
[numa]: https://docs.kernel.org/admin-guide/mm/numa_memory_policy.html
|
||||
[thp]: https://docs.kernel.org/admin-guide/mm/transhuge.html
|
||||
[thermal]: https://docs.kernel.org/driver-api/thermal/sysfs-api.html
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,9 @@
|
||||
# Odin TUI workflow prototype
|
||||
|
||||
Disposable InkUI prototype for choosing between three structurally different benchmark workflows. All measurements and findings are sample data; no benchmark runs or system changes occur.
|
||||
|
||||
Run with `./run.sh` from this directory. On first run, the script installs the pinned local dependencies.
|
||||
|
||||
Controls: `[` / `]` change layout; `p` changes profile; `Enter` moves through the run; `c` requests cancellation; `g` plan, `l` live run, `s` summary, `h` history, `o` advice; `t` theme; `a` ASCII fallback; `q` quit.
|
||||
|
||||
Mouse support is experimental. Click visible action rows in terminals that send SGR mouse reports. Resize terminal while running to inspect reflow. If your shell sets `NO_COLOR`, use `env -u NO_COLOR ./run.sh` to inspect the color theme.
|
||||
@@ -0,0 +1,110 @@
|
||||
// Auto-generated by sync-registry.ts
|
||||
// Inlines @inkui-cli/core theme and tokens
|
||||
|
||||
export const spacing = {
|
||||
none: 0,
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
xl: 4,
|
||||
} as const;
|
||||
|
||||
export const borderStyles = {
|
||||
single: {
|
||||
topLeft: '┌', top: '─', topRight: '┐',
|
||||
left: '│', right: '│',
|
||||
bottomLeft: '└', bottom: '─', bottomRight: '┘',
|
||||
topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼',
|
||||
},
|
||||
double: {
|
||||
topLeft: '╔', top: '═', topRight: '╗',
|
||||
left: '║', right: '║',
|
||||
bottomLeft: '╚', bottom: '═', bottomRight: '╝',
|
||||
topT: '╦', bottomT: '╩', leftT: '╠', rightT: '╣', cross: '╬',
|
||||
},
|
||||
rounded: {
|
||||
topLeft: '╭', top: '─', topRight: '╮',
|
||||
left: '│', right: '│',
|
||||
bottomLeft: '╰', bottom: '─', bottomRight: '╯',
|
||||
topT: '┬', bottomT: '┴', leftT: '├', rightT: '┤', cross: '┼',
|
||||
},
|
||||
bold: {
|
||||
topLeft: '┏', top: '━', topRight: '┓',
|
||||
left: '┃', right: '┃',
|
||||
bottomLeft: '┗', bottom: '━', bottomRight: '┛',
|
||||
topT: '┳', bottomT: '┻', leftT: '┣', rightT: '┫', cross: '╋',
|
||||
},
|
||||
ascii: {
|
||||
topLeft: '+', top: '-', topRight: '+',
|
||||
left: '|', right: '|',
|
||||
bottomLeft: '+', bottom: '-', bottomRight: '+',
|
||||
topT: '+', bottomT: '+', leftT: '+', rightT: '+', cross: '+',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type BorderStyle = keyof typeof borderStyles;
|
||||
|
||||
export const spinnerFrames = {
|
||||
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
||||
line: ['−', '\\', '|', '/'],
|
||||
arc: ['◜', '◠', '◝', '◞', '◡', '◟'],
|
||||
bounce: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
|
||||
} as const;
|
||||
|
||||
export type SpinnerType = keyof typeof spinnerFrames;
|
||||
|
||||
|
||||
export interface InkUITheme {
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
info: string;
|
||||
muted: string;
|
||||
text: string;
|
||||
textInverse: string;
|
||||
border: string;
|
||||
focus: string;
|
||||
selection: string;
|
||||
};
|
||||
border: 'single' | 'double' | 'rounded' | 'bold' | 'ascii';
|
||||
}
|
||||
|
||||
export const darkTheme: InkUITheme = {
|
||||
colors: {
|
||||
primary: 'cyan',
|
||||
secondary: 'magenta',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
info: 'blue',
|
||||
muted: 'gray',
|
||||
text: 'white',
|
||||
textInverse: 'black',
|
||||
border: 'gray',
|
||||
focus: 'cyan',
|
||||
selection: 'blue',
|
||||
},
|
||||
border: 'single',
|
||||
};
|
||||
|
||||
export const lightTheme: InkUITheme = {
|
||||
colors: {
|
||||
primary: 'blue',
|
||||
secondary: 'magenta',
|
||||
success: 'green',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
info: 'cyan',
|
||||
muted: 'gray',
|
||||
text: 'black',
|
||||
textInverse: 'white',
|
||||
border: 'gray',
|
||||
focus: 'blue',
|
||||
selection: 'cyan',
|
||||
},
|
||||
border: 'rounded',
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
export interface BadgeProps {
|
||||
/** Badge label text */
|
||||
children: string;
|
||||
/** Visual style — maps to theme colors */
|
||||
variant?: BadgeVariant;
|
||||
/** Theme override — defaults to darkTheme */
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
function variantColor(variant: BadgeVariant, theme: InkUITheme): string {
|
||||
switch (variant) {
|
||||
case 'success': return theme.colors.success;
|
||||
case 'warning': return theme.colors.warning;
|
||||
case 'error': return theme.colors.error;
|
||||
case 'info': return theme.colors.info;
|
||||
default: return theme.colors.muted;
|
||||
}
|
||||
}
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const color = variantColor(variant, theme);
|
||||
return (
|
||||
<Box>
|
||||
<Text color={color} bold inverse>{` ${children} `}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BadgeProps, BadgeVariant } from './Badge.js';
|
||||
export { Badge } from './Badge.js';
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface GaugeProps {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
variant?: 'arc' | 'bar' | 'ring';
|
||||
colorStops?: [number, string][];
|
||||
width?: number;
|
||||
showValue?: boolean;
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Gauge: React.FC<GaugeProps> = ({
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
label,
|
||||
unit = '%',
|
||||
variant = 'bar',
|
||||
colorStops,
|
||||
width = 20,
|
||||
showValue = true,
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const pct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
|
||||
|
||||
const defaultStops: [number, string][] = [
|
||||
[0, theme.colors.success],
|
||||
[60, theme.colors.warning],
|
||||
[80, theme.colors.error],
|
||||
];
|
||||
const stops = colorStops ?? defaultStops;
|
||||
let color = stops[0][1];
|
||||
for (const [threshold, c] of stops) {
|
||||
if (pct >= threshold) color = c;
|
||||
}
|
||||
|
||||
const displayValue = `${Math.round(value)}${unit}`;
|
||||
|
||||
if (variant === 'bar') {
|
||||
const filled = Math.round((pct / 100) * width);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{label && <Text color={theme.colors.muted}>{label}</Text>}
|
||||
<Text color={color}>{bar}</Text>
|
||||
{showValue && <Text color={color}>{displayValue}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'ring') {
|
||||
const ringWidth = Math.floor(width / 2);
|
||||
const topFilled = Math.round((pct / 100) * ringWidth);
|
||||
const topBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled);
|
||||
const botBar = '█'.repeat(topFilled) + '░'.repeat(ringWidth - topFilled);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text color={color}>{'◜'}{topBar}{'◝'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'} <Text color={color}>{displayValue.padStart(ringWidth - 1)}</Text>{' █'}</Text>
|
||||
<Text color={theme.colors.muted}>{'█'}{' '.repeat(ringWidth)}{' █'}</Text>
|
||||
<Text color={color}>{'◟'}{botBar}{'◞'}</Text>
|
||||
{label && <Text color={theme.colors.muted} dimColor>{label}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// arc variant
|
||||
const arcWidth = Math.max(10, width);
|
||||
const filled = Math.round((pct / 100) * arcWidth);
|
||||
const arcBar = '█'.repeat(filled) + '░'.repeat(arcWidth - filled);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text color={theme.colors.border}>{' ╭' + '─'.repeat(arcWidth) + '╮'}</Text>
|
||||
<Text color={theme.colors.border}>{' ╱'}{' '.repeat(arcWidth + 2)}{'╲'}</Text>
|
||||
<Text color={theme.colors.border}>{'╱ '}<Text color={color}>{arcBar}</Text>{' ╲'}</Text>
|
||||
<Text color={theme.colors.border}>{'│'}{' '.repeat(Math.floor(arcWidth / 2))}<Text color={color}>{displayValue}</Text>{' '.repeat(Math.ceil(arcWidth / 2))}{'│'}</Text>
|
||||
<Text color={theme.colors.border}>{'╲'}{' '.repeat(arcWidth + 2)}{'╱'}</Text>
|
||||
<Text color={theme.colors.border}>{' ╰' + '─'.repeat(arcWidth) + '╯'}</Text>
|
||||
{label && <Text color={theme.colors.muted} dimColor>{' '}{label}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Gauge } from './Gauge.js';
|
||||
export type { GaugeProps } from './Gauge.js';
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Text, Box, useStdout } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export type HeaderStyle = 'box' | 'line' | 'filled';
|
||||
|
||||
export interface HeaderProps {
|
||||
title: string;
|
||||
version?: string;
|
||||
subtitle?: string;
|
||||
style?: HeaderStyle;
|
||||
align?: 'left' | 'center';
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
version,
|
||||
subtitle,
|
||||
style = 'box',
|
||||
align = 'left',
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const w = stdout?.columns ?? 80;
|
||||
const fullTitle = version ? `${title} v${version}` : title;
|
||||
|
||||
if (style === 'box') {
|
||||
// ┌─── MyApp v1.0 ──────────────────┐
|
||||
const inner = w - 2; // exclude ┌ and ┐
|
||||
const label = ` ${fullTitle} `;
|
||||
const left = 3; // ─── before label
|
||||
const right = Math.max(0, inner - left - label.length);
|
||||
const top = '┌' + '─'.repeat(left) + label + '─'.repeat(right) + '┐';
|
||||
const bot = '└' + '─'.repeat(inner) + '┘';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{top}</Text>
|
||||
{subtitle && (
|
||||
<Text color={theme.colors.primary}>
|
||||
{'│'} <Text color={theme.colors.muted}>{subtitle.padEnd(inner - 2)}</Text> {'│'}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.colors.primary}>{bot}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (style === 'line') {
|
||||
// ══ MyApp v1.0 ════════════════════
|
||||
const label = ` ${fullTitle} `;
|
||||
const pre = '══';
|
||||
const remaining = Math.max(0, w - pre.length - label.length);
|
||||
const line = pre + label + '═'.repeat(remaining);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{line}</Text>
|
||||
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// filled: ███ MyApp v1.0 ████████████
|
||||
const label = ` ${fullTitle} `;
|
||||
const pre = '██ ';
|
||||
const remaining = Math.max(0, w - pre.length - label.length);
|
||||
const line = pre + label + '█'.repeat(remaining);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.colors.primary}>{line}</Text>
|
||||
{subtitle && <Text color={theme.colors.muted}> {subtitle}</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { HeaderProps, HeaderStyle } from './Header.js';
|
||||
export { Header } from './Header.js';
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface KeyHintItem {
|
||||
/** Displayed in brackets, e.g. "Enter", "↑↓", "Space" */
|
||||
key: string;
|
||||
/** Description label, e.g. "Select", "Navigate", "Toggle" */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface KeyHintProps {
|
||||
keys: KeyHintItem[];
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const KeyHint: React.FC<KeyHintProps> = ({
|
||||
keys,
|
||||
theme = darkTheme,
|
||||
}) => (
|
||||
<Box gap={2}>
|
||||
{keys.map(({ key, label }) => (
|
||||
<Box key={key} gap={1}>
|
||||
<Text bold dimColor>[{key}]</Text>
|
||||
<Text color={theme.colors.muted}>{label}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { KeyHintProps, KeyHintItem } from './KeyHint.js';
|
||||
export { KeyHint } from './KeyHint.js';
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import stringWidth from 'string-width';
|
||||
import { borderStyles, darkTheme } from '../_core.js';
|
||||
import type { BorderStyle, InkUITheme } from '../_core.js';
|
||||
|
||||
export interface SplitPaneProps {
|
||||
/** Split direction */
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
/** Sizes as flex ratios, e.g. [1, 2] for 33%/67% */
|
||||
sizes?: number[];
|
||||
/** Gap between panes (columns/rows) */
|
||||
gap?: number;
|
||||
theme?: InkUITheme;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SplitPane({
|
||||
direction = 'horizontal',
|
||||
sizes,
|
||||
gap = 1,
|
||||
children,
|
||||
}: SplitPaneProps) {
|
||||
const childArray = React.Children.toArray(children);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection={direction === 'horizontal' ? 'row' : 'column'}
|
||||
gap={gap}
|
||||
>
|
||||
{childArray.map((child, i) => {
|
||||
const flex = sizes ? sizes[i] ?? 1 : 1;
|
||||
return (
|
||||
<Box key={i} flexGrow={flex} flexShrink={1} flexBasis={0}>
|
||||
{child}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Map our BorderStyle names → Ink/cli-boxes names
|
||||
// ('rounded' → 'round', 'ascii' → 'classic', others match directly)
|
||||
const INK_BORDER_MAP: Record<BorderStyle, string> = {
|
||||
single: 'single',
|
||||
double: 'double',
|
||||
rounded: 'round',
|
||||
bold: 'bold',
|
||||
ascii: 'classic',
|
||||
};
|
||||
|
||||
export interface PanelProps {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
borderStyle?: BorderStyle;
|
||||
/** Fixed column width (includes borders). 'auto' = fill terminal width. */
|
||||
width?: number | 'auto';
|
||||
/** Inner horizontal padding on each side, default 1 */
|
||||
padding?: number;
|
||||
borderColor?: string;
|
||||
titleColor?: string;
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
function repeat(char: string, n: number): string {
|
||||
return n <= 0 ? '' : char.repeat(n);
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
children,
|
||||
borderStyle = 'rounded',
|
||||
width = 'auto',
|
||||
padding = 1,
|
||||
borderColor,
|
||||
titleColor: _titleColor,
|
||||
theme = darkTheme,
|
||||
}: PanelProps) {
|
||||
const { stdout } = useStdout();
|
||||
|
||||
// Track terminal width for auto-sizing
|
||||
const [termCols, setTermCols] = useState(stdout?.columns ?? 80);
|
||||
useEffect(() => {
|
||||
if (!stdout) return;
|
||||
const handler = () => setTermCols(stdout.columns ?? 80);
|
||||
stdout.on('resize', handler);
|
||||
return () => { stdout.off('resize', handler); };
|
||||
}, [stdout]);
|
||||
|
||||
const chars = borderStyles[borderStyle];
|
||||
const bc = borderColor ?? theme.colors.border;
|
||||
|
||||
// Total panel width (includes the 2 border chars on each side)
|
||||
const totalWidth: number = width === 'auto' ? termCols : (width as number);
|
||||
// Inner width = space between left and right border chars
|
||||
const innerWidth = Math.max(4, totalWidth - 2);
|
||||
|
||||
// ── Build the custom top border line with optional embedded title ──
|
||||
let topLine: string;
|
||||
if (title) {
|
||||
// Max visible title length: innerWidth - "─ " - " ─" = innerWidth - 4
|
||||
const maxTitleLen = Math.max(0, innerWidth - 4);
|
||||
const raw = stringWidth(title) > maxTitleLen
|
||||
? title.slice(0, Math.max(0, maxTitleLen - 1)) + '…'
|
||||
: title;
|
||||
// titleCell = " Title "
|
||||
const titleCell = ' ' + raw + ' ';
|
||||
const leading = 1; // one '─' before the title
|
||||
const remaining = innerWidth - leading - stringWidth(titleCell);
|
||||
topLine =
|
||||
chars.topLeft +
|
||||
repeat(chars.top, leading) +
|
||||
titleCell +
|
||||
repeat(chars.top, Math.max(0, remaining)) +
|
||||
chars.topRight;
|
||||
} else {
|
||||
topLine =
|
||||
chars.topLeft +
|
||||
repeat(chars.top, innerWidth) +
|
||||
chars.topRight;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={totalWidth}>
|
||||
{/* Custom top border with optional title */}
|
||||
<Text color={bc}>{topLine}</Text>
|
||||
|
||||
{/*
|
||||
Native Ink Box with borderStyle for left, right, bottom.
|
||||
borderTop={false} removes the top line — our custom line above handles it.
|
||||
The corner chars from Ink's bottom border (╰ and ╯ for rounded) naturally
|
||||
connect to our top line since we used the same borderStyle chars.
|
||||
*/}
|
||||
<Box
|
||||
borderStyle={INK_BORDER_MAP[borderStyle] as 'single'}
|
||||
borderTop={false}
|
||||
borderColor={bc}
|
||||
paddingX={padding}
|
||||
flexDirection="column"
|
||||
width={totalWidth}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Panel, SplitPane } from './Panel.js';
|
||||
export type { PanelProps, SplitPaneProps } from './Panel.js';
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** Progress value 0–100 */
|
||||
value: number;
|
||||
/** Optional label shown to the left of the bar */
|
||||
label?: string;
|
||||
/** Show percentage at the right end */
|
||||
showPercent?: boolean;
|
||||
/** Fixed bar width in columns — defaults to auto (fills terminal width) */
|
||||
width?: number;
|
||||
/** Theme override — defaults to darkTheme */
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
const FILL = '█';
|
||||
const EMPTY = '░';
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
value,
|
||||
label,
|
||||
showPercent = true,
|
||||
width,
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const termWidth = stdout?.columns ?? 80;
|
||||
|
||||
// Clamp value to [0, 100]
|
||||
const pct = Math.min(100, Math.max(0, value));
|
||||
|
||||
// Build the bar width:
|
||||
// total - label - space - percent suffix (e.g. " 100%") - 2 padding spaces
|
||||
const percentSuffix = showPercent ? ` ${String(Math.round(pct)).padStart(3)}%` : '';
|
||||
const labelPrefix = label ? `${label} ` : '';
|
||||
const overhead = labelPrefix.length + percentSuffix.length;
|
||||
const barWidth = width ?? Math.max(8, termWidth - overhead);
|
||||
|
||||
const filled = Math.round((pct / 100) * barWidth);
|
||||
const empty = barWidth - filled;
|
||||
|
||||
const filledStr = FILL.repeat(filled);
|
||||
const emptyStr = EMPTY.repeat(empty);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{label ? <Text>{label} </Text> : null}
|
||||
<Text color={theme.colors.primary}>{filledStr}</Text>
|
||||
<Text color={theme.colors.muted}>{emptyStr}</Text>
|
||||
{showPercent ? (
|
||||
<Text color={theme.colors.muted}>{percentSuffix}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ProgressBarProps } from './ProgressBar.js';
|
||||
export { ProgressBar } from './ProgressBar.js';
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface ScrollAreaProps {
|
||||
/** Visible height in rows */
|
||||
height: number;
|
||||
/** Scrollbar visibility */
|
||||
scrollbar?: boolean;
|
||||
/** Scrollbar thumb character */
|
||||
scrollbarChar?: string;
|
||||
/** Track character */
|
||||
trackChar?: string;
|
||||
/** Called when scroll position changes */
|
||||
onScroll?: (offset: number, total: number) => void;
|
||||
/** Whether this component accepts keyboard input */
|
||||
focus?: boolean;
|
||||
/** Color theme */
|
||||
theme?: InkUITheme;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ScrollArea: React.FC<ScrollAreaProps> = ({
|
||||
height,
|
||||
scrollbar = true,
|
||||
scrollbarChar = '█',
|
||||
trackChar = '░',
|
||||
onScroll,
|
||||
focus = true,
|
||||
theme = darkTheme,
|
||||
children,
|
||||
}) => {
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
const items = React.Children.toArray(children);
|
||||
const totalItems = items.length;
|
||||
const maxOffset = Math.max(0, totalItems - height);
|
||||
|
||||
const scroll = useCallback(
|
||||
(delta: number) => {
|
||||
setScrollOffset((prev) => {
|
||||
const next = Math.max(0, Math.min(maxOffset, prev + delta));
|
||||
onScroll?.(next, totalItems);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[maxOffset, totalItems, onScroll]
|
||||
);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow || input === 'k') scroll(-1);
|
||||
else if (key.downArrow || input === 'j') scroll(1);
|
||||
else if (key.pageUp || input === 'u') scroll(-Math.floor(height / 2));
|
||||
else if (key.pageDown || input === 'd') scroll(Math.floor(height / 2));
|
||||
else if (input === 'g') scroll(-totalItems);
|
||||
else if (input === 'G') scroll(totalItems);
|
||||
},
|
||||
{ isActive: focus }
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + height);
|
||||
|
||||
// Scrollbar calculation
|
||||
const thumbSize = Math.max(1, Math.round((height / Math.max(totalItems, 1)) * height));
|
||||
const thumbPos =
|
||||
maxOffset > 0
|
||||
? Math.round((scrollOffset / maxOffset) * (height - thumbSize))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{visibleItems}
|
||||
</Box>
|
||||
{scrollbar && totalItems > height && (
|
||||
<Box flexDirection="column" width={1}>
|
||||
{Array.from({ length: height }, (_, i) => {
|
||||
const isThumb = i >= thumbPos && i < thumbPos + thumbSize;
|
||||
return (
|
||||
<Text key={i} color={isThumb ? theme.colors.primary : theme.colors.muted}>
|
||||
{isThumb ? scrollbarChar : trackChar}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ScrollArea } from './ScrollArea.js';
|
||||
export type { ScrollAreaProps } from './ScrollArea.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
label?: string;
|
||||
showRange?: boolean;
|
||||
showLatest?: boolean;
|
||||
color?: string;
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Sparkline: React.FC<SparklineProps> = ({
|
||||
data,
|
||||
width = 20,
|
||||
height = 1,
|
||||
label,
|
||||
showRange = false,
|
||||
showLatest = true,
|
||||
color,
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
if (!data || data.length === 0) {
|
||||
return <Text color={theme.colors.muted}>{label ?? ''} (no data)</Text>;
|
||||
}
|
||||
|
||||
const barColor = color ?? theme.colors.primary;
|
||||
const minVal = Math.min(...data);
|
||||
const maxVal = Math.max(...data);
|
||||
const range = maxVal - minVal || 1;
|
||||
|
||||
// Downsample or use as-is
|
||||
let points: number[];
|
||||
if (data.length > width) {
|
||||
const step = data.length / width;
|
||||
points = Array.from({ length: width }, (_, i) => {
|
||||
const start = Math.floor(i * step);
|
||||
const end = Math.floor((i + 1) * step);
|
||||
const slice = data.slice(start, end);
|
||||
return slice.reduce((a, b) => a + b, 0) / slice.length;
|
||||
});
|
||||
} else if (data.length < width) {
|
||||
points = [...data, ...Array(width - data.length).fill(data[data.length - 1])];
|
||||
} else {
|
||||
points = data;
|
||||
}
|
||||
|
||||
const chars = points.map((v) => {
|
||||
const normalized = (v - minVal) / range;
|
||||
const idx = Math.min(7, Math.round(normalized * 7));
|
||||
return BLOCKS[idx];
|
||||
});
|
||||
|
||||
const sparkStr = chars.join('');
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{label && <Text color={theme.colors.muted}>{label}</Text>}
|
||||
<Text color={barColor}>{sparkStr}</Text>
|
||||
{showLatest && <Text color={barColor}>{Math.round(data[data.length - 1])}</Text>}
|
||||
{showRange && (
|
||||
<Text color={theme.colors.muted} dimColor>
|
||||
(min: {Math.round(minVal)} max: {Math.round(maxVal)})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Sparkline } from './Sparkline.js';
|
||||
export type { SparklineProps } from './Sparkline.js';
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { darkTheme } from '../_core.js';
|
||||
import type { InkUITheme } from '../_core.js';
|
||||
|
||||
export interface Step {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface StepperProps {
|
||||
steps: Step[];
|
||||
currentStep: string;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
showNumbers?: boolean;
|
||||
completedSteps?: string[];
|
||||
errorSteps?: string[];
|
||||
theme?: InkUITheme;
|
||||
}
|
||||
|
||||
export const Stepper: React.FC<StepperProps> = ({
|
||||
steps,
|
||||
currentStep,
|
||||
orientation = 'horizontal',
|
||||
showNumbers = false,
|
||||
completedSteps = [],
|
||||
errorSteps = [],
|
||||
theme = darkTheme,
|
||||
}) => {
|
||||
const getIndicator = (step: Step, index: number) => {
|
||||
if (errorSteps.includes(step.key)) return { char: '✕', color: theme.colors.error };
|
||||
if (completedSteps.includes(step.key)) return { char: '✓', color: theme.colors.success };
|
||||
if (step.key === currentStep) return { char: '●', color: theme.colors.primary };
|
||||
return { char: '○', color: theme.colors.muted };
|
||||
};
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{steps.map((step, i) => {
|
||||
const { char, color } = getIndicator(step, i);
|
||||
const isCurrent = step.key === currentStep;
|
||||
const connector = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border;
|
||||
|
||||
return (
|
||||
<Box key={step.key} flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
|
||||
<Text color={color}>{char}</Text>
|
||||
<Text bold={isCurrent} color={isCurrent ? theme.colors.text : theme.colors.muted}>
|
||||
{step.title}
|
||||
{step.optional && <Text color={theme.colors.muted}> (optional)</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
{step.description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.colors.muted}>│ {step.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{i < steps.length - 1 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={connector}>│</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{steps.map((step, i) => {
|
||||
const { char, color } = getIndicator(step, i);
|
||||
const isCurrent = step.key === currentStep;
|
||||
const connectorColor = completedSteps.includes(step.key) ? theme.colors.success : theme.colors.border;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{showNumbers && <Text color={theme.colors.muted}>{i + 1}.</Text>}
|
||||
<Text color={color}>{char}</Text>
|
||||
<Text color={isCurrent ? theme.colors.text : theme.colors.muted} bold={isCurrent}>
|
||||
{' '}{step.title}{step.optional ? ' (opt)' : ''}
|
||||
</Text>
|
||||
{i < steps.length - 1 && (
|
||||
<Text color={connectorColor}>{' ── '}</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
{steps.map((step, i) => (
|
||||
<React.Fragment key={step.key}>
|
||||
{step.description && (
|
||||
<Text color={theme.colors.muted}>
|
||||
{' '}{step.description}
|
||||
{i < steps.length - 1 ? ' ' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Stepper } from './Stepper.js';
|
||||
export type { StepperProps, Step } from './Stepper.js';
|
||||
Generated
+1054
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "odin-tui-workflow-prototype",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/main.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "7.1.1",
|
||||
"react": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "4.23.15"
|
||||
}
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
if [[ ! -d node_modules ]]; then
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
exec npm start
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
measureElement,
|
||||
render,
|
||||
useApp,
|
||||
useInput,
|
||||
useWindowSize,
|
||||
} from 'ink';
|
||||
import { Badge } from '../components/ui/badge/index.js';
|
||||
import { Header } from '../components/ui/header/index.js';
|
||||
import { KeyHint } from '../components/ui/key-hint/index.js';
|
||||
import { ProgressBar } from '../components/ui/progress-bar/index.js';
|
||||
import { ScrollArea } from '../components/ui/scroll-area/index.js';
|
||||
import { Sparkline } from '../components/ui/sparkline/index.js';
|
||||
import { Stepper } from '../components/ui/stepper/index.js';
|
||||
import { Gauge } from '../components/ui/gauge/index.js';
|
||||
import { darkTheme, lightTheme } from '../components/ui/_core.js';
|
||||
|
||||
const phases = [
|
||||
{ id: 'plan', key: 'g', title: 'Plan' },
|
||||
{ id: 'run', key: 'l', title: 'Live run' },
|
||||
{ id: 'results', key: 's', title: 'Summary' },
|
||||
{ id: 'history', key: 'h', title: 'History' },
|
||||
{ id: 'advice', key: 'o', title: 'Advice' },
|
||||
];
|
||||
|
||||
const profiles = [
|
||||
{ id: 'quick', label: 'Quick', time: '~4 min', impact: 'Low', note: 'Read-only triage' },
|
||||
{ id: 'standard', label: 'Standard', time: '~14 min', impact: 'Moderate', note: 'All capable domains' },
|
||||
{ id: 'extended', label: 'Extended', time: '~38 min', impact: 'High', note: 'Sustained load; opt in' },
|
||||
];
|
||||
|
||||
const domains = [
|
||||
{ name: 'CPU', state: 'Ready', score: '96.4' },
|
||||
{ name: 'Memory', state: 'Ready', score: '91.2' },
|
||||
{ name: 'GPU', state: 'Unavailable', score: '—', reason: 'No supported compute driver' },
|
||||
{ name: 'Storage', state: 'Ready', score: '94.1' },
|
||||
{ name: 'Browser', state: 'Ready', score: '90.5' },
|
||||
{ name: 'Bash', state: 'Ready', score: '97.6' },
|
||||
{ name: 'Languages', state: 'Ready', score: '93.0' },
|
||||
];
|
||||
|
||||
const variantNames = ['Mission Control', 'Guided Run', 'Run Ledger'];
|
||||
const scoreValues = [96.4, 91.2, 94.1, 90.5, 97.6, 93.0];
|
||||
|
||||
function Card({ title, children, ascii, theme, width, flexGrow = 0 }) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle={ascii ? 'classic' : 'round'}
|
||||
borderColor={theme.colors.border}
|
||||
paddingX={1}
|
||||
width={width}
|
||||
flexGrow={flexGrow}
|
||||
flexShrink={width ? 0 : 1}
|
||||
>
|
||||
<Text bold color={theme.colors.secondary}>{title}</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClickRow({ id, register, children, selected = false, theme, ascii }) {
|
||||
return (
|
||||
<Box ref={register(id)}>
|
||||
<Text
|
||||
inverse={selected}
|
||||
color={selected ? theme.colors.textInverse : theme.colors.text}
|
||||
bold={selected}
|
||||
>
|
||||
{selected ? (ascii ? '> ' : '› ') : ' '}{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StageNav({ phase, register, theme, ascii, vertical = false }) {
|
||||
return (
|
||||
<Box flexDirection={vertical ? 'column' : 'row'} gap={vertical ? 0 : 1}>
|
||||
{phases.map((item) => (
|
||||
<Box key={item.id} ref={register(`phase:${item.id}`)}>
|
||||
<Text
|
||||
bold={phase === item.id}
|
||||
color={phase === item.id ? theme.colors.primary : theme.colors.muted}
|
||||
inverse={phase === item.id}
|
||||
>
|
||||
{ascii ? `[${item.key}] ${item.title}` : ` ${item.title} `}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRows({ profile, setProfile, register, theme, ascii, compact = false }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={compact ? 0 : 1}>
|
||||
{profiles.map((item) => (
|
||||
<Box key={item.id} ref={register(`profile:${item.id}`)}>
|
||||
<Text
|
||||
bold={profile === item.id}
|
||||
color={profile === item.id ? theme.colors.primary : theme.colors.text}
|
||||
inverse={profile === item.id}
|
||||
>
|
||||
{profile === item.id ? (ascii ? '> ' : '◆ ') : ' '}
|
||||
{item.label} {item.time}{compact ? '' : ` ${item.impact}`}
|
||||
</Text>
|
||||
{!compact && <Text color={theme.colors.muted}> {item.note}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={theme.colors.muted}>{ascii ? 'p changes profile' : 'p changes profile; arrows scroll domains'}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StageContent({
|
||||
phase,
|
||||
profile,
|
||||
theme,
|
||||
ascii,
|
||||
progress,
|
||||
cancelPrompt,
|
||||
cancelQueued,
|
||||
recovered,
|
||||
runStatus,
|
||||
register,
|
||||
}) {
|
||||
if (phase === 'plan') {
|
||||
const selected = profiles.find((item) => item.id === profile) ?? profiles[1];
|
||||
return (
|
||||
<Box flexDirection="column" gap={0}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="success" theme={theme}>6 READY</Badge>
|
||||
<Badge variant="warning" theme={theme}>1 UNAVAILABLE</Badge>
|
||||
</Box>
|
||||
<Text>Selected: {selected.label} · {selected.time} · {selected.impact} impact</Text>
|
||||
<Text color={theme.colors.muted}>{selected.note}. No score if coverage stays incomplete.</Text>
|
||||
<Text>GPU unavailable: no supported compute driver detected.</Text>
|
||||
<Text>Workload writes: none in this profile.</Text>
|
||||
<Box ref={register('start')} marginTop={1}>
|
||||
<Text bold color={theme.colors.success} inverse> Enter to begin {selected.label} plan </Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'run') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="info" theme={theme}>RUNNING</Badge>
|
||||
<Text>CPU mixed load · trial 3 of 5 · ~6 min left</Text>
|
||||
</Box>
|
||||
{ascii
|
||||
? <Text>[########------------] {progress}%</Text>
|
||||
: <ProgressBar value={progress} width={28} theme={theme} />}
|
||||
<Text color={theme.colors.warning} bold>
|
||||
{cancelQueued ? 'Cancel queued for the next safe trial boundary.' : 'Timed sample active · display updates pause during measurement.'}
|
||||
</Text>
|
||||
<Text>GPU: skipped · no supported compute driver</Text>
|
||||
<Text>Worker isolated · UI input cannot change workload conditions.</Text>
|
||||
{cancelPrompt ? (
|
||||
<Box borderStyle={ascii ? 'classic' : 'round'} borderColor={theme.colors.warning} paddingX={1} marginTop={1}>
|
||||
<Text bold>Stop after this trial? [y] Confirm [n] Continue</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box ref={register('cancel')}><Text bold color={theme.colors.error} inverse> c Request safe stop </Text></Box>
|
||||
<Box ref={register('recover')}><Text color={theme.colors.secondary}> u Simulate interruption recovery </Text></Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'results') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Badge variant="warning" theme={theme}>HEADLINE WITHHELD</Badge>
|
||||
<Text>{runStatus === 'cancelled' || runStatus === 'recovered' ? 'Partial coverage · no median' : '6/7 valid · GPU unavailable'}</Text>
|
||||
</Box>
|
||||
<Text bold>{runStatus === 'cancelled' ? 'Cancelled safely · partial record preserved' : runStatus === 'recovered' ? 'Recovered after interruption · partial record preserved' : runStatus === 'complete' ? 'Run complete · capability coverage remains 6/7' : 'Sample run · partial record preserved'}</Text>
|
||||
{runStatus === 'cancelled' || runStatus === 'recovered'
|
||||
? <Text>Partial evidence remains; unrun domains have no measurements.</Text>
|
||||
: <Box flexDirection="row" flexWrap="wrap" gap={1}>
|
||||
{domains.filter((item) => item.score !== '—').map((item) => (
|
||||
<Text key={item.name} color={theme.colors.primary}>{item.name} {item.score}</Text>
|
||||
))}
|
||||
</Box>}
|
||||
<Text>Prior complete run: median 93.2 · reference only</Text>
|
||||
<Text color={theme.colors.warning}>Warning · thermal throttling observed after trial 3</Text>
|
||||
<Text color={theme.colors.muted}>Evidence: temperature rise + clock drop · corroborated</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === 'history') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>LOCAL RUN HISTORY</Text>
|
||||
<Text>OD-0928 · {profiles.find((item) => item.id === profile)?.label} · {runStatus === 'cancelled' ? 'Cancelled · partial' : runStatus === 'recovered' ? 'Recovered · partial' : 'Complete attempt'} · headline withheld</Text>
|
||||
<Text>OD-0921 · Standard · Complete · 7/7 · median 93.2</Text>
|
||||
<Text color={theme.colors.warning}>Partial run is not comparable; prior complete run remains available.</Text>
|
||||
{recovered && <Text color={theme.colors.warning}>Recovered after interruption · partial evidence retained · cleanup confirmed</Text>}
|
||||
<Text color={theme.colors.muted}>Notes stay separate from original measurements.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.colors.warning}>Finding · WARNING · corroborated</Text>
|
||||
<Text>CPU clocks fell as temperature reached 91°C during sustained load.</Text>
|
||||
<Text>Advice: inspect cooling before repeating extended profile.</Text>
|
||||
<Text color={theme.colors.success}>Expected benefit: steadier sustained throughput.</Text>
|
||||
<Text>Tradeoff: downtime for inspection. Odin changes no settings.</Text>
|
||||
<Text color={theme.colors.muted}>Evidence and uncertainty remain visible beside advice.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { exit } = useApp();
|
||||
const { columns = 80, rows = 24 } = useWindowSize();
|
||||
const [variant, setVariant] = useState(0);
|
||||
const [phase, setPhase] = useState('plan');
|
||||
const [profile, setProfile] = useState('standard');
|
||||
const [themeMode, setThemeMode] = useState('dark');
|
||||
const [ascii, setAscii] = useState(false);
|
||||
const [cancelPrompt, setCancelPrompt] = useState(false);
|
||||
const [cancelQueued, setCancelQueued] = useState(false);
|
||||
const [progress, setProgress] = useState(64);
|
||||
const [recovered, setRecovered] = useState(false);
|
||||
const [runStatus, setRunStatus] = useState('sample');
|
||||
const [mouseLast, setMouseLast] = useState('No click yet');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const hitTargets = useRef(new Map());
|
||||
const handlers = useRef({});
|
||||
const themeBase = themeMode === 'dark' ? darkTheme : lightTheme;
|
||||
const colors = ascii || process.env.NO_COLOR !== undefined
|
||||
? Object.fromEntries(Object.keys(themeBase.colors).map((key) => [key, undefined]))
|
||||
: {
|
||||
...themeBase.colors,
|
||||
primary: themeMode === 'dark' ? 'magenta' : 'blue',
|
||||
secondary: themeMode === 'dark' ? 'cyan' : 'magenta',
|
||||
};
|
||||
const theme = { ...themeBase, colors };
|
||||
const roomy = columns >= 80 && rows >= 19;
|
||||
const register = (id) => (node) => {
|
||||
if (node) hitTargets.current.set(id, node);
|
||||
else hitTargets.current.delete(id);
|
||||
};
|
||||
|
||||
const nextVariant = (delta) => setVariant((prev) => (prev + delta + variantNames.length) % variantNames.length);
|
||||
const nextProfile = () => {
|
||||
const index = profiles.findIndex((item) => item.id === profile);
|
||||
setProfile(profiles[(index + 1) % profiles.length].id);
|
||||
};
|
||||
const startRun = () => {
|
||||
setPhase('run');
|
||||
setProgress(64);
|
||||
setRunStatus('running');
|
||||
setCancelPrompt(false);
|
||||
setCancelQueued(false);
|
||||
};
|
||||
const cancelRun = () => {
|
||||
setCancelPrompt(false);
|
||||
setCancelQueued(false);
|
||||
setRunStatus('cancelled');
|
||||
setPhase('results');
|
||||
};
|
||||
const advanceRun = () => {
|
||||
if (progress >= 96) {
|
||||
setRunStatus('complete');
|
||||
setPhase('results');
|
||||
} else setProgress((value) => Math.min(96, value + 16));
|
||||
};
|
||||
const recoverRun = () => {
|
||||
setRecovered(true);
|
||||
setRunStatus('recovered');
|
||||
setPhase('history');
|
||||
setCancelPrompt(false);
|
||||
};
|
||||
|
||||
handlers.current = {
|
||||
'variant:prev': () => nextVariant(-1),
|
||||
'variant:next': () => nextVariant(1),
|
||||
'theme': () => setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'),
|
||||
'ascii': () => setAscii((value) => !value),
|
||||
'phase:plan': () => setPhase('plan'),
|
||||
'phase:run': () => setPhase('run'),
|
||||
'phase:results': () => setPhase('results'),
|
||||
'phase:history': () => setPhase('history'),
|
||||
'phase:advice': () => setPhase('advice'),
|
||||
'profile:quick': () => setProfile('quick'),
|
||||
'profile:standard': () => setProfile('standard'),
|
||||
'profile:extended': () => setProfile('extended'),
|
||||
'start': startRun,
|
||||
'cancel': () => { setCancelQueued(true); setCancelPrompt(true); },
|
||||
'recover': recoverRun,
|
||||
};
|
||||
|
||||
const clickAt = (column, row) => {
|
||||
for (const [id, node] of hitTargets.current.entries()) {
|
||||
const rect = measureElement(node);
|
||||
if (column >= rect.x && column < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height) {
|
||||
handlers.current[id]?.();
|
||||
setMouseLast(id.replace(':', ' '));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
||||
const enable = '\u001b[?1000h\u001b[?1006h';
|
||||
const disable = '\u001b[?1006l\u001b[?1000l';
|
||||
process.stdout.write(enable);
|
||||
let carry = '';
|
||||
const onData = (chunk) => {
|
||||
carry += chunk.toString('utf8');
|
||||
const pattern = /\u001b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(carry)) !== null) {
|
||||
const button = Number(match[1]) & 3;
|
||||
if (button === 0 && match[4] === 'M') {
|
||||
clickAt(Number(match[2]) - 1, Number(match[3]) - 1);
|
||||
}
|
||||
}
|
||||
carry = carry.slice(-80);
|
||||
};
|
||||
const onExit = () => process.stdout.write(disable);
|
||||
process.stdin.on('data', onData);
|
||||
process.once('exit', onExit);
|
||||
return () => {
|
||||
process.stdin.off('data', onData);
|
||||
process.off('exit', onExit);
|
||||
process.stdout.write(disable);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (cancelPrompt) {
|
||||
if (input.toLowerCase() === 'y' || key.return) cancelRun();
|
||||
else if (input.toLowerCase() === 'n' || key.escape) { setCancelPrompt(false); setCancelQueued(false); }
|
||||
return;
|
||||
}
|
||||
if (input === 'q' || (key.ctrl && input === 'c')) { exit(); return; }
|
||||
if (input === '[') { nextVariant(-1); return; }
|
||||
if (input === ']') { nextVariant(1); return; }
|
||||
if (input === 'p') { nextProfile(); return; }
|
||||
if (input === 't') { setThemeMode((mode) => mode === 'dark' ? 'light' : 'dark'); return; }
|
||||
if (input === 'a') { setAscii((value) => !value); return; }
|
||||
if (input === '?') { setShowHelp((value) => !value); return; }
|
||||
if (input === 'g' || key.tab && key.shift) { setPhase('plan'); return; }
|
||||
if (input === 'l') { setPhase('run'); return; }
|
||||
if (input === 's') { setPhase('results'); return; }
|
||||
if (input === 'h') { setPhase('history'); return; }
|
||||
if (input === 'o') { setPhase('advice'); return; }
|
||||
if (key.leftArrow) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + phases.length - 1) % phases.length].id); return; }
|
||||
if (key.rightArrow || key.tab) { const i = phases.findIndex((item) => item.id === phase); setPhase(phases[(i + 1) % phases.length].id); return; }
|
||||
if (phase === 'run' && input.toLowerCase() === 'c') { setCancelQueued(true); setCancelPrompt(true); return; }
|
||||
if (phase === 'run' && input.toLowerCase() === 'u') { recoverRun(); return; }
|
||||
if (phase === 'plan' && key.return) { startRun(); return; }
|
||||
if (phase === 'run' && key.return) { advanceRun(); return; }
|
||||
if (phase === 'results' && key.return) { setPhase('history'); return; }
|
||||
if (phase === 'history' && key.return) { setPhase('advice'); return; }
|
||||
if (phase === 'advice' && key.return) { setPhase('plan'); return; }
|
||||
});
|
||||
|
||||
const renderHeader = () => ascii
|
||||
? <Box flexDirection="row" gap={1}><Text bold color={theme.colors.primary}>ODIN</Text><Text>by Bongbetic · local benchmark + system health</Text></Box>
|
||||
: <Header title="ODIN · Bongbetic" version="prototype" subtitle="LOCAL BENCHMARK + SYSTEM HEALTH" style="line" theme={theme} />;
|
||||
|
||||
const renderProfiles = (compact = false) => (
|
||||
<ProfileRows profile={profile} register={register} theme={theme} ascii={ascii} compact={compact} />
|
||||
);
|
||||
|
||||
const renderDomains = (height = 5) => (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>COVERAGE · 6/7</Text>
|
||||
{ascii
|
||||
? <ScrollArea height={height} theme={theme} scrollbarChar="#" trackChar="." focus>
|
||||
{domains.map((item) => <Text key={item.name}>{item.name}: {item.state}{item.reason ? ` — ${item.reason}` : ''}</Text>)}
|
||||
</ScrollArea>
|
||||
: <ScrollArea height={height} theme={theme} focus>
|
||||
{domains.map((item) => (
|
||||
<Box key={item.name} flexDirection="row" gap={1}>
|
||||
<Text color={item.state === 'Ready' ? theme.colors.success : theme.colors.warning}>{item.state === 'Ready' ? '●' : '○'}</Text>
|
||||
<Text>{item.name}</Text>
|
||||
<Text color={theme.colors.muted}>{item.state === 'Ready' ? 'ready' : 'unavailable'}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</ScrollArea>}
|
||||
<Text color={theme.colors.muted}>Arrows scroll · status words work without color</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<StageContent
|
||||
phase={phase}
|
||||
profile={profile}
|
||||
theme={theme}
|
||||
ascii={ascii}
|
||||
progress={progress}
|
||||
cancelPrompt={cancelPrompt}
|
||||
cancelQueued={cancelQueued}
|
||||
recovered={recovered}
|
||||
runStatus={runStatus}
|
||||
register={register}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderVariantSwitch = () => (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Box ref={register('variant:prev')}><Text color={theme.colors.muted}>[</Text></Box>
|
||||
<Text color={theme.colors.secondary} bold>Layout {String.fromCharCode(65 + variant)} · {variantNames[variant]}</Text>
|
||||
<Box ref={register('variant:next')}><Text color={theme.colors.muted}>]</Text></Box>
|
||||
<Box ref={register('theme')}><Text color={theme.colors.muted}>[t] theme</Text></Box>
|
||||
<Box ref={register('ascii')}><Text color={theme.colors.muted}>[a] ASCII</Text></Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderFooter = () => (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{showHelp ? (
|
||||
<Text color={theme.colors.muted}>[[]/]] layout · [g/l/s/h/o] views · [p] profile · [Enter] advance · [c] cancel · [u] recover · [t] theme · [a] ASCII · [q] quit</Text>
|
||||
) : (
|
||||
<KeyHint theme={theme} keys={[
|
||||
{ key: 'Tab/←→', label: 'View' },
|
||||
{ key: 'p', label: 'Profile' },
|
||||
{ key: 'Enter', label: phase === 'run' ? 'Next trial' : 'Open' },
|
||||
{ key: 'c', label: 'Cancel run' },
|
||||
{ key: '?', label: 'Help' },
|
||||
{ key: 'q', label: 'Quit' },
|
||||
]} />
|
||||
)}
|
||||
<Text color={theme.colors.muted}>Mouse: {process.stdin.isTTY ? 'SGR click mode · ' : 'unavailable · '}{mouseLast} · {columns}×{rows} · {ascii ? 'ASCII + monochrome' : process.env.NO_COLOR !== undefined ? 'Unicode · NO_COLOR' : `${themeMode} color`}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderA = () => (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box marginBottom={1}><StageNav phase={phase} register={register} theme={theme} ascii={ascii} /></Box>
|
||||
<Box flexDirection={roomy ? 'row' : 'column'} gap={1} flexGrow={1}>
|
||||
<Card title="RUN SETUP" ascii={ascii} theme={theme} width={roomy ? Math.min(31, Math.floor(columns * 0.38)) : columns}>
|
||||
{renderProfiles(true)}
|
||||
{renderDomains(roomy ? 5 : 3)}
|
||||
</Card>
|
||||
<Card title={phases.find((item) => item.id === phase)?.title.toUpperCase()} ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{renderContent()}
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderB = () => (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{!ascii && <Stepper
|
||||
steps={[{ key: 'plan', title: 'Plan' }, { key: 'run', title: 'Measure' }, { key: 'review', title: 'Review' }]}
|
||||
currentStep={phase === 'plan' ? 'plan' : phase === 'run' ? 'run' : 'review'}
|
||||
completedSteps={phase === 'run' ? ['plan'] : phase === 'results' || phase === 'history' || phase === 'advice' ? ['plan', 'run'] : []}
|
||||
theme={theme}
|
||||
/>}
|
||||
{ascii && <Text>STEP 1 PLAN --> STEP 2 MEASURE --> STEP 3 REVIEW</Text>}
|
||||
<Box marginTop={1}><StageNav phase={phase} register={register} theme={theme} ascii={ascii} /></Box>
|
||||
<Card title={phase === 'plan' ? 'CHOOSE A PROFILE' : phase === 'run' ? 'MEASUREMENT IN PROGRESS' : phase === 'results' ? 'REVIEW RESULT' : phase === 'history' ? 'SAVED RUNS' : 'NEXT ACTION'} ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{phase === 'plan' && renderProfiles(false)}
|
||||
{renderContent()}
|
||||
{phase === 'plan' && renderDomains(4)}
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderC = () => {
|
||||
const wideConsole = columns >= 112 && rows >= 22;
|
||||
if (!wideConsole) {
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<StageNav phase={phase} register={register} theme={theme} ascii={ascii} />
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text>LOCAL · NO UPLOAD</Text>
|
||||
<Text color={theme.colors.success}>6 ready / 7</Text>
|
||||
<Text color={theme.colors.warning}>1 finding</Text>
|
||||
</Box>
|
||||
<Card title="OPERATOR VIEW" ascii={ascii} theme={theme} flexGrow={1}>
|
||||
{renderContent()}
|
||||
{phase === 'results' && <Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>VALID DOMAIN MEDIANS</Text>
|
||||
{ascii
|
||||
? <Text>CPU 96 · MEM 91 · GPU N/A · DISK 94 · WEB 90 · BASH 98 · LANG 93</Text>
|
||||
: <Sparkline data={scoreValues} width={Math.min(30, columns - 8)} label="Scores" theme={theme} />}
|
||||
</Box>}
|
||||
</Card>
|
||||
<Text color={theme.colors.muted}>EVENT · GPU unavailable · thermal warning corroborated · worker separate from redraw</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} flexGrow={1}>
|
||||
<Card title="ODIN" ascii={ascii} theme={theme} width={27}>
|
||||
<Text color={theme.colors.muted}>LOCAL · NO UPLOAD</Text>
|
||||
<StageNav phase={phase} register={register} theme={theme} ascii={ascii} vertical />
|
||||
<Text color={theme.colors.secondary}>PROFILE · {profile.toUpperCase()}</Text>
|
||||
<Text>6 ready · 1 unavailable</Text>
|
||||
<Text color={theme.colors.warning}>1 finding</Text>
|
||||
<Text color={theme.colors.muted}>[[] / ]] layouts</Text>
|
||||
</Card>
|
||||
<Box flexDirection="column" flexGrow={1} gap={1}>
|
||||
<Card title="OPERATOR VIEW" ascii={ascii} theme={theme} flexGrow={1}>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Box flexDirection="column" flexGrow={1}>{renderContent()}</Box>
|
||||
{phase === 'results' && <Box flexDirection="column" width={30}>
|
||||
<Text bold>DOMAIN MEDIANS</Text>
|
||||
{ascii
|
||||
? <Text>CPU 96 | MEM 91 | GPU N/A | DISK 94 | WEB 90 | BASH 98 | LANG 93</Text>
|
||||
: <><Gauge value={93} width={16} label="Valid domains" unit="%" theme={theme} /><Sparkline data={scoreValues} width={18} label="Scores" theme={theme} /></>}
|
||||
</Box>}
|
||||
</Box>
|
||||
</Card>
|
||||
<Card title="EVENT / EVIDENCE LOG" ascii={ascii} theme={theme}>
|
||||
<Text>14:03 capability scan · GPU unavailable · reason recorded</Text>
|
||||
<Text>14:04 thermal finding · clocks down as temperature rose</Text>
|
||||
<Text color={theme.colors.muted}>Worker activity remains separate from terminal redraws.</Text>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={columns} paddingX={1}>
|
||||
{renderHeader()}
|
||||
<Box flexDirection="row" justifyContent="space-between" marginY={1}>
|
||||
{renderVariantSwitch()}
|
||||
<Text color={theme.colors.muted}>{profile.toUpperCase()} · {profiles.find((item) => item.id === profile)?.time}</Text>
|
||||
</Box>
|
||||
{variant === 0 ? renderA() : variant === 1 ? renderB() : renderC()}
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />, { alternateScreen: true, kittyKeyboard: { mode: 'auto' } });
|
||||
Reference in New Issue
Block a user