Deploy b92dc4b from main
This commit is contained in:
171
admin/class-peptide-news-admin.php
Normal file → Executable file
171
admin/class-peptide-news-admin.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Admin-specific functionality.
|
||||
*
|
||||
@@ -14,7 +15,7 @@ class Peptide_News_Admin {
|
||||
/** @var string */
|
||||
private $version;
|
||||
|
||||
public function __construct( $plugin_name, $version ) {
|
||||
public function __construct( string $plugin_name, string $version ) {
|
||||
$this->plugin_name = $plugin_name;
|
||||
$this->version = $version;
|
||||
}
|
||||
@@ -22,7 +23,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Enqueue admin CSS.
|
||||
*/
|
||||
public function enqueue_styles( $hook ) {
|
||||
public function enqueue_styles( string $hook ): void {
|
||||
if ( strpos( $hook, 'peptide-news' ) === false ) {
|
||||
return;
|
||||
}
|
||||
@@ -38,7 +39,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Enqueue admin JS.
|
||||
*/
|
||||
public function enqueue_scripts( $hook ) {
|
||||
public function enqueue_scripts( string $hook ): void {
|
||||
if ( strpos( $hook, 'peptide-news' ) === false ) {
|
||||
return;
|
||||
}
|
||||
@@ -71,7 +72,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Add admin menu pages.
|
||||
*/
|
||||
public function add_admin_menu() {
|
||||
public function add_admin_menu(): void {
|
||||
// Main menu — Analytics Dashboard.
|
||||
add_menu_page(
|
||||
__( 'Peptide News', 'peptide-news' ),
|
||||
@@ -102,12 +103,22 @@ class Peptide_News_Admin {
|
||||
'peptide-news-articles',
|
||||
array( $this, 'render_articles_page' )
|
||||
);
|
||||
|
||||
// Sub-menu — Cost Dashboard.
|
||||
add_submenu_page(
|
||||
'peptide-news-dashboard',
|
||||
__( 'LLM Costs', 'peptide-news' ),
|
||||
__( 'LLM Costs', 'peptide-news' ),
|
||||
'manage_options',
|
||||
'peptide-news-costs',
|
||||
array( $this, 'render_cost_dashboard_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all settings.
|
||||
*/
|
||||
public function register_settings() {
|
||||
public function register_settings(): void {
|
||||
|
||||
// --- General section ---
|
||||
add_settings_section(
|
||||
@@ -213,12 +224,43 @@ class Peptide_News_Admin {
|
||||
$this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' );
|
||||
|
||||
$this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' );
|
||||
|
||||
// --- Cost Tracking / Budget section ---
|
||||
add_settings_section(
|
||||
'peptide_news_cost_section',
|
||||
__( 'Cost Tracking & Budget', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Monitor and control LLM API spending. Set a monthly budget to prevent unexpected charges.', 'peptide-news' ) . '</p>';
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) {
|
||||
$month_spend = Peptide_News_Cost_Tracker::get_current_month_spend();
|
||||
$budget = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
printf(
|
||||
'<p class="description"><strong>%s:</strong> $%.4f',
|
||||
esc_html__( 'This month\'s spend', 'peptide-news' ),
|
||||
$month_spend
|
||||
);
|
||||
if ( $budget > 0 ) {
|
||||
printf(
|
||||
' / $%.2f (%.1f%%)',
|
||||
$budget,
|
||||
( $month_spend / $budget ) * 100
|
||||
);
|
||||
}
|
||||
echo '</p>';
|
||||
}
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'monthly_budget', __( 'Monthly Budget (USD)', 'peptide-news' ), 'render_monthly_budget_field', 'peptide_news_cost_section' );
|
||||
$this->add_setting( 'budget_mode', __( 'Budget Enforcement', 'peptide-news' ), 'render_budget_mode_field', 'peptide_news_cost_section' );
|
||||
$this->add_setting( 'cost_retention', __( 'Cost Log Retention (days)', 'peptide-news' ), 'render_cost_retention_field', 'peptide_news_cost_section' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: register a single setting with a field callback.
|
||||
*/
|
||||
private function add_setting( $key, $label, $callback, $section ) {
|
||||
private function add_setting( string $key, string $label, string $callback, string $section ): void {
|
||||
$option_name = "peptide_news_{$key}";
|
||||
|
||||
$sanitize_cb = $this->get_sanitize_callback( $key );
|
||||
@@ -242,7 +284,7 @@ class Peptide_News_Admin {
|
||||
* @param string $key Setting key (without prefix).
|
||||
* @return callable
|
||||
*/
|
||||
private function get_sanitize_callback( $key ) {
|
||||
private function get_sanitize_callback( string $key ) {
|
||||
$callbacks = array(
|
||||
'fetch_interval' => 'sanitize_text_field',
|
||||
'articles_count' => 'absint',
|
||||
@@ -270,6 +312,16 @@ class Peptide_News_Admin {
|
||||
'llm_keywords_model' => array( $this, 'sanitize_model_id' ),
|
||||
'llm_summary_model' => array( $this, 'sanitize_model_id' ),
|
||||
'llm_max_articles' => 'absint',
|
||||
'monthly_budget' => function ( $value ) {
|
||||
return max( 0.0, (float) $value );
|
||||
},
|
||||
'budget_mode' => function ( $value ) {
|
||||
$valid = array( 'disabled', 'hard_stop', 'warn_only' );
|
||||
return in_array( $value, $valid, true ) ? $value : 'disabled';
|
||||
},
|
||||
'cost_retention' => function ( $value ) {
|
||||
return max( 30, absint( $value ) );
|
||||
},
|
||||
);
|
||||
|
||||
return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field';
|
||||
@@ -281,7 +333,7 @@ class Peptide_News_Admin {
|
||||
* @param string $value Raw input.
|
||||
* @return string Sanitized model ID or default.
|
||||
*/
|
||||
public function sanitize_model_id( $value ) {
|
||||
public function sanitize_model_id( string $value ): string {
|
||||
$value = sanitize_text_field( $value );
|
||||
if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) {
|
||||
add_settings_error(
|
||||
@@ -300,13 +352,17 @@ class Peptide_News_Admin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize API keys. If a masked key is submitted (••••••••XXXX format),
|
||||
* keep the existing key. Otherwise, store the new full key.
|
||||
* Sanitize and encrypt API keys before storage.
|
||||
*
|
||||
* @param string $value Raw input.
|
||||
* @return string Stored API key (full value or existing).
|
||||
* If a masked key is submitted (••••••••XXXX format), the existing
|
||||
* encrypted value is preserved. New plaintext keys are encrypted
|
||||
* via Peptide_News_Encryption before being written to wp_options.
|
||||
*
|
||||
* @param string $value Raw input from the settings form.
|
||||
* @param string $option_name The wp_options key for this API key.
|
||||
* @return string Encrypted API key or existing stored value.
|
||||
*/
|
||||
public function sanitize_api_key( $value, $option_name ) {
|
||||
public function sanitize_api_key( string $value, string $option_name ): string {
|
||||
$value = sanitize_text_field( $value );
|
||||
if ( empty( $value ) ) {
|
||||
return '';
|
||||
@@ -317,19 +373,33 @@ class Peptide_News_Admin {
|
||||
return get_option( $option_name, '' );
|
||||
}
|
||||
|
||||
// Encrypt the new plaintext key before storing.
|
||||
if ( class_exists( 'Peptide_News_Encryption' ) ) {
|
||||
return Peptide_News_Encryption::encrypt( $value );
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a masked version of an API key showing only the last 4 characters.
|
||||
*
|
||||
* @param string $key Full API key.
|
||||
* Decrypts the stored value first (handles both encrypted and legacy
|
||||
* plaintext keys transparently).
|
||||
*
|
||||
* @param string $key Stored API key (encrypted or plaintext).
|
||||
* @return string Masked key (e.g., ••••••••XXXX).
|
||||
*/
|
||||
private function mask_api_key( $key ) {
|
||||
private function mask_api_key( string $key ): string {
|
||||
if ( empty( $key ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Decrypt before masking so we show the real key's last 4 chars.
|
||||
if ( class_exists( 'Peptide_News_Encryption' ) ) {
|
||||
$key = Peptide_News_Encryption::decrypt( $key );
|
||||
}
|
||||
|
||||
$len = strlen( $key );
|
||||
if ( $len <= 4 ) {
|
||||
return str_repeat( '•', $len );
|
||||
@@ -591,9 +661,57 @@ class Peptide_News_Admin {
|
||||
echo '<p class="description">' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
// --- Cost tracking field renderers ---
|
||||
|
||||
/**
|
||||
* Render the monthly budget input field.
|
||||
*/
|
||||
public function render_monthly_budget_field(): void {
|
||||
$value = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_monthly_budget" value="%.2f" min="0" step="0.01" class="small-text" style="width:100px;" />',
|
||||
$value
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'Set to 0 to disable budget limits. The plugin will stop making API calls when this amount is reached (if enforcement is enabled).', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the budget enforcement mode selector.
|
||||
*/
|
||||
public function render_budget_mode_field(): void {
|
||||
$value = get_option( 'peptide_news_budget_mode', 'disabled' );
|
||||
$modes = array(
|
||||
'disabled' => __( 'Disabled — track costs only, no limits', 'peptide-news' ),
|
||||
'warn_only' => __( 'Warn only — log alerts at 50%, 80%, 100%', 'peptide-news' ),
|
||||
'hard_stop' => __( 'Hard stop — block API calls when budget is reached', 'peptide-news' ),
|
||||
);
|
||||
echo '<select name="peptide_news_budget_mode">';
|
||||
foreach ( $modes as $mode => $label ) {
|
||||
printf(
|
||||
'<option value="%s" %s>%s</option>',
|
||||
esc_attr( $mode ),
|
||||
selected( $value, $mode, false ),
|
||||
esc_html( $label )
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the cost log retention field.
|
||||
*/
|
||||
public function render_cost_retention_field(): void {
|
||||
$value = absint( get_option( 'peptide_news_cost_retention', 365 ) );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_cost_retention" value="%d" min="30" max="3650" class="small-text" />',
|
||||
$value
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'How long to keep detailed cost records. Minimum 30 days.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
// --- Page renderers ---
|
||||
|
||||
public function render_settings_page() {
|
||||
public function render_settings_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
@@ -895,7 +1013,7 @@ class Peptide_News_Admin {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
public function render_dashboard_page() {
|
||||
public function render_dashboard_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
@@ -913,7 +1031,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Stream a CSV export of click analytics data.
|
||||
*/
|
||||
private function export_csv() {
|
||||
private function export_csv(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) );
|
||||
}
|
||||
@@ -947,10 +1065,25 @@ class Peptide_News_Admin {
|
||||
exit;
|
||||
}
|
||||
|
||||
public function render_articles_page() {
|
||||
public function render_articles_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the LLM cost tracking dashboard page.
|
||||
*
|
||||
* Shows monthly budget status, daily cost chart, per-model breakdown,
|
||||
* and recent API call log. Data is loaded via AJAX for responsiveness.
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public function render_cost_dashboard_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/cost-dashboard.php';
|
||||
}
|
||||
}
|
||||
|
||||
0
admin/css/admin-style.css
Normal file → Executable file
0
admin/css/admin-style.css
Normal file → Executable file
0
admin/js/admin-script.js
Normal file → Executable file
0
admin/js/admin-script.js
Normal file → Executable file
2
admin/partials/articles-list.php
Normal file → Executable file
2
admin/partials/articles-list.php
Normal file → Executable file
@@ -5,6 +5,8 @@
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare( strict_types=1 );
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
325
admin/partials/cost-dashboard.php
Executable file
325
admin/partials/cost-dashboard.php
Executable file
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
/**
|
||||
* Cost Dashboard admin partial.
|
||||
*
|
||||
* Displays LLM API cost tracking data: budget status, daily cost chart,
|
||||
* per-model and per-operation breakdowns, and a recent API call log.
|
||||
*
|
||||
* Data is loaded via AJAX (peptide_news_get_cost_data action) and rendered
|
||||
* client-side using Chart.js for the daily cost trend chart.
|
||||
*
|
||||
* @since 2.4.0
|
||||
* @see class-peptide-news-cost-tracker.php — Data source for all cost queries
|
||||
* @see class-peptide-news-admin.php — Registers this page via add_submenu_page()
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'LLM Cost Dashboard', 'peptide-news' ); ?></h1>
|
||||
|
||||
<!-- Period selector -->
|
||||
<div style="margin: 15px 0;">
|
||||
<label for="pn-cost-period"><strong><?php esc_html_e( 'Period:', 'peptide-news' ); ?></strong></label>
|
||||
<select id="pn-cost-period" style="margin-left: 5px;">
|
||||
<option value="day"><?php esc_html_e( 'Today', 'peptide-news' ); ?></option>
|
||||
<option value="week"><?php esc_html_e( 'Last 7 Days', 'peptide-news' ); ?></option>
|
||||
<option value="month" selected><?php esc_html_e( 'This Month', 'peptide-news' ); ?></option>
|
||||
</select>
|
||||
<button type="button" id="pn-cost-refresh" class="button button-secondary" style="margin-left: 8px;">
|
||||
<?php esc_html_e( 'Refresh', 'peptide-news' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Budget Status Card -->
|
||||
<div id="pn-budget-status" class="card" style="max-width: 600px; padding: 15px; margin-bottom: 20px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Monthly Budget', 'peptide-news' ); ?></h2>
|
||||
<div id="pn-budget-content">
|
||||
<p class="description"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards Row -->
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
|
||||
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'Total Cost', 'peptide-news' ); ?></h3>
|
||||
<p id="pn-cost-total" style="font-size: 24px; font-weight: bold; margin: 0;">$0.0000</p>
|
||||
</div>
|
||||
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
|
||||
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'API Requests', 'peptide-news' ); ?></h3>
|
||||
<p id="pn-cost-requests" style="font-size: 24px; font-weight: bold; margin: 0;">0</p>
|
||||
</div>
|
||||
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
|
||||
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'Total Tokens', 'peptide-news' ); ?></h3>
|
||||
<p id="pn-cost-tokens" style="font-size: 24px; font-weight: bold; margin: 0;">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Cost Chart -->
|
||||
<div class="card" style="max-width: 800px; padding: 15px; margin-bottom: 20px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Daily Cost Trend', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-cost-chart" height="250"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Breakdowns Row -->
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||
<!-- Per-Model Breakdown -->
|
||||
<div class="card" style="flex: 1; min-width: 350px; padding: 15px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Cost by Model', 'peptide-news' ); ?></h2>
|
||||
<table class="widefat striped" id="pn-cost-by-model">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Model', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Requests', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Tokens', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Per-Operation Breakdown -->
|
||||
<div class="card" style="flex: 1; min-width: 300px; padding: 15px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Cost by Operation', 'peptide-news' ); ?></h2>
|
||||
<table class="widefat striped" id="pn-cost-by-operation">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Operation', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Requests', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="3"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent API Calls Log -->
|
||||
<div class="card" style="padding: 15px; margin-bottom: 20px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Recent API Calls', 'peptide-news' ); ?></h2>
|
||||
<table class="widefat striped" id="pn-cost-recent">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Time', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Model', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Operation', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Prompt', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Completion', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Total', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="7"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
var costChart = null;
|
||||
|
||||
function loadCostData() {
|
||||
var period = $('#pn-cost-period').val();
|
||||
|
||||
$.ajax({
|
||||
url: peptideNewsAdmin.ajax_url,
|
||||
method: 'GET',
|
||||
data: {
|
||||
action: 'peptide_news_get_cost_data',
|
||||
nonce: peptideNewsAdmin.admin_nonce,
|
||||
period: period
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
renderCostData(response.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCostData(data) {
|
||||
// Summary cards.
|
||||
$('#pn-cost-total').text('$' + parseFloat(data.summary.total_cost).toFixed(4));
|
||||
$('#pn-cost-requests').text(data.summary.total_requests.toLocaleString());
|
||||
$('#pn-cost-tokens').text(data.summary.total_tokens.toLocaleString());
|
||||
|
||||
// Budget status.
|
||||
renderBudget(data.budget);
|
||||
|
||||
// Daily chart.
|
||||
renderChart(data.daily_costs);
|
||||
|
||||
// Model breakdown table.
|
||||
renderModelTable(data.summary.by_model);
|
||||
|
||||
// Operation breakdown table.
|
||||
renderOperationTable(data.summary.by_operation);
|
||||
|
||||
// Recent calls log.
|
||||
renderRecentCalls(data.recent_calls);
|
||||
}
|
||||
|
||||
function renderBudget(budget) {
|
||||
var html = '';
|
||||
if (budget.limit <= 0 || budget.mode === 'disabled') {
|
||||
html = '<p>' + '<?php echo esc_js( __( 'No budget set. Configure a monthly budget in Settings to track spending limits.', 'peptide-news' ) ); ?>' + '</p>';
|
||||
} else {
|
||||
var pct = budget.percent_used;
|
||||
var barColor = pct >= 100 ? '#dc3232' : (pct >= 80 ? '#dba617' : '#46b450');
|
||||
var modeLabel = budget.mode === 'hard_stop' ? '<?php echo esc_js( __( 'Hard Stop', 'peptide-news' ) ); ?>' : '<?php echo esc_js( __( 'Warn Only', 'peptide-news' ) ); ?>';
|
||||
|
||||
html += '<p><strong><?php echo esc_js( __( 'Spent:', 'peptide-news' ) ); ?></strong> $' + budget.month_spend.toFixed(4) + ' / $' + budget.limit.toFixed(2) + ' (' + pct.toFixed(1) + '%)</p>';
|
||||
html += '<div style="background:#e0e0e0;border-radius:4px;height:20px;max-width:400px;margin:8px 0;">';
|
||||
html += '<div style="background:' + barColor + ';height:100%;border-radius:4px;width:' + Math.min(pct, 100) + '%;transition:width 0.3s;"></div>';
|
||||
html += '</div>';
|
||||
html += '<p class="description"><strong><?php echo esc_js( __( 'Mode:', 'peptide-news' ) ); ?></strong> ' + modeLabel + '</p>';
|
||||
|
||||
if (pct >= 100 && budget.mode === 'hard_stop') {
|
||||
html += '<div class="notice notice-error inline" style="margin:10px 0;"><p><?php echo esc_js( __( 'Budget exceeded! LLM API calls are blocked until next month or budget is increased.', 'peptide-news' ) ); ?></p></div>';
|
||||
} else if (pct >= 80) {
|
||||
html += '<div class="notice notice-warning inline" style="margin:10px 0;"><p><?php echo esc_js( __( 'Approaching budget limit.', 'peptide-news' ) ); ?></p></div>';
|
||||
}
|
||||
}
|
||||
$('#pn-budget-content').html(html);
|
||||
}
|
||||
|
||||
function renderChart(dailyCosts) {
|
||||
var ctx = document.getElementById('pn-cost-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
var labels = dailyCosts.map(function(d) { return d.date; });
|
||||
var costs = dailyCosts.map(function(d) { return parseFloat(d.cost); });
|
||||
var tokens = dailyCosts.map(function(d) { return parseInt(d.tokens, 10); });
|
||||
|
||||
if (costChart) {
|
||||
costChart.destroy();
|
||||
}
|
||||
|
||||
costChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '<?php echo esc_js( __( 'Cost (USD)', 'peptide-news' ) ); ?>',
|
||||
data: costs,
|
||||
backgroundColor: 'rgba(0, 115, 170, 0.7)',
|
||||
borderColor: '#0073aa',
|
||||
borderWidth: 1,
|
||||
yAxisID: 'y'
|
||||
}, {
|
||||
label: '<?php echo esc_js( __( 'Tokens', 'peptide-news' ) ); ?>',
|
||||
data: tokens,
|
||||
type: 'line',
|
||||
borderColor: '#46b450',
|
||||
backgroundColor: 'rgba(70, 180, 80, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
title: { display: true, text: '<?php echo esc_js( __( 'Cost (USD)', 'peptide-news' ) ); ?>' },
|
||||
ticks: {
|
||||
callback: function(v) { return '$' + v.toFixed(4); }
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
title: { display: true, text: '<?php echo esc_js( __( 'Tokens', 'peptide-news' ) ); ?>' },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderModelTable(models) {
|
||||
var tbody = $('#pn-cost-by-model tbody');
|
||||
if (!models || models.length === 0) {
|
||||
tbody.html('<tr><td colspan="4"><?php echo esc_js( __( 'No data for this period.', 'peptide-news' ) ); ?></td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
models.forEach(function(m) {
|
||||
html += '<tr>';
|
||||
html += '<td><code>' + escHtml(m.model) + '</code></td>';
|
||||
html += '<td>' + parseInt(m.requests, 10).toLocaleString() + '</td>';
|
||||
html += '<td>' + parseInt(m.total_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>$' + parseFloat(m.total_cost).toFixed(4) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
function renderOperationTable(ops) {
|
||||
var tbody = $('#pn-cost-by-operation tbody');
|
||||
if (!ops || ops.length === 0) {
|
||||
tbody.html('<tr><td colspan="3"><?php echo esc_js( __( 'No data for this period.', 'peptide-news' ) ); ?></td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
ops.forEach(function(o) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + escHtml(o.operation) + '</td>';
|
||||
html += '<td>' + parseInt(o.requests, 10).toLocaleString() + '</td>';
|
||||
html += '<td>$' + parseFloat(o.total_cost).toFixed(4) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
function renderRecentCalls(calls) {
|
||||
var tbody = $('#pn-cost-recent tbody');
|
||||
if (!calls || calls.length === 0) {
|
||||
tbody.html('<tr><td colspan="7"><?php echo esc_js( __( 'No API calls recorded yet.', 'peptide-news' ) ); ?></td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
calls.forEach(function(c) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + escHtml(c.created_at) + '</td>';
|
||||
html += '<td><code style="font-size:11px;">' + escHtml(c.model) + '</code></td>';
|
||||
html += '<td>' + escHtml(c.operation) + '</td>';
|
||||
html += '<td>' + parseInt(c.prompt_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>' + parseInt(c.completion_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>' + parseInt(c.total_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>$' + parseFloat(c.cost_usd).toFixed(6) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Event handlers.
|
||||
$('#pn-cost-period').on('change', loadCostData);
|
||||
$('#pn-cost-refresh').on('click', loadCostData);
|
||||
|
||||
// Initial load.
|
||||
$(document).ready(loadCostData);
|
||||
|
||||
})(jQuery);
|
||||
</script>
|
||||
2
admin/partials/dashboard.php
Normal file → Executable file
2
admin/partials/dashboard.php
Normal file → Executable file
@@ -5,6 +5,8 @@
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare( strict_types=1 );
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user