feat: add request table preferences
This commit is contained in:
@@ -50,6 +50,7 @@ Configuration:
|
|||||||
- `--install`
|
- `--install`
|
||||||
- `--refresh-seconds` / `CODEX_RETRY_GATEWAY_TUI_REFRESH_SECONDS`
|
- `--refresh-seconds` / `CODEX_RETRY_GATEWAY_TUI_REFRESH_SECONDS`
|
||||||
- `--timeout` / `CODEX_RETRY_GATEWAY_TUI_TIMEOUT`
|
- `--timeout` / `CODEX_RETRY_GATEWAY_TUI_TIMEOUT`
|
||||||
|
- request-table preferences auto-save to `~/.config/codex-retry-gateway-tui/request-table.json`
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ uv run codex-retry-gateway-tui
|
|||||||
Views:
|
Views:
|
||||||
|
|
||||||
- overview: current gateway status and summary counts
|
- overview: current gateway status and summary counts
|
||||||
- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, cached ratio, retry note, and `usage_last_updated_at`
|
- requests: recent requests, response/request/thread IDs, timing, effort, reasoning tokens, usage, current retry round like `3(2)`, retry note, and `usage_last_updated_at`
|
||||||
- logs: recent gateway logs
|
- logs: recent gateway logs
|
||||||
- profiles: saved profiles, active profile default selection, profile actions
|
- profiles: saved profiles, active profile default selection, profile actions
|
||||||
|
|
||||||
@@ -81,14 +82,20 @@ Controls:
|
|||||||
- `2` requests
|
- `2` requests
|
||||||
- `3` logs
|
- `3` logs
|
||||||
- `4` profiles
|
- `4` profiles
|
||||||
|
- `,` / `.` select request-table column for visibility/sort actions
|
||||||
|
- `v` toggle the selected requests column visible/hidden
|
||||||
|
- `c` sort requests by the selected column
|
||||||
|
- `C` toggle requests sort ascending/descending
|
||||||
- `e` edit selected profile in `$VISUAL` / `$EDITOR`
|
- `e` edit selected profile in `$VISUAL` / `$EDITOR`
|
||||||
- `/` filter
|
- `/` filter
|
||||||
- `r` refresh
|
- `r` refresh
|
||||||
|
- `m` switch request table compact/wide width profile
|
||||||
|
- `[` / `]` narrow or widen the requests usage column
|
||||||
- `p` probe selected profile
|
- `p` probe selected profile
|
||||||
- `s` switch to selected profile
|
- `s` switch to selected profile
|
||||||
- `w` save selected profile snapshot
|
- `w` save selected profile snapshot
|
||||||
- `d` delete selected inactive profile
|
- `d` delete selected inactive profile
|
||||||
- `o` open selected profile upstream URL
|
- `u` open selected profile upstream URL
|
||||||
|
|
||||||
This client uses only the public gateway admin API and does not need SSH or secrets.
|
This client uses only the public gateway admin API and does not need SSH or secrets.
|
||||||
|
|
||||||
|
|||||||
+441
-31
@@ -29,6 +29,7 @@ DEFAULT_API_URL = DEFAULT_GATEWAY_URL
|
|||||||
DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url"
|
DEFAULT_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/api-url"
|
||||||
DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url"
|
DEFAULT_STATUS_CONFIG_FILE = "~/.config/codex-retry-gateway-tui/status-url"
|
||||||
DEFAULT_ACCESS_KEY_FILE = "~/.config/codex-retry-gateway-tui/access-key"
|
DEFAULT_ACCESS_KEY_FILE = "~/.config/codex-retry-gateway-tui/access-key"
|
||||||
|
DEFAULT_REQUEST_TABLE_PREFERENCES_FILE = "~/.config/codex-retry-gateway-tui/request-table.json"
|
||||||
DEFAULT_GATEWAY_STATE_FILE = "~/.codex-retry-gateway/state.json"
|
DEFAULT_GATEWAY_STATE_FILE = "~/.codex-retry-gateway/state.json"
|
||||||
DEFAULT_GATEWAY_JSON_CONFIG_FILE = "~/.codex-retry-gateway/config/config.json"
|
DEFAULT_GATEWAY_JSON_CONFIG_FILE = "~/.codex-retry-gateway/config/config.json"
|
||||||
DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui/raw/branch/main/pyproject.toml"
|
DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui/raw/branch/main/pyproject.toml"
|
||||||
@@ -50,6 +51,33 @@ DEFAULT_PROFILE_ENDPOINTS = [
|
|||||||
"/v1/responses",
|
"/v1/responses",
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
]
|
]
|
||||||
|
REQUEST_TABLE_COLUMNS = (
|
||||||
|
{"key": "seq", "label": "Seq", "width_key": "seq"},
|
||||||
|
{"key": "req_id", "label": "Req ID", "width_key": "req_id"},
|
||||||
|
{"key": "resp_id", "label": "Resp ID", "width_key": "resp_id"},
|
||||||
|
{"key": "thread", "label": "Thread", "width_key": "thread"},
|
||||||
|
{"key": "started", "label": "Started", "width_key": "started"},
|
||||||
|
{"key": "status", "label": "Status", "width_key": "status"},
|
||||||
|
{"key": "path", "label": "Path", "width_key": "path"},
|
||||||
|
{"key": "model", "label": "Model", "width_key": "model"},
|
||||||
|
{"key": "effort", "label": "Effort", "width_key": "effort"},
|
||||||
|
{"key": "reasoning", "label": "R.Tok", "width_key": "reasoning"},
|
||||||
|
{"key": "usage", "label": "Usage", "width_key": "usage"},
|
||||||
|
{"key": "req_bytes", "label": "Req Size", "width_key": "req"},
|
||||||
|
{"key": "resp_bytes", "label": "Resp Size", "width_key": "resp"},
|
||||||
|
{"key": "chunks", "label": "Chunks", "width_key": "chunks"},
|
||||||
|
{"key": "first", "label": "First", "width_key": "first"},
|
||||||
|
{"key": "duration", "label": "Duration", "width_key": "duration"},
|
||||||
|
{"key": "updated", "label": "Updated", "width_key": "updated"},
|
||||||
|
{"key": "note", "label": "Note", "width_key": "note"},
|
||||||
|
{"key": "round", "label": "Round", "width_key": "round"},
|
||||||
|
)
|
||||||
|
REQUEST_TABLE_COLUMN_BY_KEY = {column["key"]: column for column in REQUEST_TABLE_COLUMNS}
|
||||||
|
REQUEST_TABLE_COLUMN_KEYS = tuple(column["key"] for column in REQUEST_TABLE_COLUMNS)
|
||||||
|
REQUEST_TABLE_COLUMN_KEY_SET = set(REQUEST_TABLE_COLUMN_KEYS)
|
||||||
|
REQUEST_TABLE_CELL_INDEX = {key: index for index, key in enumerate(REQUEST_TABLE_COLUMN_KEYS)}
|
||||||
|
DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS = REQUEST_TABLE_COLUMN_KEYS
|
||||||
|
DEFAULT_REQUEST_TABLE_SORT_COLUMN = "seq"
|
||||||
INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git"
|
INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/codex-retry-gateway-tui.git"
|
||||||
INSTALL_COMMAND_ARGS = [
|
INSTALL_COMMAND_ARGS = [
|
||||||
"uv",
|
"uv",
|
||||||
@@ -148,6 +176,15 @@ def access_key_file_path() -> Path:
|
|||||||
return Path(os.environ.get("CODEX_RETRY_GATEWAY_TUI_ACCESS_KEY_FILE", DEFAULT_ACCESS_KEY_FILE)).expanduser()
|
return Path(os.environ.get("CODEX_RETRY_GATEWAY_TUI_ACCESS_KEY_FILE", DEFAULT_ACCESS_KEY_FILE)).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def request_table_preferences_file_path() -> Path:
|
||||||
|
return Path(
|
||||||
|
os.environ.get(
|
||||||
|
"CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE",
|
||||||
|
DEFAULT_REQUEST_TABLE_PREFERENCES_FILE,
|
||||||
|
)
|
||||||
|
).expanduser()
|
||||||
|
|
||||||
|
|
||||||
def default_status_url() -> str:
|
def default_status_url() -> str:
|
||||||
return configured_url(
|
return configured_url(
|
||||||
("CODEX_RETRY_GATEWAY_TUI_STATUS_URL",),
|
("CODEX_RETRY_GATEWAY_TUI_STATUS_URL",),
|
||||||
@@ -164,6 +201,57 @@ def default_access_key() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request_table_visible_columns(value: Any) -> list[str]:
|
||||||
|
requested = []
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
requested = [str(item or "").strip() for item in value]
|
||||||
|
visible = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for column_key in REQUEST_TABLE_COLUMN_KEYS:
|
||||||
|
if column_key not in requested or column_key in seen:
|
||||||
|
continue
|
||||||
|
visible.append(column_key)
|
||||||
|
seen.add(column_key)
|
||||||
|
return visible or list(DEFAULT_REQUEST_TABLE_VISIBLE_COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_request_table_sort_column(value: Any) -> str:
|
||||||
|
column_key = str(value or "").strip()
|
||||||
|
if column_key in REQUEST_TABLE_COLUMN_KEY_SET:
|
||||||
|
return column_key
|
||||||
|
return DEFAULT_REQUEST_TABLE_SORT_COLUMN
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_request_table_preferences(data: Any) -> dict[str, Any]:
|
||||||
|
source = data if isinstance(data, dict) else {}
|
||||||
|
return {
|
||||||
|
"visible_columns": request_table_visible_columns(source.get("visible_columns")),
|
||||||
|
"sort_column": normalize_request_table_sort_column(source.get("sort_column")),
|
||||||
|
"sort_reverse": parse_bool_value(source.get("sort_reverse"), True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_request_table_preferences() -> dict[str, Any]:
|
||||||
|
path = request_table_preferences_file_path()
|
||||||
|
return normalize_request_table_preferences(read_json_object(str(path)))
|
||||||
|
|
||||||
|
|
||||||
|
def write_request_table_preferences(preferences: dict[str, Any]) -> Path:
|
||||||
|
path = request_table_preferences_file_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
path.parent.chmod(0o700)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
normalized = normalize_request_table_preferences(preferences)
|
||||||
|
path.write_text(json.dumps(normalized, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
path.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def normalize_gateway_url(api_url: str) -> str:
|
def normalize_gateway_url(api_url: str) -> str:
|
||||||
value = str(api_url or "").strip()
|
value = str(api_url or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -476,6 +564,82 @@ def request_effort_text(row: dict[str, Any]) -> str:
|
|||||||
return str(row.get("reasoning_effort") or "").strip() or "-"
|
return str(row.get("reasoning_effort") or "").strip() or "-"
|
||||||
|
|
||||||
|
|
||||||
|
def request_model_text(row: dict[str, Any]) -> str:
|
||||||
|
return str(row.get("model") or row.get("requested_model") or row.get("forwarded_model") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_request_retry_firsts(value: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(value, (list, tuple)):
|
||||||
|
return []
|
||||||
|
firsts = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
firsts.append(
|
||||||
|
{
|
||||||
|
"slot": parse_int_value(item.get("slot")),
|
||||||
|
"first_delay_ms": item.get("first_response_delay_ms", item.get("first_delay_ms", item.get("first_ms"))),
|
||||||
|
"outcome": str(item.get("outcome") or "").strip(),
|
||||||
|
"status_code": item.get("status_code", item.get("upstream_status_code", item.get("status"))),
|
||||||
|
"reasoning_effort": str(item.get("reasoning_effort") or "").strip(),
|
||||||
|
"reasoning_tokens": item.get("reasoning_tokens"),
|
||||||
|
"reasoning": str(item.get("reasoning") or item.get("reason") or item.get("error") or "").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return firsts
|
||||||
|
|
||||||
|
|
||||||
|
def request_retry_round_text(row: dict[str, Any]) -> str:
|
||||||
|
round_number = parse_int_value(row.get("reasoning_retry_current_round"))
|
||||||
|
width = parse_int_value(row.get("reasoning_retry_current_width"))
|
||||||
|
if round_number is None and width is None:
|
||||||
|
return "-"
|
||||||
|
if round_number is None:
|
||||||
|
return f"-({max(0, width or 0)})"
|
||||||
|
if width is None:
|
||||||
|
return str(round_number)
|
||||||
|
return f"{round_number}({max(0, width)})"
|
||||||
|
|
||||||
|
|
||||||
|
def request_retry_first_summary(first: dict[str, Any]) -> str:
|
||||||
|
bits = []
|
||||||
|
slot = parse_int_value(first.get("slot"))
|
||||||
|
if slot is not None:
|
||||||
|
bits.append(f"slot {slot}")
|
||||||
|
first_delay = format_duration_ms_as_seconds(first.get("first_delay_ms"))
|
||||||
|
if first_delay != "-":
|
||||||
|
bits.append(f"first {first_delay}")
|
||||||
|
outcome = str(first.get("outcome") or "").strip()
|
||||||
|
status_code = first.get("status_code")
|
||||||
|
status_bits = [bit for bit in (outcome, str(status_code).strip() if status_code not in (None, "") else "") if bit]
|
||||||
|
if status_bits:
|
||||||
|
bits.append(f"status {'/'.join(status_bits)}")
|
||||||
|
reasoning_bits = []
|
||||||
|
if first.get("reasoning_effort"):
|
||||||
|
reasoning_bits.append(str(first["reasoning_effort"]))
|
||||||
|
reasoning_tokens = parse_int_value(first.get("reasoning_tokens"))
|
||||||
|
if reasoning_tokens is not None:
|
||||||
|
reasoning_bits.append(format_count(reasoning_tokens))
|
||||||
|
reasoning = str(first.get("reasoning") or "").strip()
|
||||||
|
if reasoning_bits or reasoning:
|
||||||
|
detail = "/".join(reasoning_bits)
|
||||||
|
if reasoning:
|
||||||
|
detail = f"{detail} {short_text(reasoning, 40)}".strip()
|
||||||
|
bits.append(f"reasoning {detail}".strip())
|
||||||
|
return " ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
def request_retry_wave_summary(row: dict[str, Any]) -> str:
|
||||||
|
summaries = []
|
||||||
|
for first in row.get("reasoning_retry_current_firsts") or []:
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
continue
|
||||||
|
summary = request_retry_first_summary(first)
|
||||||
|
if summary:
|
||||||
|
summaries.append(summary)
|
||||||
|
return "; ".join(summaries)
|
||||||
|
|
||||||
|
|
||||||
def request_retry_note(row: dict[str, Any]) -> str:
|
def request_retry_note(row: dict[str, Any]) -> str:
|
||||||
attempts = as_int(row.get("upstream_attempt_count"))
|
attempts = as_int(row.get("upstream_attempt_count"))
|
||||||
if attempts <= 1:
|
if attempts <= 1:
|
||||||
@@ -499,7 +663,7 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None =
|
|||||||
short_time(row["started_at"]),
|
short_time(row["started_at"]),
|
||||||
f"{status_symbol(row)} {row.get('status_code') or '-'}",
|
f"{status_symbol(row)} {row.get('status_code') or '-'}",
|
||||||
short_text(row["path"], limits.get("path", 22)),
|
short_text(row["path"], limits.get("path", 22)),
|
||||||
short_text(row["model"] or row["requested_model"] or row["forwarded_model"], limits.get("model", 16)),
|
short_text(request_model_text(row), limits.get("model", 16)),
|
||||||
short_text(request_effort_text(row), limits.get("effort", 10)),
|
short_text(request_effort_text(row), limits.get("effort", 10)),
|
||||||
request_reasoning_tokens_text(row),
|
request_reasoning_tokens_text(row),
|
||||||
short_text(request_usage_summary(row), limits.get("usage", 24)),
|
short_text(request_usage_summary(row), limits.get("usage", 24)),
|
||||||
@@ -510,9 +674,96 @@ def request_row_cells(row: dict[str, Any], text_limits: dict[str, int] | None =
|
|||||||
format_duration_ms_as_seconds(row["duration_ms"]),
|
format_duration_ms_as_seconds(row["duration_ms"]),
|
||||||
request_updated_elapsed(row),
|
request_updated_elapsed(row),
|
||||||
short_text(retry_note, note_width),
|
short_text(retry_note, note_width),
|
||||||
|
short_text(request_retry_round_text(row), limits.get("round", 10)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request_table_column_label(column_key: str) -> str:
|
||||||
|
column = REQUEST_TABLE_COLUMN_BY_KEY.get(column_key)
|
||||||
|
return str(column["label"]) if column else column_key
|
||||||
|
|
||||||
|
|
||||||
|
def request_sort_value(row: dict[str, Any], column_key: str) -> Any:
|
||||||
|
if column_key == "seq":
|
||||||
|
return parse_int_value(row.get("seq"))
|
||||||
|
if column_key == "req_id":
|
||||||
|
text = request_id_text(row)
|
||||||
|
return None if text == "-" else text.lower()
|
||||||
|
if column_key == "resp_id":
|
||||||
|
text = response_id_text(row)
|
||||||
|
return None if text == "-" else text.lower()
|
||||||
|
if column_key == "thread":
|
||||||
|
text = str(row.get("thread_id") or "").strip()
|
||||||
|
return text.lower() if text else None
|
||||||
|
if column_key == "started":
|
||||||
|
started = parse_datetime(row.get("started_at"))
|
||||||
|
return started.timestamp() if started else None
|
||||||
|
if column_key == "status":
|
||||||
|
return parse_int_value(row.get("status_code"))
|
||||||
|
if column_key == "path":
|
||||||
|
text = str(row.get("path") or "").strip()
|
||||||
|
return text.lower() if text else None
|
||||||
|
if column_key == "model":
|
||||||
|
text = request_model_text(row)
|
||||||
|
return text.lower() if text else None
|
||||||
|
if column_key == "effort":
|
||||||
|
text = str(row.get("reasoning_effort") or "").strip()
|
||||||
|
return text.lower() if text else None
|
||||||
|
if column_key == "reasoning":
|
||||||
|
return parse_int_value(row.get("reasoning_tokens"))
|
||||||
|
if column_key == "usage":
|
||||||
|
total_tokens = parse_int_value(row.get("total_tokens"))
|
||||||
|
if total_tokens is not None:
|
||||||
|
return total_tokens
|
||||||
|
input_tokens = parse_int_value(row.get("input_tokens")) or 0
|
||||||
|
output_tokens = parse_int_value(row.get("output_tokens")) or 0
|
||||||
|
cached_tokens = parse_int_value(row.get("cached_tokens")) or 0
|
||||||
|
combined = input_tokens + output_tokens + cached_tokens
|
||||||
|
return combined if combined else None
|
||||||
|
if column_key == "req_bytes":
|
||||||
|
return parse_int_value(row.get("request_body_bytes"))
|
||||||
|
if column_key == "resp_bytes":
|
||||||
|
return parse_int_value(row.get("response_bytes_received"))
|
||||||
|
if column_key == "chunks":
|
||||||
|
return parse_int_value(row.get("stream_chunk_count"))
|
||||||
|
if column_key == "first":
|
||||||
|
return parse_int_value(row.get("first_response_delay_ms"))
|
||||||
|
if column_key == "duration":
|
||||||
|
return parse_int_value(row.get("duration_ms"))
|
||||||
|
if column_key == "updated":
|
||||||
|
updated = parse_datetime(request_updated_at(row))
|
||||||
|
return updated.timestamp() if updated else None
|
||||||
|
if column_key == "note":
|
||||||
|
text = request_retry_note(row)
|
||||||
|
return text.lower() if text else None
|
||||||
|
if column_key == "round":
|
||||||
|
round_number = parse_int_value(row.get("reasoning_retry_current_round"))
|
||||||
|
width = parse_int_value(row.get("reasoning_retry_current_width"))
|
||||||
|
if round_number is None and width is None:
|
||||||
|
return None
|
||||||
|
return (round_number or 0, width or 0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sort_request_rows(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
sort_column: str = DEFAULT_REQUEST_TABLE_SORT_COLUMN,
|
||||||
|
*,
|
||||||
|
reverse: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
column_key = normalize_request_table_sort_column(sort_column)
|
||||||
|
known: list[tuple[Any, dict[str, Any]]] = []
|
||||||
|
missing: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
value = request_sort_value(row, column_key)
|
||||||
|
if value is None or value == "":
|
||||||
|
missing.append(row)
|
||||||
|
continue
|
||||||
|
known.append((value, row))
|
||||||
|
known.sort(key=lambda item: item[0], reverse=reverse)
|
||||||
|
return [row for _, row in known] + missing
|
||||||
|
|
||||||
|
|
||||||
def status_kind(value: Any) -> str:
|
def status_kind(value: Any) -> str:
|
||||||
text = str(value or "").strip().lower()
|
text = str(value or "").strip().lower()
|
||||||
if text in {"ok", "operational", "success"}:
|
if text in {"ok", "operational", "success"}:
|
||||||
@@ -715,8 +966,12 @@ def request_match_score(row: dict[str, Any], needle: str) -> bool:
|
|||||||
"output_tokens",
|
"output_tokens",
|
||||||
"total_tokens",
|
"total_tokens",
|
||||||
"cached_tokens",
|
"cached_tokens",
|
||||||
|
"reasoning_retry_current_round",
|
||||||
|
"reasoning_retry_current_width",
|
||||||
)
|
)
|
||||||
).lower()
|
).lower()
|
||||||
|
current_firsts = json.dumps(row.get("reasoning_retry_current_firsts") or [], ensure_ascii=False).lower()
|
||||||
|
haystack = f"{haystack} {current_firsts}"
|
||||||
return needle in haystack
|
return needle in haystack
|
||||||
|
|
||||||
|
|
||||||
@@ -780,6 +1035,9 @@ def normalize_request_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
|||||||
"response_bytes_received": entry.get("response_bytes_received"),
|
"response_bytes_received": entry.get("response_bytes_received"),
|
||||||
"stream_chunk_count": entry.get("stream_chunk_count"),
|
"stream_chunk_count": entry.get("stream_chunk_count"),
|
||||||
"first_response_delay_ms": entry.get("first_response_delay_ms"),
|
"first_response_delay_ms": entry.get("first_response_delay_ms"),
|
||||||
|
"reasoning_retry_current_round": parse_int_value(entry.get("reasoning_retry_current_round")),
|
||||||
|
"reasoning_retry_current_width": parse_int_value(entry.get("reasoning_retry_current_width")),
|
||||||
|
"reasoning_retry_current_firsts": normalize_request_retry_firsts(entry.get("reasoning_retry_current_firsts")),
|
||||||
"duration_ms": entry.get("duration_ms"),
|
"duration_ms": entry.get("duration_ms"),
|
||||||
"started_at": entry.get("started_at"),
|
"started_at": entry.get("started_at"),
|
||||||
"finished_at": entry.get("finished_at"),
|
"finished_at": entry.get("finished_at"),
|
||||||
@@ -818,6 +1076,9 @@ def render_request_detail(row: dict[str, Any]) -> str:
|
|||||||
f"finished {short_time(row['finished_at'])}",
|
f"finished {short_time(row['finished_at'])}",
|
||||||
f"updated {request_updated_elapsed(row)}",
|
f"updated {request_updated_elapsed(row)}",
|
||||||
]
|
]
|
||||||
|
retry_round = request_retry_round_text(row)
|
||||||
|
if retry_round != "-":
|
||||||
|
bits.append(f"retry round {retry_round}")
|
||||||
if row.get("model"):
|
if row.get("model"):
|
||||||
bits.append(f"model {row['model']}")
|
bits.append(f"model {row['model']}")
|
||||||
if row.get("requested_model") and row.get("requested_model") != row.get("model"):
|
if row.get("requested_model") and row.get("requested_model") != row.get("model"):
|
||||||
@@ -840,6 +1101,9 @@ def render_request_detail(row: dict[str, Any]) -> str:
|
|||||||
bits.append(f"upstream path {row['upstream_path']}")
|
bits.append(f"upstream path {row['upstream_path']}")
|
||||||
if row.get("upstream_auth_mode"):
|
if row.get("upstream_auth_mode"):
|
||||||
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}")
|
bits.append(f"auth {row['upstream_auth_mode']}/{row.get('upstream_auth_source') or '-'}")
|
||||||
|
retry_wave = request_retry_wave_summary(row)
|
||||||
|
if retry_wave:
|
||||||
|
bits.append(f"current wave {retry_wave}")
|
||||||
return " | ".join(bits)
|
return " | ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
@@ -1112,6 +1376,8 @@ def fetch_dashboard_snapshot(
|
|||||||
access_key: str = "",
|
access_key: str = "",
|
||||||
filter_text: str = "",
|
filter_text: str = "",
|
||||||
current_view: str = "overview",
|
current_view: str = "overview",
|
||||||
|
request_sort_column: str = DEFAULT_REQUEST_TABLE_SORT_COLUMN,
|
||||||
|
request_sort_reverse: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
gateway_root = gateway_admin_url(gateway_url)
|
gateway_root = gateway_admin_url(gateway_url)
|
||||||
endpoints = {
|
endpoints = {
|
||||||
@@ -1143,7 +1409,11 @@ def fetch_dashboard_snapshot(
|
|||||||
"payload": payload,
|
"payload": payload,
|
||||||
"status_payload": payload,
|
"status_payload": payload,
|
||||||
"status_error": " | ".join(errors),
|
"status_error": " | ".join(errors),
|
||||||
"requests": normalize_request_rows(requests_source, filter_text),
|
"requests": sort_request_rows(
|
||||||
|
normalize_request_rows(requests_source, filter_text),
|
||||||
|
request_sort_column,
|
||||||
|
reverse=request_sort_reverse,
|
||||||
|
),
|
||||||
"logs": normalize_log_rows(logs_source),
|
"logs": normalize_log_rows(logs_source),
|
||||||
"profiles": normalize_profile_rows(
|
"profiles": normalize_profile_rows(
|
||||||
profiles_source,
|
profiles_source,
|
||||||
@@ -1199,6 +1469,11 @@ def run_textual(
|
|||||||
("3", "show_logs", "Logs"),
|
("3", "show_logs", "Logs"),
|
||||||
("4", "show_profiles", "Profiles"),
|
("4", "show_profiles", "Profiles"),
|
||||||
("m", "toggle_request_table_density", "Wide/Compact"),
|
("m", "toggle_request_table_density", "Wide/Compact"),
|
||||||
|
(",", "select_previous_request_column", "Prev Column"),
|
||||||
|
(".", "select_next_request_column", "Next Column"),
|
||||||
|
("v", "toggle_request_column_visibility", "Show/Hide Column"),
|
||||||
|
("c", "sort_requests_by_selected_column", "Sort Column"),
|
||||||
|
("shift+c", "toggle_request_sort_direction", "Sort Asc/Desc"),
|
||||||
("shift+left", "scroll_table_left", "Scroll Left"),
|
("shift+left", "scroll_table_left", "Scroll Left"),
|
||||||
("shift+right", "scroll_table_right", "Scroll Right"),
|
("shift+right", "scroll_table_right", "Scroll Right"),
|
||||||
("[", "narrow_usage_column", "Narrow Usage"),
|
("[", "narrow_usage_column", "Narrow Usage"),
|
||||||
@@ -1225,7 +1500,7 @@ def run_textual(
|
|||||||
self.log_by_key: dict[str, dict[str, Any]] = {}
|
self.log_by_key: dict[str, dict[str, Any]] = {}
|
||||||
self.current_view = "overview"
|
self.current_view = "overview"
|
||||||
self.filter_mode = "requests"
|
self.filter_mode = "requests"
|
||||||
self.last_request_id = ""
|
self.last_request_key = ""
|
||||||
self.loading = False
|
self.loading = False
|
||||||
self.executor = ThreadPoolExecutor(max_workers=1)
|
self.executor = ThreadPoolExecutor(max_workers=1)
|
||||||
self.refresh_generation = 0
|
self.refresh_generation = 0
|
||||||
@@ -1237,6 +1512,10 @@ def run_textual(
|
|||||||
"profiles": "profiles",
|
"profiles": "profiles",
|
||||||
}
|
}
|
||||||
self.request_table_column_keys: dict[str, Any] = {}
|
self.request_table_column_keys: dict[str, Any] = {}
|
||||||
|
self.request_table_preferences = load_request_table_preferences()
|
||||||
|
self.request_table_sort_column = self.request_table_preferences["sort_column"]
|
||||||
|
self.request_table_sort_reverse = bool(self.request_table_preferences["sort_reverse"])
|
||||||
|
self.request_table_selected_column = self.request_table_sort_column
|
||||||
self.request_table_density = "compact"
|
self.request_table_density = "compact"
|
||||||
self.request_table_width_profiles = {
|
self.request_table_width_profiles = {
|
||||||
"compact": {
|
"compact": {
|
||||||
@@ -1258,6 +1537,7 @@ def run_textual(
|
|||||||
"duration": 8,
|
"duration": 8,
|
||||||
"updated": 8,
|
"updated": 8,
|
||||||
"note": 22,
|
"note": 22,
|
||||||
|
"round": 10,
|
||||||
},
|
},
|
||||||
"wide": {
|
"wide": {
|
||||||
"seq": 7,
|
"seq": 7,
|
||||||
@@ -1278,6 +1558,7 @@ def run_textual(
|
|||||||
"duration": 8,
|
"duration": 8,
|
||||||
"updated": 8,
|
"updated": 8,
|
||||||
"note": 48,
|
"note": 48,
|
||||||
|
"round": 10,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.request_usage_width = self.request_table_width_profiles[self.request_table_density]["usage"]
|
self.request_usage_width = self.request_table_width_profiles[self.request_table_density]["usage"]
|
||||||
@@ -1300,27 +1581,7 @@ def run_textual(
|
|||||||
requests = self.query_one("#requests_table", DataTable)
|
requests = self.query_one("#requests_table", DataTable)
|
||||||
requests.cursor_type = "row"
|
requests.cursor_type = "row"
|
||||||
requests.zebra_stripes = True
|
requests.zebra_stripes = True
|
||||||
width_profile = self.request_table_width_profiles[self.request_table_density]
|
self._rebuild_request_table_columns()
|
||||||
self.request_table_column_keys = {
|
|
||||||
"seq": requests.add_column("Seq", width=width_profile["seq"], key="seq"),
|
|
||||||
"req_id": requests.add_column("Req ID", width=width_profile["req_id"], key="req_id"),
|
|
||||||
"resp_id": requests.add_column("Resp ID", width=width_profile["resp_id"], key="resp_id"),
|
|
||||||
"thread": requests.add_column("Thread", width=width_profile["thread"], key="thread"),
|
|
||||||
"started": requests.add_column("Started", width=width_profile["started"], key="started"),
|
|
||||||
"status": requests.add_column("Status", width=width_profile["status"], key="status"),
|
|
||||||
"path": requests.add_column("Path", width=width_profile["path"], key="path"),
|
|
||||||
"model": requests.add_column("Model", width=width_profile["model"], key="model"),
|
|
||||||
"effort": requests.add_column("Effort", width=width_profile["effort"], key="effort"),
|
|
||||||
"reasoning": requests.add_column("R.Tok", width=width_profile["reasoning"], key="reasoning"),
|
|
||||||
"usage": requests.add_column("Usage", width=self.request_usage_width, key="usage"),
|
|
||||||
"req_bytes": requests.add_column("Req Size", width=width_profile["req"], key="req_bytes"),
|
|
||||||
"resp_bytes": requests.add_column("Resp Size", width=width_profile["resp"], key="resp_bytes"),
|
|
||||||
"chunks": requests.add_column("Chunks", width=width_profile["chunks"], key="chunks"),
|
|
||||||
"first": requests.add_column("First", width=width_profile["first"], key="first"),
|
|
||||||
"duration": requests.add_column("Duration", width=width_profile["duration"], key="duration"),
|
|
||||||
"updated": requests.add_column("Updated", width=width_profile["updated"], key="updated"),
|
|
||||||
"note": requests.add_column("Note", width=width_profile["note"], key="note"),
|
|
||||||
}
|
|
||||||
|
|
||||||
logs = self.query_one("#logs_table", DataTable)
|
logs = self.query_one("#logs_table", DataTable)
|
||||||
logs.cursor_type = "row"
|
logs.cursor_type = "row"
|
||||||
@@ -1346,7 +1607,7 @@ def run_textual(
|
|||||||
self.query_one("#logs_table").display = active_table == "logs"
|
self.query_one("#logs_table").display = active_table == "logs"
|
||||||
self.query_one("#profiles_table").display = active_table == "profiles"
|
self.query_one("#profiles_table").display = active_table == "profiles"
|
||||||
self.query_one("#controls", Static).update(
|
self.query_one("#controls", Static).update(
|
||||||
"views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | m compact/wide | [ ] usage width | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream"
|
"views: 1 overview | 2 requests | 3 logs | 4 profiles | / filter | r refresh | m compact/wide | , . column | v hide/show | c sort | C asc/desc | [ ] usage width | Shift-Left/Right scroll | e edit | p probe | s switch | w save | d delete | u open upstream"
|
||||||
)
|
)
|
||||||
self._focus_table()
|
self._focus_table()
|
||||||
self._update_table_headers()
|
self._update_table_headers()
|
||||||
@@ -1361,12 +1622,82 @@ def run_textual(
|
|||||||
self.query_one("#paths", Static).update(state_summary(self.payload))
|
self.query_one("#paths", Static).update(state_summary(self.payload))
|
||||||
|
|
||||||
def _update_table_headers(self) -> None:
|
def _update_table_headers(self) -> None:
|
||||||
counts = f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)}"
|
selected_label = request_table_column_label(self.request_table_selected_column)
|
||||||
|
selected_visible = self.request_table_selected_column in self.request_table_preferences["visible_columns"]
|
||||||
|
sort_label = request_table_column_label(self.request_table_sort_column)
|
||||||
|
sort_direction = "desc" if self.request_table_sort_reverse else "asc"
|
||||||
|
counts = (
|
||||||
|
f"requests {len(self.requests)} | logs {len(self.logs)} | profiles {len(self.profiles)} | "
|
||||||
|
f"req cols {len(self.request_table_preferences['visible_columns'])}/{len(REQUEST_TABLE_COLUMNS)} | "
|
||||||
|
f"selected {selected_label} ({'shown' if selected_visible else 'hidden'}) | sort {sort_label} {sort_direction}"
|
||||||
|
)
|
||||||
self.query_one("#tables", Static).update(counts)
|
self.query_one("#tables", Static).update(counts)
|
||||||
|
|
||||||
def _current_filter(self) -> str:
|
def _current_filter(self) -> str:
|
||||||
return self.query_one("#filter", Input).value.strip()
|
return self.query_one("#filter", Input).value.strip()
|
||||||
|
|
||||||
|
def _visible_request_column_keys(self) -> list[str]:
|
||||||
|
return request_table_visible_columns(self.request_table_preferences.get("visible_columns"))
|
||||||
|
|
||||||
|
def _request_table_actions_enabled(self) -> bool:
|
||||||
|
if self.visible_tables.get(self.current_view, "requests") == "requests":
|
||||||
|
return True
|
||||||
|
self._set_status("switch to requests or overview first")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _persist_request_table_preferences(self) -> bool:
|
||||||
|
self.request_table_preferences = normalize_request_table_preferences(self.request_table_preferences)
|
||||||
|
self.request_table_sort_column = self.request_table_preferences["sort_column"]
|
||||||
|
self.request_table_sort_reverse = bool(self.request_table_preferences["sort_reverse"])
|
||||||
|
try:
|
||||||
|
write_request_table_preferences(self.request_table_preferences)
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_status(f"save request table prefs error: {exc}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _request_row_key(self, row: dict[str, Any]) -> str:
|
||||||
|
return f"{row['seq']}:{row['request_id']}"
|
||||||
|
|
||||||
|
def _selected_request_key(self) -> str:
|
||||||
|
row = self._selected_request_row()
|
||||||
|
if row:
|
||||||
|
return self._request_row_key(row)
|
||||||
|
return self.last_request_key
|
||||||
|
|
||||||
|
def _select_request_row(self, row_key: str) -> bool:
|
||||||
|
if not row_key:
|
||||||
|
return False
|
||||||
|
table = self.query_one("#requests_table", DataTable)
|
||||||
|
for index, row in enumerate(self.requests):
|
||||||
|
if self._request_row_key(row) == row_key:
|
||||||
|
return self._select_table_row(table, index)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _rebuild_request_table_columns(self) -> None:
|
||||||
|
table = self.query_one("#requests_table", DataTable)
|
||||||
|
selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0
|
||||||
|
selected_row_key = self._selected_request_key()
|
||||||
|
width_profile = self.request_table_width_profiles[self.request_table_density]
|
||||||
|
table.clear(columns=True)
|
||||||
|
self.request_table_column_keys = {}
|
||||||
|
for column in REQUEST_TABLE_COLUMNS:
|
||||||
|
column_key = column["key"]
|
||||||
|
if column_key not in self._visible_request_column_keys():
|
||||||
|
continue
|
||||||
|
width = self.request_usage_width if column_key == "usage" else width_profile[column["width_key"]]
|
||||||
|
self.request_table_column_keys[column_key] = table.add_column(
|
||||||
|
column["label"],
|
||||||
|
width=width,
|
||||||
|
key=column_key,
|
||||||
|
)
|
||||||
|
if self.requests:
|
||||||
|
self.render_requests()
|
||||||
|
if not self._select_request_row(selected_row_key):
|
||||||
|
self._select_table_row(table, min(selected_row_index, len(self.requests) - 1))
|
||||||
|
else:
|
||||||
|
self.request_by_key = {}
|
||||||
|
|
||||||
def _current_table_widget(self) -> DataTable | None:
|
def _current_table_widget(self) -> DataTable | None:
|
||||||
table_id = self.visible_tables.get(self.current_view, "requests")
|
table_id = self.visible_tables.get(self.current_view, "requests")
|
||||||
if table_id == "logs":
|
if table_id == "logs":
|
||||||
@@ -1437,6 +1768,8 @@ def run_textual(
|
|||||||
access_key=access_key,
|
access_key=access_key,
|
||||||
filter_text=current_filter,
|
filter_text=current_filter,
|
||||||
current_view=self.current_view,
|
current_view=self.current_view,
|
||||||
|
request_sort_column=self.request_table_sort_column,
|
||||||
|
request_sort_reverse=self.request_table_sort_reverse,
|
||||||
)
|
)
|
||||||
result["generation"] = generation
|
result["generation"] = generation
|
||||||
self.call_from_thread(self._apply_refresh_result, result)
|
self.call_from_thread(self._apply_refresh_result, result)
|
||||||
@@ -1446,6 +1779,7 @@ def run_textual(
|
|||||||
def _apply_refresh_result(self, result: dict[str, Any]) -> None:
|
def _apply_refresh_result(self, result: dict[str, Any]) -> None:
|
||||||
if result.get("generation") != self.refresh_generation:
|
if result.get("generation") != self.refresh_generation:
|
||||||
return
|
return
|
||||||
|
selected_request_key = self._selected_request_key()
|
||||||
self.payload = result["payload"]
|
self.payload = result["payload"]
|
||||||
self.status_payload = result["status_payload"]
|
self.status_payload = result["status_payload"]
|
||||||
self.status_error = result["status_error"]
|
self.status_error = result["status_error"]
|
||||||
@@ -1453,6 +1787,7 @@ def run_textual(
|
|||||||
self.logs = result["logs"]
|
self.logs = result["logs"]
|
||||||
self.profiles = result["profiles"]
|
self.profiles = result["profiles"]
|
||||||
self.active_profile_name = result.get("active_profile_name") or ""
|
self.active_profile_name = result.get("active_profile_name") or ""
|
||||||
|
self.last_request_key = selected_request_key
|
||||||
self.loading = False
|
self.loading = False
|
||||||
self.render_all()
|
self.render_all()
|
||||||
if self.current_view == "profiles":
|
if self.current_view == "profiles":
|
||||||
@@ -1495,14 +1830,18 @@ def run_textual(
|
|||||||
def render_requests(self) -> None:
|
def render_requests(self) -> None:
|
||||||
table = self.query_one("#requests_table", DataTable)
|
table = self.query_one("#requests_table", DataTable)
|
||||||
selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0
|
selected_row_index = table.cursor_row if table.cursor_row is not None and table.cursor_row >= 0 else 0
|
||||||
|
selected_row_key = self._selected_request_key()
|
||||||
text_limits = self._request_text_limits(table)
|
text_limits = self._request_text_limits(table)
|
||||||
table.clear()
|
table.clear()
|
||||||
self.request_by_key = {}
|
self.request_by_key = {}
|
||||||
for row in self.requests:
|
for row in self.requests:
|
||||||
key = f"{row['seq']}:{row['request_id']}"
|
key = self._request_row_key(row)
|
||||||
self.request_by_key[key] = row
|
self.request_by_key[key] = row
|
||||||
table.add_row(*request_row_cells(row, text_limits), key=key)
|
cells = request_row_cells(row, text_limits)
|
||||||
|
visible_cells = [cells[REQUEST_TABLE_CELL_INDEX[column_key]] for column_key in self._visible_request_column_keys()]
|
||||||
|
table.add_row(*visible_cells, key=key)
|
||||||
if self.requests:
|
if self.requests:
|
||||||
|
if not self._select_request_row(selected_row_key):
|
||||||
self._select_table_row(table, min(selected_row_index, len(self.requests) - 1))
|
self._select_table_row(table, min(selected_row_index, len(self.requests) - 1))
|
||||||
|
|
||||||
def render_logs(self) -> None:
|
def render_logs(self) -> None:
|
||||||
@@ -1600,8 +1939,9 @@ def run_textual(
|
|||||||
"effort": width_profile["effort"],
|
"effort": width_profile["effort"],
|
||||||
"usage": self.request_usage_width,
|
"usage": self.request_usage_width,
|
||||||
"note": width_profile["note"],
|
"note": width_profile["note"],
|
||||||
|
"round": width_profile["round"],
|
||||||
}
|
}
|
||||||
for name in ("req_id", "resp_id", "thread", "path", "model", "effort", "usage", "note"):
|
for name in ("req_id", "resp_id", "thread", "path", "model", "effort", "usage", "note", "round"):
|
||||||
key = self.request_table_column_keys.get(name)
|
key = self.request_table_column_keys.get(name)
|
||||||
column = table.columns.get(key) if key is not None else None
|
column = table.columns.get(key) if key is not None else None
|
||||||
if column is None or not getattr(column, "width", 0):
|
if column is None or not getattr(column, "width", 0):
|
||||||
@@ -1611,6 +1951,7 @@ def run_textual(
|
|||||||
|
|
||||||
def _rerender_requests_after_layout_change(self) -> None:
|
def _rerender_requests_after_layout_change(self) -> None:
|
||||||
self.render_requests()
|
self.render_requests()
|
||||||
|
self._update_table_headers()
|
||||||
if self.current_view not in {"overview", "requests"}:
|
if self.current_view not in {"overview", "requests"}:
|
||||||
return
|
return
|
||||||
row = self._selected_request_row()
|
row = self._selected_request_row()
|
||||||
@@ -1624,9 +1965,10 @@ def run_textual(
|
|||||||
width_profile = self.request_table_width_profiles[density]
|
width_profile = self.request_table_width_profiles[density]
|
||||||
for name, key in self.request_table_column_keys.items():
|
for name, key in self.request_table_column_keys.items():
|
||||||
column = table.columns.get(key)
|
column = table.columns.get(key)
|
||||||
if column is None or name not in width_profile:
|
width_key = REQUEST_TABLE_COLUMN_BY_KEY.get(name, {}).get("width_key", name)
|
||||||
|
if column is None or width_key not in width_profile:
|
||||||
continue
|
continue
|
||||||
column.width = width_profile[name]
|
column.width = width_profile[width_key]
|
||||||
self.request_table_density = density
|
self.request_table_density = density
|
||||||
self.request_usage_width = width_profile["usage"]
|
self.request_usage_width = width_profile["usage"]
|
||||||
self._rerender_requests_after_layout_change()
|
self._rerender_requests_after_layout_change()
|
||||||
@@ -1636,6 +1978,73 @@ def run_textual(
|
|||||||
next_density = "wide" if self.request_table_density == "compact" else "compact"
|
next_density = "wide" if self.request_table_density == "compact" else "compact"
|
||||||
self._apply_request_table_width_profile(next_density)
|
self._apply_request_table_width_profile(next_density)
|
||||||
|
|
||||||
|
def _step_request_column(self, offset: int) -> None:
|
||||||
|
if not self._request_table_actions_enabled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
index = REQUEST_TABLE_COLUMN_KEYS.index(self.request_table_selected_column)
|
||||||
|
except ValueError:
|
||||||
|
index = 0
|
||||||
|
self.request_table_selected_column = REQUEST_TABLE_COLUMN_KEYS[(index + offset) % len(REQUEST_TABLE_COLUMN_KEYS)]
|
||||||
|
visibility = "shown" if self.request_table_selected_column in self.request_table_preferences["visible_columns"] else "hidden"
|
||||||
|
self._update_table_headers()
|
||||||
|
self._set_status(f"selected request column {request_table_column_label(self.request_table_selected_column)} ({visibility})")
|
||||||
|
|
||||||
|
def action_select_previous_request_column(self) -> None:
|
||||||
|
self._step_request_column(-1)
|
||||||
|
|
||||||
|
def action_select_next_request_column(self) -> None:
|
||||||
|
self._step_request_column(1)
|
||||||
|
|
||||||
|
def action_toggle_request_column_visibility(self) -> None:
|
||||||
|
if not self._request_table_actions_enabled():
|
||||||
|
return
|
||||||
|
visible_columns = self._visible_request_column_keys()
|
||||||
|
column_key = self.request_table_selected_column
|
||||||
|
if column_key in visible_columns:
|
||||||
|
if len(visible_columns) <= 1:
|
||||||
|
self._set_status("keep at least one request column visible")
|
||||||
|
return
|
||||||
|
visible_columns = [key for key in visible_columns if key != column_key]
|
||||||
|
action = "hidden"
|
||||||
|
else:
|
||||||
|
visible_columns.append(column_key)
|
||||||
|
action = "shown"
|
||||||
|
self.request_table_preferences["visible_columns"] = request_table_visible_columns(visible_columns)
|
||||||
|
if not self._persist_request_table_preferences():
|
||||||
|
return
|
||||||
|
self._rebuild_request_table_columns()
|
||||||
|
self._rerender_requests_after_layout_change()
|
||||||
|
self._set_status(f"request column {request_table_column_label(column_key)} {action}")
|
||||||
|
|
||||||
|
def _apply_request_sort(self, column_key: str, reverse: bool) -> None:
|
||||||
|
if not self._request_table_actions_enabled():
|
||||||
|
return
|
||||||
|
selected_request_key = self._selected_request_key()
|
||||||
|
self.request_table_sort_column = normalize_request_table_sort_column(column_key)
|
||||||
|
self.request_table_sort_reverse = bool(reverse)
|
||||||
|
self.request_table_preferences["sort_column"] = self.request_table_sort_column
|
||||||
|
self.request_table_preferences["sort_reverse"] = self.request_table_sort_reverse
|
||||||
|
if not self._persist_request_table_preferences():
|
||||||
|
return
|
||||||
|
self.requests = sort_request_rows(
|
||||||
|
self.requests,
|
||||||
|
self.request_table_sort_column,
|
||||||
|
reverse=self.request_table_sort_reverse,
|
||||||
|
)
|
||||||
|
self.last_request_key = selected_request_key
|
||||||
|
self._rerender_requests_after_layout_change()
|
||||||
|
self._set_status(
|
||||||
|
f"request sort {request_table_column_label(self.request_table_sort_column)} "
|
||||||
|
f"{'desc' if self.request_table_sort_reverse else 'asc'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_sort_requests_by_selected_column(self) -> None:
|
||||||
|
self._apply_request_sort(self.request_table_selected_column, self.request_table_sort_reverse)
|
||||||
|
|
||||||
|
def action_toggle_request_sort_direction(self) -> None:
|
||||||
|
self._apply_request_sort(self.request_table_sort_column, not self.request_table_sort_reverse)
|
||||||
|
|
||||||
def action_widen_usage_column(self) -> None:
|
def action_widen_usage_column(self) -> None:
|
||||||
self._set_request_usage_width(self.request_usage_width + 4)
|
self._set_request_usage_width(self.request_usage_width + 4)
|
||||||
|
|
||||||
@@ -1742,6 +2151,7 @@ def run_textual(
|
|||||||
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
if event.data_table.id == "requests_table":
|
if event.data_table.id == "requests_table":
|
||||||
key = str(event.row_key.value)
|
key = str(event.row_key.value)
|
||||||
|
self.last_request_key = key
|
||||||
row = self.request_by_key.get(key)
|
row = self.request_by_key.get(key)
|
||||||
if row:
|
if row:
|
||||||
self.render_detail(row, kind="request")
|
self.render_detail(row, kind="request")
|
||||||
|
|||||||
@@ -210,6 +210,84 @@ class CodexRetryGatewayTUITests(unittest.TestCase):
|
|||||||
"Selected model is at capacity. Please try a different model.",
|
"Selected model is at capacity. Please try a different model.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_request_rows_capture_retry_round_and_wave_firsts(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
payload = {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"seq": 3,
|
||||||
|
"request_id": "req_round",
|
||||||
|
"response_id": "resp_round",
|
||||||
|
"thread_id": "thread_round",
|
||||||
|
"path": "/responses",
|
||||||
|
"status_code": 200,
|
||||||
|
"reasoning_retry_current_round": 3,
|
||||||
|
"reasoning_retry_current_width": 2,
|
||||||
|
"reasoning_retry_current_firsts": [
|
||||||
|
{
|
||||||
|
"slot": 1,
|
||||||
|
"first_response_delay_ms": 1200,
|
||||||
|
"outcome": "ok",
|
||||||
|
"status_code": 200,
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"reasoning_tokens": 516,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slot": 2,
|
||||||
|
"first_delay_ms": 1800,
|
||||||
|
"outcome": "retry",
|
||||||
|
"status_code": 429,
|
||||||
|
"reason": "capacity",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
rows = mod.normalize_request_rows(payload)
|
||||||
|
row = rows[0]
|
||||||
|
self.assertEqual(row["reasoning_retry_current_round"], 3)
|
||||||
|
self.assertEqual(row["reasoning_retry_current_width"], 2)
|
||||||
|
self.assertEqual(len(row["reasoning_retry_current_firsts"]), 2)
|
||||||
|
self.assertEqual(mod.request_retry_round_text(row), "3(2)")
|
||||||
|
self.assertIn("slot 1", mod.request_retry_wave_summary(row))
|
||||||
|
self.assertIn("retry round 3(2)", mod.render_request_detail(row))
|
||||||
|
self.assertIn("current wave slot 1 first 1.2s", mod.render_request_detail(row))
|
||||||
|
self.assertEqual(mod.request_row_cells(row)[18], "3(2)")
|
||||||
|
|
||||||
|
def test_request_table_preferences_round_trip(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
prefs_path = Path(tmpdir) / "request-table.json"
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"CODEX_RETRY_GATEWAY_TUI_REQUEST_TABLE_PREFERENCES_FILE": str(prefs_path)},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
saved_path = mod.write_request_table_preferences(
|
||||||
|
{
|
||||||
|
"visible_columns": ["round", "seq", "status", "unknown"],
|
||||||
|
"sort_column": "round",
|
||||||
|
"sort_reverse": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(saved_path, prefs_path)
|
||||||
|
loaded = mod.load_request_table_preferences()
|
||||||
|
self.assertEqual(loaded["visible_columns"], ["seq", "status", "round"])
|
||||||
|
self.assertEqual(loaded["sort_column"], "round")
|
||||||
|
self.assertFalse(loaded["sort_reverse"])
|
||||||
|
|
||||||
|
def test_sort_request_rows_supports_custom_column_and_missing_values(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
rows = [
|
||||||
|
{"seq": 1, "request_id": "req_1", "duration_ms": 5000, "reasoning_retry_current_round": 1, "reasoning_retry_current_width": 1},
|
||||||
|
{"seq": 2, "request_id": "req_2", "duration_ms": None, "reasoning_retry_current_round": None, "reasoning_retry_current_width": None},
|
||||||
|
{"seq": 3, "request_id": "req_3", "duration_ms": 1200, "reasoning_retry_current_round": 3, "reasoning_retry_current_width": 2},
|
||||||
|
]
|
||||||
|
by_duration = mod.sort_request_rows(rows, "duration", reverse=False)
|
||||||
|
self.assertEqual([row["request_id"] for row in by_duration], ["req_3", "req_1", "req_2"])
|
||||||
|
by_round = mod.sort_request_rows(rows, "round", reverse=True)
|
||||||
|
self.assertEqual([row["request_id"] for row in by_round], ["req_3", "req_1", "req_2"])
|
||||||
|
|
||||||
def test_request_rows_read_usage_from_nested_usage_object(self) -> None:
|
def test_request_rows_read_usage_from_nested_usage_object(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
Reference in New Issue
Block a user