Init Sentinel – Part 6: Building the Full Security Logs Admin Page

The dashboard widget is only a preview. To actually investigate incidents, Init Sentinel needs a dedicated admin page where logs can be filtered, sorted, paginated, and exported. This article walks through building that page step by step, directly from the implementation.

Init Sentinel – Part 6: Building the Full Security Logs Admin Page

At the end of this part, Init Sentinel becomes a complete security observability system inside WordPress.

Step 1: Restrict Access to High-Privilege Users

The Security Logs page must never be accessible to regular users. The first line of defense is a strict capability check.

if ( ! current_user_can('manage_options') ) {
    wp_die( esc_html__('You do not have permission to view this page.', 'init-html') );
}

This guarantees that only administrators can inspect security events.

Step 2: Verify the Log Table Exists

Before running any queries, the page verifies that the security log table exists. This avoids fatal errors during first-time activation or partial deployments.

$has_table = $wpdb->get_var( $wpdb->prepare(
    "SELECT COUNT(*) FROM information_schema.tables
     WHERE table_schema = DATABASE()
       AND table_name = %s",
    $table
) );

if ( ! $has_table ) {
    echo '<div class="wrap"><h1>Security Logs</h1><p>Security log table not found.</p></div>';
    return;
}

This defensive check keeps wp-admin stable even when Sentinel is not fully initialized.

Step 3: Read and Sanitize Filter Inputs

The page accepts multiple filters to support real investigation workflows.

$paged     = max(1, absint($_GET['paged'] ?? 1));
$per_page  = min( max(20, absint($_GET['per_page'] ?? 50)), 200 );
$q         = trim((string)($_GET['s'] ?? ''));
$code      = trim((string)($_GET['code'] ?? ''));
$endpoint  = trim((string)($_GET['endpoint'] ?? ''));
$action    = trim((string)($_GET['action_key'] ?? ''));
$user_id   = trim((string)($_GET['user'] ?? ''));
$ip        = trim((string)($_GET['ip'] ?? ''));
$date_from = trim((string)($_GET['from'] ?? ''));
$date_to   = trim((string)($_GET['to'] ?? ''));

Each parameter maps directly to a column or behavior in the log table.

Step 4: Build the WHERE Clause Incrementally

Instead of hardcoding queries, Init Sentinel builds the WHERE clause dynamically based on provided filters.

$where  = [];
$params = [];

if ($endpoint !== '') {
    $where[]  = 'endpoint LIKE %s';
    $params[] = $endpoint . '%';
}

if ($action !== '') {
    $where[]  = 'action LIKE %s';
    $params[] = '%' . $wpdb->esc_like($action) . '%';
}

if ($ip !== '') {
    $where[]  = 'ip_address LIKE %s';
    $params[] = '%' . $wpdb->esc_like($ip) . '%';
}

This pattern keeps the query readable, safe, and extensible.

Step 5: Support Status Code Groups (4xx, 5xx)

Status codes can be filtered either by exact value or by group.

if (preg_match('/^\d{3}$/', $code)) {
    $where[]  = 'status_code = %d';
    $params[] = (int)$code;
} elseif (preg_match('/^[245]xx$/', $code)) {
    $prefix = (int)$code[0];
    $where[]  = 'status_code BETWEEN %d AND %d';
    $params[] = $prefix * 100;
    $params[] = $prefix * 100 + 99;
}

This makes it easy to focus on authorization failures or server errors.

Step 6: Count Total Rows for Pagination

Pagination requires knowing the total number of matching records.

$total = (int) $wpdb->get_var(
    $wpdb->prepare("SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params)
);

This query is separate from the main data query to keep logic clear.

Step 7: Query Paginated Results

The actual log data is retrieved using LIMIT and OFFSET.

$sql = "SELECT id,created_at,user_id,ip_address,endpoint,action,status_code,user_agent
        FROM {$table}
        {$where_sql}
        ORDER BY {$orderby} {$order}
        LIMIT %d OFFSET %d";

$rows = $wpdb->get_results(
    $wpdb->prepare($sql, ...array_merge($params, [$per_page, $offset]))
);

Sorting is restricted to a whitelist of columns to avoid unsafe SQL.

Step 8: Render the Logs Table

Each row shows the essential attributes of a security event.

$time_local = get_date_from_gmt(
    gmdate('Y-m-d H:i:s', strtotime($r->created_at)),
    get_option('date_format').' '.get_option('time_format')
);

UTC is stored in the database, local time is only applied at display time.

Step 9: Add CSV Export for Offline Analysis

The page supports CSV export with a hard row limit and nonce protection.

if ( isset($_GET['export']) && $_GET['export'] === 'csv' ) {
    check_admin_referer('init_html_sentinel_export');
    header('Content-Type: text/csv; charset=utf-8');
    header('Content-Disposition: attachment; filename=security-logs.csv');
}

This allows incident analysis without exposing unlimited data.

Final Result

With this page in place, Init Sentinel now provides a complete workflow: log silently, observe quickly via widget, and investigate thoroughly via a dedicated admin interface.

This concludes the Init Sentinel build series.

Ideally, this page should always be empty. If it isn’t, someone is messing around.

Comments


  • No comments yet.

Init Toolbox

Press Ctrl + \ on desktop, or swipe left anywhere on mobile.

Login