Files
pr-vision/includes/core/class-prv-call-log-table-renderer.php
peptiderepo 4eee9f5f64 PR Vision v0.3.0: per-call cost accountability + LLM call audit trail
P0 security:
- PRV_Gateway_Client: remove response body from RuntimeException (HTTP status only)
- PRV_Capture_Writer: allowlist-only write (prompt/response text, never headers/auth)
- Mandatory capture order: can_afford → probe → persist_result → update_row_cost → write_meta → capture_io
- capture_io last and wrapped in swallowing try/catch

New tables (SCHEMA_VERSION 3):
- prv_call_meta: cost/metadata per call, kept indefinitely
- prv_call_io: rendered prompt + raw response, pruned on retention window (default 90d)

New classes:
- PRV_Call_Meta_Table, PRV_Call_Io_Table: table schema + dbDelta
- PRV_Capture_Writer: metadata + I/O write; security allowlist enforced
- PRV_Prune_Cron: daily prv_daily_prune cron deletes aged prv_call_io rows only
- PRV_Cost_Rollup_Query: MTD summary, model breakdown, drill-down, projection
- PRV_Call_Log_Query: paginated + filtered call-log query + per-call I/O fetch
- PRV_Costs_Page + PRV_Costs_Renderer: Costs admin sub-page (cap progress, bento, model table, drill-down)
- PRV_Call_Log_Page + PRV_Call_Log_Table_Renderer: Call Log admin sub-page (filter bar, paginated table, WCAG AA pagination)
- PRV_Call_Drawer_Renderer: non-modal complementary drawer (5 states: Normal/Pruned/Legacy/Failed/Loading)
- PRV_Call_Drawer_Script: inline JS for drawer (300-line split from renderer)
- PRV_Call_Detail_Ajax: wp_ajax_prv_call_detail AJAX handler
- PRV_Admin_Page_Css: CSS provider (300-line split from PRV_Admin_Page)
- PRV_Probe_Run_Executor: inner probe loop (300-line split from PRV_Probe_Runner)

Updated:
- PRV_Probe_Result: tokens_in/tokens_out params + getters
- Both providers: report tokens_in/tokens_out from usage.prompt_tokens/completion_tokens
- PRV_Probe_Runner: slimmed to lock+UUID+delegation; injects PRV_Capture_Writer
- PRV_Config: get_io_retention_days(); PRV_IO_RETENTION_DEFAULT_DAYS constant
- PRV_Upgrader/Activator/Deactivator/Plugin: register new tables, cron, pages, AJAX
- Settings: retention days field + save in controller
- uninstall.php: DROP both new tables, clear prv_daily_prune cron
- Subnav 4-tab strip (Dashboard | Costs | Call Log | Settings) on all pages
- ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONVENTIONS.md updated

Tests:
- test-capture-writer.php: write_meta, write_io, P0 security (401 record has no key/Bearer/sk-or-)
- test-cost-audit.php: MTD reconciliation, projection, prune, forced capture_io exception, table drops

Agent-Session: peptiderepo-geomonitor-v0.3.0-cost-audit
2026-06-15 09:09:29 +00:00

277 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Renders the Call Log table and filter bar (v0.3.0).
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Renders the paginated Call Log table, filter bar, and result count.
*
* Does not render the detail drawer — that is PRV_Call_Drawer_Renderer.
* All output is escaped at render. Cited% 0 renders muted; >0 renders lime.
* Status/verdict are never signalled by colour alone (chip + label).
*
* Who triggers: PRV_Call_Log_Page::render_page().
* Dependencies: PRV_Costs_Renderer (for subnav helper), PRV_Config.
*
* @see class-prv-call-log-page.php — Caller, passes data array.
* @see class-prv-call-drawer-renderer.php — Drawer renderer (emitted below table).
* @see class-prv-costs-renderer.php — render_subnav() shared helper.
* @package PrVision
*/
class PRV_Call_Log_Table_Renderer {
/**
* Render the full Call Log page including table and drawer.
*
* Side effects: Outputs HTML.
*
* @param array<string, mixed> $data Keys: rows, total, pages, page, filters.
*
* @return void
*/
public function render( array $data ): void {
$rows = (array) $data['rows'];
$total = (int) $data['total'];
$pages = (int) $data['pages'];
$page = (int) $data['page'];
$filters = (array) $data['filters'];
$subnav = new PRV_Costs_Renderer();
echo '<div class="wrap prv-page-wrap">';
$subnav->render_subnav( 'calls' );
echo '<div class="prv-page-header">';
echo '<h1 class="prv-page-title">' . esc_html__( 'Call Log', 'pr-vision' ) . '</h1>';
$retention_days = PRV_Config::get_io_retention_days();
echo '<p class="prv-retention-note">';
echo esc_html(
sprintf(
/* translators: %d: retention days */
__( 'Prompt + response kept %d days. Cost + metadata kept indefinitely.', 'pr-vision' ),
$retention_days
)
);
echo '</p>';
echo '</div>';
$this->render_filter_bar( $filters );
echo '<div class="prv-results-count" aria-live="polite">';
if ( $total > 0 ) {
$from = ( ( $page - 1 ) * PRV_Call_Log_Query::PAGE_SIZE ) + 1;
$to = min( $page * PRV_Call_Log_Query::PAGE_SIZE, $total );
echo esc_html(
sprintf(
/* translators: 1: from, 2: to, 3: total */
__( 'Showing %1$d–%2$d of %3$d calls', 'pr-vision' ),
$from,
$to,
$total
)
);
} else {
echo esc_html__( 'No calls recorded yet.', 'pr-vision' );
}
echo '</div>';
$this->render_table( $rows );
$this->render_pagination( $page, $pages, $filters );
// Emit drawer (hidden until a row is clicked).
$drawer = new PRV_Call_Drawer_Renderer();
$drawer->render_container();
echo '</div>'; // .wrap
}
/**
* Render the filter bar with 4 select controls and Clear button.
*
* Side effects: Outputs HTML.
*
* @param array<string, string> $filters Active filter values.
*
* @return void
*/
private function render_filter_bar( array $filters ): void {
$base_url = admin_url( 'admin.php?page=pr-vision-calls' );
echo '<form method="get" action="' . esc_url( admin_url( 'admin.php' ) ) . '" class="prv-filter-bar">';
echo '<input type="hidden" name="page" value="pr-vision-calls">';
// Model filter.
echo '<label for="prv-f-model" class="prv-sr-only">' . esc_html__( 'Model', 'pr-vision' ) . '</label>';
echo '<select id="prv-f-model" name="prv_filter_model" class="prv-filter-select">';
echo '<option value="">' . esc_html__( 'All models', 'pr-vision' ) . '</option>';
foreach ( PRV_Config::get_models() as $model_slug ) {
$sel = ( $filters['model'] === $model_slug ) ? ' selected' : '';
echo '<option value="' . esc_attr( $model_slug ) . '"' . $sel . '>' . esc_html( $model_slug ) . '</option>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $sel is safe
}
echo '</select>';
// Peptide filter.
echo '<label for="prv-f-peptide" class="prv-sr-only">' . esc_html__( 'Peptide', 'pr-vision' ) . '</label>';
echo '<select id="prv-f-peptide" name="prv_filter_peptide" class="prv-filter-select">';
echo '<option value="">' . esc_html__( 'All peptides', 'pr-vision' ) . '</option>';
foreach ( PRV_Config::get_peptides() as $p ) {
$slug = (string) $p['slug'];
$sel = ( $filters['peptide'] === $slug ) ? ' selected' : '';
echo '<option value="' . esc_attr( $slug ) . '"' . $sel . '>' . esc_html( $p['label'] ) . '</option>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $sel is safe
}
echo '</select>';
// Date range.
echo '<label for="prv-f-from" class="prv-sr-only">' . esc_html__( 'From date', 'pr-vision' ) . '</label>';
echo '<input type="date" id="prv-f-from" name="prv_filter_from" class="prv-filter-date" value="' . esc_attr( $filters['date_from'] ) . '">';
echo '<label for="prv-f-to" class="prv-sr-only">' . esc_html__( 'To date', 'pr-vision' ) . '</label>';
echo '<input type="date" id="prv-f-to" name="prv_filter_to" class="prv-filter-date" value="' . esc_attr( $filters['date_to'] ) . '">';
// Status filter.
$status_opts = array(
'' => __( 'All statuses', 'pr-vision' ),
'cited' => __( 'Cited', 'pr-vision' ),
'not_cited' => __( 'Not cited', 'pr-vision' ),
'error' => __( 'Error', 'pr-vision' ),
);
echo '<label for="prv-f-status" class="prv-sr-only">' . esc_html__( 'Status', 'pr-vision' ) . '</label>';
echo '<select id="prv-f-status" name="prv_filter_status" class="prv-filter-select">';
foreach ( $status_opts as $val => $label ) {
$sel = ( $filters['status'] === $val ) ? ' selected' : '';
echo '<option value="' . esc_attr( $val ) . '"' . $sel . '>' . esc_html( $label ) . '</option>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $sel is safe
}
echo '</select>';
echo '<button type="submit" class="prv-filter-apply button">' . esc_html__( 'Filter', 'pr-vision' ) . '</button>';
echo '<a href="' . esc_url( $base_url ) . '" class="prv-filter-clear button">' . esc_html__( 'Clear', 'pr-vision' ) . '</a>';
echo '</form>';
}
/**
* Render the calls table.
*
* Side effects: Outputs HTML.
*
* @param array<int, array<string, mixed>> $rows Table rows from query.
*
* @return void
*/
private function render_table( array $rows ): void {
echo '<div class="prv-table-wrap" style="overflow-x:auto">';
echo '<table class="prv-standings prv-callog-table" cellspacing="0" aria-label="' . esc_attr__( 'Call log', 'pr-vision' ) . '">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Timestamp', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Peptide', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Intent', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Model', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Tokens', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Latency', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Cost', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Cited', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Status', 'pr-vision' ) . '</th>';
echo '</tr></thead><tbody>';
if ( empty( $rows ) ) {
echo '<tr><td colspan="9" class="prv-empty-msg">' . esc_html__( 'No calls recorded yet.', 'pr-vision' ) . '</td></tr>';
}
foreach ( $rows as $row ) {
$id = (int) $row['id'];
$is_error = ( (int) $row['http_status'] >= 400 );
$cited_null = ( null === $row['cited'] );
$is_cited = ! $cited_null && (bool) $row['cited'];
$latency_ms = isset( $row['latency_ms'] ) ? (int) $row['latency_ms'] : null;
$lat_warn = ( null !== $latency_ms && $latency_ms > 8000 ) ? ' style="color:#FFB36E"' : '';
$status_chip = $is_error ? '<span class="prv-chip prv-chip--error">&#x2715; Error</span>' : '<span class="prv-chip prv-chip--ok">OK</span>';
if ( $is_error ) {
$cited_chip = '<span class="prv-status prv-status--not-yet">&#x2013; Error</span>';
} elseif ( $cited_null ) {
$cited_chip = '<span class="prv-status prv-status--not-yet">&#x2013; —</span>';
} elseif ( $is_cited ) {
$cited_chip = '<span class="prv-status prv-status--cited">&#x2713; Cited</span>';
} else {
$cited_chip = '<span class="prv-status prv-status--not-yet">&#x2013; Not yet</span>';
}
echo '<tr id="call-row-' . esc_attr( (string) $id ) . '" class="prv-call-row" tabindex="0" role="button" data-call-id="' . esc_attr( (string) $id ) . '" aria-label="' . esc_attr( sprintf( /* translators: %d: call ID */ __( 'Call #%d details', 'pr-vision' ), $id ) ) . '">';
echo '<td class="prv-tabnum">' . esc_html( (string) $row['captured_at'] ) . '</td>';
echo '<td><strong>' . esc_html( (string) $row['peptide_slug'] ) . '</strong></td>';
echo '<td class="prv-muted">' . esc_html( (string) $row['intent_label'] ) . '</td>';
echo '<td><code>' . esc_html( (string) $row['model'] ) . '</code></td>';
$tok_in = isset( $row['tokens_in'] ) && null !== $row['tokens_in'] ? (int) $row['tokens_in'] : null;
$tok_out = isset( $row['tokens_out'] ) && null !== $row['tokens_out'] ? (int) $row['tokens_out'] : null;
$tok_str = ( null !== $tok_in && null !== $tok_out )
? '&#x2191;' . esc_html( number_format( $tok_in ) ) . ' &#x2193;' . esc_html( number_format( $tok_out ) )
: '—';
echo '<td class="prv-tabnum prv-muted">' . $tok_str . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- arrows are HTML entities; numbers are escaped above
echo '<td class="prv-tabnum"' . $lat_warn . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static style attr
echo null !== $latency_ms ? esc_html( number_format( $latency_ms ) ) . 'ms' : '—';
echo '</td>';
echo '<td class="prv-tabnum">$' . esc_html( number_format( (float) $row['cost_usd'], 6 ) ) . '</td>';
echo '<td>' . $cited_chip . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from safe parts above
echo '<td>' . $status_chip . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from safe parts above
echo '</tr>';
}
echo '</tbody></table></div>';
}
/**
* Render pagination bar.
*
* Disabled buttons use colour/border, not opacity (WCAG AA).
* Side effects: Outputs HTML.
*
* @param int $page Current page (1-based).
* @param int $pages Total pages.
* @param array<string, string> $filters Active filters for link building.
*
* @return void
*/
private function render_pagination( int $page, int $pages, array $filters ): void {
if ( $pages <= 1 ) {
return;
}
$base = admin_url( 'admin.php' );
$params = array( 'page' => 'pr-vision-calls' );
foreach ( $filters as $k => $v ) {
if ( '' !== $v ) {
$params[ 'prv_filter_' . ltrim( $k, 'prv_filter_' ) ] = $v;
}
}
echo '<div class="prv-pagination" role="navigation" aria-label="' . esc_attr__( 'Pagination', 'pr-vision' ) . '">';
// Prev.
if ( $page > 1 ) {
$prev_url = add_query_arg( array_merge( $params, array( 'prv_page' => $page - 1 ) ), $base );
echo '<a href="' . esc_url( $prev_url ) . '" class="prv-page-btn">&#x2190; ' . esc_html__( 'Prev', 'pr-vision' ) . '</a>';
} else {
echo '<span class="prv-page-btn prv-page-btn--disabled" aria-disabled="true">&#x2190; ' . esc_html__( 'Prev', 'pr-vision' ) . '</span>';
}
echo '<span class="prv-page-info">' . esc_html( sprintf( /* translators: 1: current, 2: total */ __( 'Page %1$d of %2$d', 'pr-vision' ), $page, $pages ) ) . '</span>';
// Next.
if ( $page < $pages ) {
$next_url = add_query_arg( array_merge( $params, array( 'prv_page' => $page + 1 ) ), $base );
echo '<a href="' . esc_url( $next_url ) . '" class="prv-page-btn">' . esc_html__( 'Next', 'pr-vision' ) . ' &#x2192;</a>';
} else {
echo '<span class="prv-page-btn prv-page-btn--disabled" aria-disabled="true">' . esc_html__( 'Next', 'pr-vision' ) . ' &#x2192;</span>';
}
echo '<span class="prv-page-per">' . esc_html( sprintf( /* translators: %d: rows per page */ __( '%d per page', 'pr-vision' ), PRV_Call_Log_Query::PAGE_SIZE ) ) . '</span>';
echo '</div>';
}
}