feat: add catlog command, agent flag reorder, and TUI polish
- Add `coinhunter catlog` with limit/offset pagination for audit logs - Optimize audit log reading with deque to avoid loading all history - Allow `-a/--agent` flag after subcommands - Fix upgrade spinner artifact and empty line issues - Render audit log TUI as timeline with low-saturation event colors - Convert audit timestamps to local timezone in TUI - Remove futures-related capabilities - Add conda environment.yml for development - Bump version to 2.0.9 and update README Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,24 @@ def _fmt_number(value: Any) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _fmt_local_ts(ts: str) -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return ts
|
||||
|
||||
|
||||
def _event_color(event: str) -> str:
|
||||
if "failed" in event or "error" in event:
|
||||
return f"{_DIM}{_RED}"
|
||||
if event.startswith("trade"):
|
||||
return f"{_DIM}{_GREEN}"
|
||||
if event.startswith("opportunity"):
|
||||
return f"{_DIM}{_YELLOW}"
|
||||
return _DIM
|
||||
|
||||
|
||||
def _is_large_dataset(payload: Any, threshold: int = 8) -> bool:
|
||||
if isinstance(payload, dict):
|
||||
for value in payload.values():
|
||||
@@ -281,7 +299,9 @@ def _render_tui(payload: Any) -> None:
|
||||
|
||||
if "klines" in payload:
|
||||
rows = payload["klines"]
|
||||
print(f"\n{_BOLD}{_CYAN} KLINES {_RESET} interval={payload.get('interval')} limit={payload.get('limit')} count={len(rows)}")
|
||||
print(
|
||||
f"\n{_BOLD}{_CYAN} KLINES {_RESET} interval={payload.get('interval')} limit={payload.get('limit')} count={len(rows)}"
|
||||
)
|
||||
display_rows = rows[:10]
|
||||
table_rows = []
|
||||
for r in display_rows:
|
||||
@@ -325,8 +345,12 @@ def _render_tui(payload: Any) -> None:
|
||||
for i, r in enumerate(rows, 1):
|
||||
score = r.get("score", 0)
|
||||
action = r.get("action", "")
|
||||
action_color = _GREEN if action == "add" else _YELLOW if action == "hold" else _RED if action == "exit" else _CYAN
|
||||
print(f" {i}. {_BOLD}{r.get('symbol', '')}{_RESET} action={_color(action, action_color)} score={score:.4f}")
|
||||
action_color = (
|
||||
_GREEN if action == "add" else _YELLOW if action == "hold" else _RED if action == "exit" else _CYAN
|
||||
)
|
||||
print(
|
||||
f" {i}. {_BOLD}{r.get('symbol', '')}{_RESET} action={_color(action, action_color)} score={score:.4f}"
|
||||
)
|
||||
for reason in r.get("reasons", []):
|
||||
print(f" · {reason}")
|
||||
metrics = r.get("metrics", {})
|
||||
@@ -340,9 +364,9 @@ def _render_tui(payload: Any) -> None:
|
||||
stdout = payload.get("stdout", "")
|
||||
stderr = payload.get("stderr", "")
|
||||
if rc == 0:
|
||||
print(f"\n{_GREEN}✓{_RESET} Update completed")
|
||||
print(f"{_GREEN}✓{_RESET} Update completed")
|
||||
else:
|
||||
print(f"\n{_RED}✗{_RESET} Update failed (exit code {rc})")
|
||||
print(f"{_RED}✗{_RESET} Update failed (exit code {rc})")
|
||||
if stdout:
|
||||
for line in stdout.strip().splitlines():
|
||||
print(f" {line}")
|
||||
@@ -352,6 +376,29 @@ def _render_tui(payload: Any) -> None:
|
||||
print(f" {line}")
|
||||
return
|
||||
|
||||
if "entries" in payload:
|
||||
rows = payload["entries"]
|
||||
print(f"\n{_BOLD}{_CYAN} AUDIT LOG {_RESET}")
|
||||
if not rows:
|
||||
print(" (no audit entries)")
|
||||
return
|
||||
for r in rows:
|
||||
ts = _fmt_local_ts(r.get("timestamp", ""))
|
||||
event = r.get("event", "")
|
||||
detail_parts: list[str] = []
|
||||
for key in ("symbol", "side", "qty", "quote_amount", "order_type", "status", "dry_run", "error"):
|
||||
val = r.get(key)
|
||||
if val is not None:
|
||||
detail_parts.append(f"{key}={val}")
|
||||
if not detail_parts:
|
||||
for key, val in r.items():
|
||||
if key not in ("timestamp", "event") and not isinstance(val, (dict, list)):
|
||||
detail_parts.append(f"{key}={val}")
|
||||
print(f"\n {_DIM}{ts}{_RESET} {_event_color(event)}{event}{_RESET}")
|
||||
if detail_parts:
|
||||
print(f" {' '.join(detail_parts)}")
|
||||
return
|
||||
|
||||
if "created_or_updated" in payload:
|
||||
print(f"\n{_BOLD}{_CYAN} INITIALIZED {_RESET}")
|
||||
print(f" Root: {payload.get('root', '')}")
|
||||
@@ -485,7 +532,10 @@ def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]:
|
||||
rc_path = _zshrc_path()
|
||||
fpath_line = "fpath+=(~/.zsh/completions)"
|
||||
if not _rc_contains(rc_path, fpath_line):
|
||||
rc_path.write_text(fpath_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else fpath_line + "\n", encoding="utf-8")
|
||||
rc_path.write_text(
|
||||
fpath_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else fpath_line + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
hint = "Added fpath+=(~/.zsh/completions) to ~/.zshrc; restart your terminal or run 'compinit'"
|
||||
else:
|
||||
hint = "Run 'compinit' or restart your terminal to activate completions"
|
||||
@@ -497,7 +547,10 @@ def install_shell_completion(parser: argparse.ArgumentParser) -> dict[str, Any]:
|
||||
rc_path = _bashrc_path()
|
||||
source_line = '[[ -r "~/.local/share/bash-completion/completions/coinhunter" ]] && . "~/.local/share/bash-completion/completions/coinhunter"'
|
||||
if not _rc_contains(rc_path, source_line):
|
||||
rc_path.write_text(source_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else source_line + "\n", encoding="utf-8")
|
||||
rc_path.write_text(
|
||||
source_line + "\n" + rc_path.read_text(encoding="utf-8") if rc_path.exists() else source_line + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
hint = "Added bash completion source line to ~/.bashrc; restart your terminal"
|
||||
else:
|
||||
hint = "Restart your terminal or source ~/.bashrc to activate completions"
|
||||
|
||||
Reference in New Issue
Block a user