tunectl 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/benchmark.sh +71 -59
- package/tunectl/__init__.py +1 -1
- package/tunectl/__pycache__/__init__.cpython-312.pyc +0 -0
- package/tunectl/__pycache__/__main__.cpython-312.pyc +0 -0
- package/tunectl/__pycache__/cli.cpython-312.pyc +0 -0
- package/tunectl/__pycache__/ui.cpython-312.pyc +0 -0
- package/tunectl/cli.py +162 -30
- package/tunectl/ui.py +282 -0
package/package.json
CHANGED
package/scripts/benchmark.sh
CHANGED
|
@@ -96,41 +96,35 @@ while [[ $# -gt 0 ]]; do
|
|
|
96
96
|
done
|
|
97
97
|
|
|
98
98
|
# -------------------------------------------------------
|
|
99
|
-
# Check dependencies
|
|
99
|
+
# Check dependencies — graceful degradation
|
|
100
|
+
# Sets HAS_SYSBENCH and HAS_FIO globals (0=available, 1=missing)
|
|
101
|
+
# Exits 1 only if BOTH tools are missing
|
|
100
102
|
# -------------------------------------------------------
|
|
103
|
+
HAS_SYSBENCH=1
|
|
104
|
+
HAS_FIO=1
|
|
105
|
+
|
|
101
106
|
check_dependencies() {
|
|
102
107
|
local missing=()
|
|
103
108
|
|
|
104
|
-
if
|
|
109
|
+
if command -v sysbench &>/dev/null; then
|
|
110
|
+
HAS_SYSBENCH=0
|
|
111
|
+
else
|
|
105
112
|
missing+=("sysbench")
|
|
113
|
+
echo "▲ sysbench not found. Install: sudo apt install sysbench" >&2
|
|
106
114
|
fi
|
|
107
115
|
|
|
108
|
-
if
|
|
116
|
+
if command -v fio &>/dev/null; then
|
|
117
|
+
HAS_FIO=0
|
|
118
|
+
else
|
|
109
119
|
missing+=("fio")
|
|
120
|
+
echo "▲ fio not found. Install: sudo apt install fio" >&2
|
|
110
121
|
fi
|
|
111
122
|
|
|
112
|
-
if [[ ${#missing[@]} -
|
|
113
|
-
echo "
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
apt-get install -y -qq "${missing[@]}" >/dev/null 2>&1
|
|
118
|
-
else
|
|
119
|
-
echo "Error: Cannot install ${missing[*]} without root. Run: sudo apt-get install ${missing[*]}" >&2
|
|
120
|
-
exit 1
|
|
121
|
-
fi
|
|
122
|
-
else
|
|
123
|
-
echo "Error: apt-get not available. Install manually: ${missing[*]}" >&2
|
|
124
|
-
exit 1
|
|
125
|
-
fi
|
|
126
|
-
|
|
127
|
-
# Verify installation
|
|
128
|
-
for tool in "${missing[@]}"; do
|
|
129
|
-
if ! command -v "$tool" &>/dev/null; then
|
|
130
|
-
echo "Error: Failed to install $tool" >&2
|
|
131
|
-
exit 1
|
|
132
|
-
fi
|
|
133
|
-
done
|
|
123
|
+
if [[ ${#missing[@]} -eq 2 ]]; then
|
|
124
|
+
echo "" >&2
|
|
125
|
+
echo "Error: No benchmark tools available. Install at least one:" >&2
|
|
126
|
+
echo " sudo apt install sysbench fio" >&2
|
|
127
|
+
exit 1
|
|
134
128
|
fi
|
|
135
129
|
}
|
|
136
130
|
|
|
@@ -327,37 +321,53 @@ except Exception:
|
|
|
327
321
|
# -------------------------------------------------------
|
|
328
322
|
# Run all benchmarks and collect results
|
|
329
323
|
# Returns results as key=value pairs
|
|
330
|
-
#
|
|
324
|
+
# Skips benchmarks for missing tools (uses HAS_SYSBENCH/HAS_FIO)
|
|
325
|
+
# Exits 1 if any executed benchmark command failed (ERROR sentinel)
|
|
331
326
|
# -------------------------------------------------------
|
|
332
327
|
run_all_benchmarks() {
|
|
333
328
|
local failed_benchmarks=()
|
|
329
|
+
local step=0 total=0
|
|
334
330
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
local cpu_st
|
|
339
|
-
cpu_st=$(run_cpu_single)
|
|
340
|
-
[[ "$cpu_st" == "ERROR" ]] && failed_benchmarks+=("CPU single-thread")
|
|
331
|
+
# Count total benchmarks to run
|
|
332
|
+
[[ $HAS_SYSBENCH -eq 0 ]] && total=$((total + 4))
|
|
333
|
+
[[ $HAS_FIO -eq 0 ]] && total=$((total + 1))
|
|
341
334
|
|
|
342
|
-
echo "
|
|
343
|
-
local cpu_mt
|
|
344
|
-
cpu_mt=$(run_cpu_multi)
|
|
345
|
-
[[ "$cpu_mt" == "ERROR" ]] && failed_benchmarks+=("CPU multi-thread")
|
|
346
|
-
|
|
347
|
-
echo " [3/5] Memory read..." >&2
|
|
348
|
-
local mem_read
|
|
349
|
-
mem_read=$(run_mem_read)
|
|
350
|
-
[[ "$mem_read" == "ERROR" ]] && failed_benchmarks+=("Memory read")
|
|
335
|
+
echo "Running benchmarks..." >&2
|
|
351
336
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
337
|
+
local cpu_st="SKIPPED" cpu_mt="SKIPPED" mem_read="SKIPPED" mem_write="SKIPPED" disk_rr="SKIPPED"
|
|
338
|
+
|
|
339
|
+
if [[ $HAS_SYSBENCH -eq 0 ]]; then
|
|
340
|
+
step=$((step + 1))
|
|
341
|
+
echo " [$step/$total] CPU single-thread..." >&2
|
|
342
|
+
cpu_st=$(run_cpu_single)
|
|
343
|
+
[[ "$cpu_st" == "ERROR" ]] && failed_benchmarks+=("CPU single-thread")
|
|
344
|
+
|
|
345
|
+
step=$((step + 1))
|
|
346
|
+
echo " [$step/$total] CPU multi-thread..." >&2
|
|
347
|
+
cpu_mt=$(run_cpu_multi)
|
|
348
|
+
[[ "$cpu_mt" == "ERROR" ]] && failed_benchmarks+=("CPU multi-thread")
|
|
349
|
+
|
|
350
|
+
step=$((step + 1))
|
|
351
|
+
echo " [$step/$total] Memory read..." >&2
|
|
352
|
+
mem_read=$(run_mem_read)
|
|
353
|
+
[[ "$mem_read" == "ERROR" ]] && failed_benchmarks+=("Memory read")
|
|
354
|
+
|
|
355
|
+
step=$((step + 1))
|
|
356
|
+
echo " [$step/$total] Memory write..." >&2
|
|
357
|
+
mem_write=$(run_mem_write)
|
|
358
|
+
[[ "$mem_write" == "ERROR" ]] && failed_benchmarks+=("Memory write")
|
|
359
|
+
else
|
|
360
|
+
echo " Skipping sysbench tests (sysbench not installed)" >&2
|
|
361
|
+
fi
|
|
356
362
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
363
|
+
if [[ $HAS_FIO -eq 0 ]]; then
|
|
364
|
+
step=$((step + 1))
|
|
365
|
+
echo " [$step/$total] Disk random read..." >&2
|
|
366
|
+
disk_rr=$(run_disk_rand_read)
|
|
367
|
+
[[ "$disk_rr" == "ERROR" ]] && failed_benchmarks+=("Disk random read")
|
|
368
|
+
else
|
|
369
|
+
echo " Skipping fio tests (fio not installed)" >&2
|
|
370
|
+
fi
|
|
361
371
|
|
|
362
372
|
echo "Done." >&2
|
|
363
373
|
|
|
@@ -406,8 +416,10 @@ display_results() {
|
|
|
406
416
|
disk_rand_read_iops) label="Disk Random Read" ;;
|
|
407
417
|
*) label="$metric" ;;
|
|
408
418
|
esac
|
|
409
|
-
# Show N/A for ERROR values
|
|
410
|
-
if [[ "$value" == "
|
|
419
|
+
# Show N/A for ERROR values, show skipped for SKIPPED
|
|
420
|
+
if [[ "$value" == "SKIPPED" ]]; then
|
|
421
|
+
printf " %-30s %15s %s\n" "$label" "N/A" "(skipped)"
|
|
422
|
+
elif [[ "$value" == "ERROR" ]]; then
|
|
411
423
|
printf " %-30s %15s %s\n" "$label" "N/A" "(failed)"
|
|
412
424
|
else
|
|
413
425
|
printf " %-30s %15s %s\n" "$label" "$value" "$unit"
|
|
@@ -442,8 +454,8 @@ for line in sys.stdin:
|
|
|
442
454
|
parts = line.strip().split(None, 2)
|
|
443
455
|
if len(parts) >= 2:
|
|
444
456
|
metric = parts[0]
|
|
445
|
-
if parts[1]
|
|
446
|
-
results[metric] = {'value': None, 'unit':
|
|
457
|
+
if parts[1] in ('ERROR', 'SKIPPED'):
|
|
458
|
+
results[metric] = {'value': None, 'unit': parts[1]}
|
|
447
459
|
continue
|
|
448
460
|
value = float(parts[1])
|
|
449
461
|
unit = parts[2] if len(parts) > 2 else ''
|
|
@@ -476,8 +488,8 @@ try:
|
|
|
476
488
|
val = data['value']
|
|
477
489
|
unit = data.get('unit', '')
|
|
478
490
|
# Handle ERROR metrics saved with None value
|
|
479
|
-
if val is None or unit
|
|
480
|
-
print(f'{metric}
|
|
491
|
+
if val is None or unit in ('ERROR', 'SKIPPED'):
|
|
492
|
+
print(f'{metric} {unit} {unit}')
|
|
481
493
|
else:
|
|
482
494
|
print(f'{metric} {val} {unit}')
|
|
483
495
|
else:
|
|
@@ -523,16 +535,16 @@ display_comparison() {
|
|
|
523
535
|
*) label="$metric" ;;
|
|
524
536
|
esac
|
|
525
537
|
|
|
526
|
-
# Handle ERROR in current value — show N/A instead of bogus computation
|
|
527
|
-
if [[ "$value" == "ERROR" ]]; then
|
|
538
|
+
# Handle SKIPPED or ERROR in current value — show N/A instead of bogus computation
|
|
539
|
+
if [[ "$value" == "SKIPPED" || "$value" == "ERROR" ]]; then
|
|
528
540
|
local bval="${baseline_vals[$metric]:-N/A}"
|
|
529
541
|
printf " %-25s %12s %12s %12s %8s\n" "$label" "$bval" "N/A" "N/A" "N/A"
|
|
530
542
|
continue
|
|
531
543
|
fi
|
|
532
544
|
|
|
533
545
|
local bval="${baseline_vals[$metric]:-}"
|
|
534
|
-
# Handle missing or
|
|
535
|
-
if [[ -z "$bval" || "$bval" == "None" || "$bval" == "ERROR" ]]; then
|
|
546
|
+
# Handle missing, ERROR, or SKIPPED baseline — show N/A for comparison
|
|
547
|
+
if [[ -z "$bval" || "$bval" == "None" || "$bval" == "ERROR" || "$bval" == "SKIPPED" ]]; then
|
|
536
548
|
printf " %-25s %12s %12s %12s %8s\n" "$label" "N/A" "$value" "N/A" "N/A"
|
|
537
549
|
continue
|
|
538
550
|
fi
|
package/tunectl/__init__.py
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tunectl/cli.py
CHANGED
|
@@ -18,6 +18,7 @@ import subprocess
|
|
|
18
18
|
import sys
|
|
19
19
|
|
|
20
20
|
from tunectl import __version__
|
|
21
|
+
from tunectl.ui import print_banner, Pipeline
|
|
21
22
|
|
|
22
23
|
# Valid tier choices for plan/apply
|
|
23
24
|
VALID_TIERS = ("conservative", "balanced", "aggressive")
|
|
@@ -129,7 +130,8 @@ def _format_discover_output(data: dict) -> str:
|
|
|
129
130
|
("Virt type", data.get("virt_type", "unknown")),
|
|
130
131
|
]
|
|
131
132
|
for label, value in info_fields:
|
|
132
|
-
|
|
133
|
+
padded = f"{label + ':':>28}"
|
|
134
|
+
lines.append(f" {_cyan(padded)} {value}")
|
|
133
135
|
|
|
134
136
|
# --- Swap status ---
|
|
135
137
|
lines.append("")
|
|
@@ -141,11 +143,15 @@ def _format_discover_output(data: dict) -> str:
|
|
|
141
143
|
swap_total = data.get("swap_total_mb", 0)
|
|
142
144
|
|
|
143
145
|
if swap_configured:
|
|
144
|
-
|
|
145
|
-
lines.append(f" {_cyan(
|
|
146
|
-
|
|
146
|
+
padded = f"{'Configured:':>28}"
|
|
147
|
+
lines.append(f" {_cyan(padded)} {_green('yes')}")
|
|
148
|
+
padded = f"{'Type:':>28}"
|
|
149
|
+
lines.append(f" {_cyan(padded)} {swap_type}")
|
|
150
|
+
padded = f"{'Total:':>28}"
|
|
151
|
+
lines.append(f" {_cyan(padded)} {swap_total} MB")
|
|
147
152
|
else:
|
|
148
|
-
|
|
153
|
+
padded = f"{'Configured:':>28}"
|
|
154
|
+
lines.append(f" {_cyan(padded)} {_yellow('no')}")
|
|
149
155
|
|
|
150
156
|
# --- Key sysctl values ---
|
|
151
157
|
sysctl = data.get("sysctl_values", {})
|
|
@@ -173,7 +179,8 @@ def _format_discover_output(data: dict) -> str:
|
|
|
173
179
|
]
|
|
174
180
|
for param in key_params:
|
|
175
181
|
if param in sysctl:
|
|
176
|
-
|
|
182
|
+
padded = f"{param + ':':>42}"
|
|
183
|
+
lines.append(f" {_cyan(padded)} {sysctl[param]}")
|
|
177
184
|
|
|
178
185
|
# --- Mount options ---
|
|
179
186
|
mount_opts = data.get("mount_options", "")
|
|
@@ -186,6 +193,88 @@ def _format_discover_output(data: dict) -> str:
|
|
|
186
193
|
return "\n".join(lines)
|
|
187
194
|
|
|
188
195
|
|
|
196
|
+
# -------------------------------------------------------
|
|
197
|
+
# Interactive tier selection
|
|
198
|
+
# -------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
# Entry counts per tier (from tune-manifest.json tiered filtering)
|
|
201
|
+
_TIER_INFO = {
|
|
202
|
+
"conservative": {"entries": 51, "desc": "safe, minimal changes"},
|
|
203
|
+
"balanced": {"entries": 80, "desc": "recommended balance"},
|
|
204
|
+
"aggressive": {"entries": 86, "desc": "maximum performance"},
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _detect_ram_mb() -> int:
|
|
209
|
+
"""Run discover.sh and extract RAM in MB from the JSON output."""
|
|
210
|
+
rc, stdout, _stderr = _run_script("discover.sh", capture=True)
|
|
211
|
+
if rc != 0:
|
|
212
|
+
return 0
|
|
213
|
+
try:
|
|
214
|
+
data = json.loads(stdout)
|
|
215
|
+
return int(data.get("ram_mb", 0))
|
|
216
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _recommend_tier(ram_mb: int) -> str:
|
|
221
|
+
"""Return the recommended tier name based on system RAM."""
|
|
222
|
+
if ram_mb < 2048:
|
|
223
|
+
return "conservative"
|
|
224
|
+
elif ram_mb <= 8192:
|
|
225
|
+
return "balanced"
|
|
226
|
+
else:
|
|
227
|
+
return "aggressive"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _select_tier_interactive() -> str:
|
|
231
|
+
"""Auto-discover system RAM and present an interactive tier menu.
|
|
232
|
+
|
|
233
|
+
Returns the selected tier string. Uses only ``input()`` for prompts
|
|
234
|
+
(no external dependencies).
|
|
235
|
+
"""
|
|
236
|
+
ram_mb = _detect_ram_mb()
|
|
237
|
+
recommended = _recommend_tier(ram_mb)
|
|
238
|
+
|
|
239
|
+
# Map tier names to a stable ordered list
|
|
240
|
+
tier_order = ["conservative", "balanced", "aggressive"]
|
|
241
|
+
rec_idx = tier_order.index(recommended) # 0-based
|
|
242
|
+
default_choice = rec_idx + 1 # 1-based for display
|
|
243
|
+
|
|
244
|
+
# Header
|
|
245
|
+
if ram_mb > 0:
|
|
246
|
+
print(f"\nSystem RAM: {ram_mb} MB\n")
|
|
247
|
+
else:
|
|
248
|
+
print()
|
|
249
|
+
|
|
250
|
+
print("Select tuning tier:")
|
|
251
|
+
for i, tier in enumerate(tier_order, start=1):
|
|
252
|
+
info = _TIER_INFO[tier]
|
|
253
|
+
label = f"{tier:14s} ({info['entries']} entries - {info['desc']})"
|
|
254
|
+
if tier == recommended:
|
|
255
|
+
label = f"{tier:14s} ({info['entries']} entries - recommended for this system)"
|
|
256
|
+
print(f" {i}) {label}")
|
|
257
|
+
|
|
258
|
+
# Prompt
|
|
259
|
+
print()
|
|
260
|
+
raw = input(f"Enter choice [1-3] (default: {default_choice}): ").strip()
|
|
261
|
+
|
|
262
|
+
if raw == "":
|
|
263
|
+
chosen = default_choice
|
|
264
|
+
else:
|
|
265
|
+
try:
|
|
266
|
+
chosen = int(raw)
|
|
267
|
+
except ValueError:
|
|
268
|
+
chosen = default_choice
|
|
269
|
+
|
|
270
|
+
if chosen < 1 or chosen > 3:
|
|
271
|
+
chosen = default_choice
|
|
272
|
+
|
|
273
|
+
selected = tier_order[chosen - 1]
|
|
274
|
+
print(f"\n→ Selected tier: {selected}\n")
|
|
275
|
+
return selected
|
|
276
|
+
|
|
277
|
+
|
|
189
278
|
# -------------------------------------------------------
|
|
190
279
|
# Subcommand handlers
|
|
191
280
|
# -------------------------------------------------------
|
|
@@ -194,40 +283,60 @@ def cmd_discover(args: argparse.Namespace) -> int:
|
|
|
194
283
|
"""Run discover.sh and show formatted system info."""
|
|
195
284
|
rc, stdout, stderr = _run_script("discover.sh", capture=True)
|
|
196
285
|
|
|
197
|
-
|
|
198
|
-
# Pass through errors from the script
|
|
199
|
-
if stderr:
|
|
200
|
-
print(stderr, end="", file=sys.stderr)
|
|
201
|
-
if stdout:
|
|
202
|
-
print(stdout, end="")
|
|
203
|
-
return rc
|
|
204
|
-
|
|
205
|
-
# --json: emit raw JSON
|
|
286
|
+
# --json: emit raw JSON (no banner/pipeline)
|
|
206
287
|
if getattr(args, "json", False):
|
|
288
|
+
if rc != 0:
|
|
289
|
+
if stderr:
|
|
290
|
+
print(stderr, end="", file=sys.stderr)
|
|
207
291
|
print(stdout, end="")
|
|
208
|
-
return
|
|
292
|
+
return rc
|
|
209
293
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
294
|
+
with Pipeline("Discover") as p:
|
|
295
|
+
if rc != 0:
|
|
296
|
+
if stderr:
|
|
297
|
+
p.fail(stderr.strip().split("\n")[0])
|
|
298
|
+
return rc
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
data = json.loads(stdout)
|
|
302
|
+
except json.JSONDecodeError as exc:
|
|
303
|
+
p.fail(f"Failed to parse discover output: {exc}")
|
|
304
|
+
print(stdout, end="")
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
p.step(f"OS: {data.get('os_version', 'unknown')}")
|
|
308
|
+
p.step(f"Kernel: {data.get('kernel_version', 'unknown')}")
|
|
309
|
+
p.step(f"RAM: {data.get('ram_mb', 0)} MB ({data.get('cpu_count', 0)} CPUs)")
|
|
310
|
+
|
|
311
|
+
swap = data.get("swap_configured", False)
|
|
312
|
+
if swap:
|
|
313
|
+
p.step(f"Swap: {data.get('swap_type', 'unknown')} ({data.get('swap_total_mb', 0)} MB)")
|
|
314
|
+
else:
|
|
315
|
+
p.step("Swap: not configured")
|
|
316
|
+
|
|
317
|
+
p.step(f"Disk: {data.get('disk_type', 'unknown')} ({data.get('virt_type', 'unknown')})")
|
|
318
|
+
p.ok("Discovery complete")
|
|
219
319
|
|
|
220
|
-
print(_format_discover_output(data))
|
|
221
320
|
return 0
|
|
222
321
|
|
|
223
322
|
|
|
224
323
|
def cmd_plan(args: argparse.Namespace) -> int:
|
|
225
324
|
"""Run tune.sh in dry-run mode for the specified tier."""
|
|
325
|
+
if args.tier is None:
|
|
326
|
+
args.tier = _select_tier_interactive()
|
|
327
|
+
with Pipeline(f"Plan ({args.tier})") as p:
|
|
328
|
+
p.step(f"Previewing {args.tier} tier changes...")
|
|
329
|
+
print()
|
|
226
330
|
return _run_script("tune.sh", ["--dry-run", "--tier", args.tier])
|
|
227
331
|
|
|
228
332
|
|
|
229
333
|
def cmd_apply(args: argparse.Namespace) -> int:
|
|
230
334
|
"""Run tune.sh in apply mode for the specified tier."""
|
|
335
|
+
if args.tier is None:
|
|
336
|
+
args.tier = _select_tier_interactive()
|
|
337
|
+
with Pipeline(f"Apply ({args.tier})") as p:
|
|
338
|
+
p.step(f"Applying {args.tier} tier tuning...")
|
|
339
|
+
print()
|
|
231
340
|
return _run_script("tune.sh", ["--apply", "--tier", args.tier])
|
|
232
341
|
|
|
233
342
|
|
|
@@ -236,13 +345,26 @@ def cmd_rollback(args: argparse.Namespace) -> int:
|
|
|
236
345
|
script_args: list[str] = []
|
|
237
346
|
if args.list:
|
|
238
347
|
script_args.append("--list")
|
|
348
|
+
with Pipeline("Rollback (list)") as p:
|
|
349
|
+
p.step("Listing available backups...")
|
|
350
|
+
print()
|
|
239
351
|
elif args.backup:
|
|
240
352
|
script_args.extend(["--backup", args.backup])
|
|
353
|
+
with Pipeline(f"Rollback ({args.backup})") as p:
|
|
354
|
+
p.step(f"Restoring from backup {args.backup}...")
|
|
355
|
+
print()
|
|
356
|
+
else:
|
|
357
|
+
with Pipeline("Rollback (latest)") as p:
|
|
358
|
+
p.step("Restoring from most recent backup...")
|
|
359
|
+
print()
|
|
241
360
|
return _run_script("rollback.sh", script_args)
|
|
242
361
|
|
|
243
362
|
|
|
244
363
|
def cmd_audit(args: argparse.Namespace) -> int:
|
|
245
364
|
"""Run audit.sh to verify applied tuning."""
|
|
365
|
+
with Pipeline("Audit") as p:
|
|
366
|
+
p.step("Verifying 86 manifest entries against live system...")
|
|
367
|
+
print()
|
|
246
368
|
return _run_script("audit.sh")
|
|
247
369
|
|
|
248
370
|
|
|
@@ -251,8 +373,15 @@ def cmd_benchmark(args: argparse.Namespace) -> int:
|
|
|
251
373
|
script_args: list[str] = []
|
|
252
374
|
if args.baseline:
|
|
253
375
|
script_args.append("--baseline")
|
|
376
|
+
label = "Benchmark (baseline)"
|
|
254
377
|
elif args.compare:
|
|
255
378
|
script_args.append("--compare")
|
|
379
|
+
label = "Benchmark (compare)"
|
|
380
|
+
else:
|
|
381
|
+
label = "Benchmark"
|
|
382
|
+
with Pipeline(label) as p:
|
|
383
|
+
p.step("Running sysbench + fio benchmarks...")
|
|
384
|
+
print()
|
|
256
385
|
return _run_script("benchmark.sh", script_args)
|
|
257
386
|
|
|
258
387
|
|
|
@@ -327,9 +456,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
327
456
|
sub_plan.add_argument(
|
|
328
457
|
"--tier",
|
|
329
458
|
type=_validate_tier,
|
|
330
|
-
required=
|
|
459
|
+
required=False,
|
|
460
|
+
default=None,
|
|
331
461
|
metavar="TIER",
|
|
332
|
-
help="Tuning tier: conservative, balanced, or aggressive",
|
|
462
|
+
help="Tuning tier: conservative, balanced, or aggressive (interactive if omitted)",
|
|
333
463
|
)
|
|
334
464
|
sub_plan.set_defaults(func=cmd_plan)
|
|
335
465
|
|
|
@@ -345,9 +475,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
345
475
|
sub_apply.add_argument(
|
|
346
476
|
"--tier",
|
|
347
477
|
type=_validate_tier,
|
|
348
|
-
required=
|
|
478
|
+
required=False,
|
|
479
|
+
default=None,
|
|
349
480
|
metavar="TIER",
|
|
350
|
-
help="Tuning tier: conservative, balanced, or aggressive",
|
|
481
|
+
help="Tuning tier: conservative, balanced, or aggressive (interactive if omitted)",
|
|
351
482
|
)
|
|
352
483
|
sub_apply.set_defaults(func=cmd_apply)
|
|
353
484
|
|
|
@@ -422,6 +553,7 @@ def main() -> None:
|
|
|
422
553
|
args = parser.parse_args()
|
|
423
554
|
|
|
424
555
|
if args.command is None:
|
|
556
|
+
print_banner()
|
|
425
557
|
parser.print_help()
|
|
426
558
|
sys.exit(2)
|
|
427
559
|
|
package/tunectl/ui.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""tunectl UI — Unicode banner and pipeline output in the opencode style.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- print_banner() — block-letter "tunectl" banner with two-tone coloring
|
|
5
|
+
- Pipeline context — ┌/│/●/◇/▲/└ vertical status output
|
|
6
|
+
|
|
7
|
+
Style reference: opencode CLI (https://github.com/nicholasgriffintn/opencode)
|
|
8
|
+
- First half "tune" rendered in dim gray (ANSI 90)
|
|
9
|
+
- Second half "ctl" rendered in bright white (ANSI 0/reset)
|
|
10
|
+
- Hollow letter interiors use 256-color backgrounds (235/238)
|
|
11
|
+
- Shadow accents on bottom row use fg 235
|
|
12
|
+
- Pipeline uses box-drawing characters with double-space indent
|
|
13
|
+
|
|
14
|
+
Letter alphabet (4-wide cells, 4 rows each):
|
|
15
|
+
Row 0 = top accent (only special letters like 'l' use ▄)
|
|
16
|
+
Row 1 = upper body (█▀▀█, █▀▀▄, █▀▀▀, etc.)
|
|
17
|
+
Row 2 = middle body (█ █, █▀▀▀, █ , etc.) — hollows get bg fill
|
|
18
|
+
Row 3 = bottom (▀▀▀▀, █▀▀▀, etc.)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# ANSI helpers
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def _use_color() -> bool:
|
|
28
|
+
return sys.stdout.isatty()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _esc(code: str) -> str:
|
|
32
|
+
return f"\033[{code}m" if _use_color() else ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
R = property(lambda self: _esc("0")) # reset
|
|
36
|
+
DIM = property(lambda self: _esc("90")) # dim gray
|
|
37
|
+
|
|
38
|
+
# We use module-level functions so the color check happens at call time.
|
|
39
|
+
|
|
40
|
+
def _r():
|
|
41
|
+
return _esc("0")
|
|
42
|
+
|
|
43
|
+
def _d():
|
|
44
|
+
return _esc("90")
|
|
45
|
+
|
|
46
|
+
def _bg_dim():
|
|
47
|
+
return _esc("48;5;235")
|
|
48
|
+
|
|
49
|
+
def _bg_bright():
|
|
50
|
+
return _esc("48;5;238")
|
|
51
|
+
|
|
52
|
+
def _fg_shadow():
|
|
53
|
+
return _esc("38;5;235")
|
|
54
|
+
|
|
55
|
+
def _green():
|
|
56
|
+
return _esc("32")
|
|
57
|
+
|
|
58
|
+
def _yellow():
|
|
59
|
+
return _esc("33")
|
|
60
|
+
|
|
61
|
+
def _red():
|
|
62
|
+
return _esc("31")
|
|
63
|
+
|
|
64
|
+
def _cyan():
|
|
65
|
+
return _esc("36")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Block-letter alphabet (4 cols x 4 rows per glyph)
|
|
70
|
+
#
|
|
71
|
+
# Each letter is a tuple of 4 strings. Characters:
|
|
72
|
+
# █ = U+2588 full block ▀ = U+2580 upper half
|
|
73
|
+
# ▄ = U+2584 lower half ' ' = space (hollow interior)
|
|
74
|
+
#
|
|
75
|
+
# Row 0 is the top-accent row (usually blank).
|
|
76
|
+
# Hollow interiors (spaces inside row 1-2) get background-color fills
|
|
77
|
+
# at render time — the alphabet stores plain spaces.
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
LETTERS = {
|
|
81
|
+
"t": (
|
|
82
|
+
" ",
|
|
83
|
+
"█▀▀█",
|
|
84
|
+
"▀██▀",
|
|
85
|
+
" ▀▀ ",
|
|
86
|
+
),
|
|
87
|
+
"u": (
|
|
88
|
+
" ",
|
|
89
|
+
"█ █",
|
|
90
|
+
"█ █",
|
|
91
|
+
"▀▀▀█",
|
|
92
|
+
),
|
|
93
|
+
"n": (
|
|
94
|
+
" ",
|
|
95
|
+
"█▀▀▄",
|
|
96
|
+
"█ █",
|
|
97
|
+
"▀ ▀",
|
|
98
|
+
),
|
|
99
|
+
"e": (
|
|
100
|
+
" ",
|
|
101
|
+
"█▀▀█",
|
|
102
|
+
"█▀▀▀",
|
|
103
|
+
"▀▀▀▀",
|
|
104
|
+
),
|
|
105
|
+
"c": (
|
|
106
|
+
" ",
|
|
107
|
+
"█▀▀▀",
|
|
108
|
+
"█ ",
|
|
109
|
+
"▀▀▀▀",
|
|
110
|
+
),
|
|
111
|
+
"l": (
|
|
112
|
+
" ▄",
|
|
113
|
+
"█▀▀█",
|
|
114
|
+
"█ █",
|
|
115
|
+
"▀▀▀▀",
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Banner renderer
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
# "tune" = dim half, "ctl" = bright half
|
|
124
|
+
_DIM_LETTERS = "tune"
|
|
125
|
+
_BRIGHT_LETTERS = "ctl"
|
|
126
|
+
_WORD = _DIM_LETTERS + _BRIGHT_LETTERS # "tunectl"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _render_char(ch: str, fg_fn, bg_fn, shadow_fn, row: int) -> str:
|
|
130
|
+
"""Render one character of a letter cell with proper ANSI coloring.
|
|
131
|
+
|
|
132
|
+
fg_fn — returns the foreground escape for solid glyphs (█ ▀ ▄)
|
|
133
|
+
bg_fn — returns the background escape for hollow interiors (spaces)
|
|
134
|
+
shadow_fn — returns the fg escape for shadow accents on the bottom row
|
|
135
|
+
row — which row (0-3) we are rendering
|
|
136
|
+
"""
|
|
137
|
+
r = _r()
|
|
138
|
+
|
|
139
|
+
if ch == " ":
|
|
140
|
+
# Hollow interior — fill with background color (rows 1-2 only)
|
|
141
|
+
if row in (1, 2):
|
|
142
|
+
return f"{bg_fn()}{ch}{r}"
|
|
143
|
+
return ch
|
|
144
|
+
|
|
145
|
+
if ch == "▀" and row == 3:
|
|
146
|
+
# Bottom row: check if this is an interior accent (shadow)
|
|
147
|
+
# We handle this at a higher level; default to fg
|
|
148
|
+
return f"{fg_fn()}{ch}{r}"
|
|
149
|
+
|
|
150
|
+
# Solid glyph
|
|
151
|
+
return f"{fg_fn()}{ch}{r}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _render_row(row_idx: int) -> str:
|
|
155
|
+
"""Render one row of the full 'tunectl' banner."""
|
|
156
|
+
r = _r()
|
|
157
|
+
parts = []
|
|
158
|
+
|
|
159
|
+
for i, letter_ch in enumerate(_WORD):
|
|
160
|
+
glyph = LETTERS[letter_ch]
|
|
161
|
+
row_str = glyph[row_idx]
|
|
162
|
+
|
|
163
|
+
if letter_ch in _DIM_LETTERS and _WORD.index(letter_ch) == i and i < len(_DIM_LETTERS):
|
|
164
|
+
fg_fn = _d
|
|
165
|
+
bg_fn = _bg_dim
|
|
166
|
+
shadow_fn = _fg_shadow
|
|
167
|
+
elif i >= len(_DIM_LETTERS):
|
|
168
|
+
fg_fn = _r
|
|
169
|
+
bg_fn = _bg_bright
|
|
170
|
+
shadow_fn = _r
|
|
171
|
+
else:
|
|
172
|
+
# Duplicate letter in dim section
|
|
173
|
+
fg_fn = _d
|
|
174
|
+
bg_fn = _bg_dim
|
|
175
|
+
shadow_fn = _fg_shadow
|
|
176
|
+
|
|
177
|
+
cell = ""
|
|
178
|
+
for j, ch in enumerate(row_str):
|
|
179
|
+
if ch == " " and row_idx in (1, 2):
|
|
180
|
+
cell += f"{bg_fn()}{ch}{_r()}"
|
|
181
|
+
elif ch == "▀" and row_idx == 3:
|
|
182
|
+
# Shadow: inner chars of dim-half bottom row get subtle shadow
|
|
183
|
+
if fg_fn == _d and 0 < j < 3:
|
|
184
|
+
cell += f"{shadow_fn()}{ch}{_r()}"
|
|
185
|
+
else:
|
|
186
|
+
cell += f"{fg_fn()}{ch}{_r()}"
|
|
187
|
+
else:
|
|
188
|
+
if ch == " ":
|
|
189
|
+
cell += ch
|
|
190
|
+
else:
|
|
191
|
+
cell += f"{fg_fn()}{ch}{_r()}"
|
|
192
|
+
|
|
193
|
+
parts.append(cell)
|
|
194
|
+
|
|
195
|
+
return " " + " ".join(parts)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def banner() -> str:
|
|
199
|
+
"""Return the full tunectl banner string (4 rows + surrounding newlines)."""
|
|
200
|
+
lines = [""]
|
|
201
|
+
for row_idx in range(4):
|
|
202
|
+
lines.append(_render_row(row_idx))
|
|
203
|
+
lines.append("")
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def print_banner() -> None:
|
|
208
|
+
"""Print the tunectl banner to stdout."""
|
|
209
|
+
print(banner())
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Pipeline output (┌ │ ● ◇ ▲ └)
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
_SYM_START = "\u250C" # ┌
|
|
217
|
+
_SYM_PIPE = "\u2502" # │
|
|
218
|
+
_SYM_DOT = "\u25CF" # ●
|
|
219
|
+
_SYM_OK = "\u25C7" # ◇
|
|
220
|
+
_SYM_WARN = "\u25B2" # ▲
|
|
221
|
+
_SYM_END = "\u2514" # └
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Pipeline:
|
|
225
|
+
"""Context-manager for styled vertical pipeline output.
|
|
226
|
+
|
|
227
|
+
Usage:
|
|
228
|
+
with Pipeline("Discover") as p:
|
|
229
|
+
p.step("OS: Ubuntu 24.04 LTS")
|
|
230
|
+
p.step("Kernel: 6.8.0-101-generic")
|
|
231
|
+
p.ok("Discovery complete")
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self, title: str, show_banner: bool = True):
|
|
235
|
+
self.title = title
|
|
236
|
+
self.show_banner = show_banner
|
|
237
|
+
self._started = False
|
|
238
|
+
|
|
239
|
+
def __enter__(self):
|
|
240
|
+
if self.show_banner:
|
|
241
|
+
print_banner()
|
|
242
|
+
print(f"{_SYM_START} {self.title}")
|
|
243
|
+
self._started = True
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
247
|
+
if exc_type is not None:
|
|
248
|
+
self.fail(f"Error: {exc_val}")
|
|
249
|
+
self._end()
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def _line(self):
|
|
253
|
+
print(_SYM_PIPE)
|
|
254
|
+
|
|
255
|
+
def step(self, msg: str) -> None:
|
|
256
|
+
self._line()
|
|
257
|
+
c = _cyan() if _use_color() else ""
|
|
258
|
+
r = _r() if _use_color() else ""
|
|
259
|
+
print(f"{_SYM_DOT} {msg}")
|
|
260
|
+
|
|
261
|
+
def ok(self, msg: str) -> None:
|
|
262
|
+
self._line()
|
|
263
|
+
g = _green() if _use_color() else ""
|
|
264
|
+
r = _r() if _use_color() else ""
|
|
265
|
+
print(f"{g}{_SYM_OK}{r} {msg}")
|
|
266
|
+
|
|
267
|
+
def warn(self, msg: str) -> None:
|
|
268
|
+
self._line()
|
|
269
|
+
y = _yellow() if _use_color() else ""
|
|
270
|
+
r = _r() if _use_color() else ""
|
|
271
|
+
print(f"{y}{_SYM_WARN}{r} {msg}")
|
|
272
|
+
|
|
273
|
+
def fail(self, msg: str) -> None:
|
|
274
|
+
self._line()
|
|
275
|
+
rd = _red() if _use_color() else ""
|
|
276
|
+
r = _r() if _use_color() else ""
|
|
277
|
+
print(f"{rd}{_SYM_DOT}{r} {msg}")
|
|
278
|
+
|
|
279
|
+
def _end(self) -> None:
|
|
280
|
+
self._line()
|
|
281
|
+
print(f"{_SYM_END} Done")
|
|
282
|
+
print()
|