- New caching_custom_backfill_jobs and caching_custom_backfill_batches tables - Admin API (POST create/cancel, GET list) at admin/api/custom_backfill.php - Admin UI form with preset buttons and job status table on Backfill page - Caching daemon module (custom_backfill.c) processes jobs independently - pg_inbox functions for job/batch claim, progress update, and cancel - Three batch modes: author-batched, id-batched, time-window scan - Fixed done_batches overcount on job completion - Admin config now environment-variable driven (C_RELAY_DB_*) - admin/serve.sh supports separate instances for different databases - make_and_restart_relay.sh only kills relay on target port, not all relays
536 lines
21 KiB
PHP
536 lines
21 KiB
PHP
<?php
|
|
/**
|
|
* admin2/api/cleanup.php — Event Cleanup: Preview & Execute.
|
|
*
|
|
* GET — Preview a cleanup query (count + size estimate, no delete).
|
|
* POST — Execute a cleanup query (dry_run=true for preview, false for delete).
|
|
*/
|
|
require_once __DIR__ . '/../lib/helpers.php';
|
|
|
|
$pdo = db();
|
|
$method = $_SERVER['REQUEST_METHOD'];
|
|
|
|
// ── Shared filter builder ──────────────────────────────────────────────
|
|
// Returns [sql_conditions[], params[]] for the WHERE clauses.
|
|
function build_filters(array $opts): array {
|
|
$conds = [];
|
|
$params = [];
|
|
|
|
// Kinds filter
|
|
if (!empty($opts['kinds'])) {
|
|
$kinds = is_array($opts['kinds']) ? $opts['kinds'] : explode(',', $opts['kinds']);
|
|
$kinds = array_map('intval', $kinds);
|
|
$kinds = array_filter($kinds, fn($v) => $v > 0);
|
|
if (!empty($kinds)) {
|
|
$placeholders = [];
|
|
foreach ($kinds as $i => $k) {
|
|
$key = ':kind_' . $i;
|
|
$placeholders[] = $key;
|
|
$params[$key] = $k;
|
|
}
|
|
$conds[] = 'e.kind IN (' . implode(',', $placeholders) . ')';
|
|
}
|
|
}
|
|
|
|
// Date/time range filter (supports YYYY-MM-DD or YYYY-MM-DD HH:MM)
|
|
$from_date = $opts['from_date'] ?? '';
|
|
$to_date = $opts['to_date'] ?? '';
|
|
if ($from_date !== '') {
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from_date)) {
|
|
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::date)::BIGINT';
|
|
$params[':from_date'] = $from_date;
|
|
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $from_date)) {
|
|
$conds[] = 'e.created_at >= EXTRACT(EPOCH FROM :from_date::timestamp)::BIGINT';
|
|
$params[':from_date'] = $from_date . ':00';
|
|
}
|
|
}
|
|
if ($to_date !== '') {
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $to_date)) {
|
|
$conds[] = 'e.created_at < (EXTRACT(EPOCH FROM :to_date::date)::BIGINT + 86400)';
|
|
$params[':to_date'] = $to_date;
|
|
} elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $to_date)) {
|
|
$conds[] = 'e.created_at < EXTRACT(EPOCH FROM :to_date::timestamp)::BIGINT';
|
|
$params[':to_date'] = $to_date . ':00';
|
|
}
|
|
}
|
|
|
|
// Follows filter — uses is_root to separate admins/roots from regular follows.
|
|
// all : no filter
|
|
// roots : only root/admin pubkeys (is_root = TRUE)
|
|
// follows_no_roots : only non-root followed pubkeys (is_root = FALSE)
|
|
// follows : roots + non-root follows (legacy; whole table)
|
|
// non_follows : pubkeys NOT in the table at all (excludes roots AND follows)
|
|
$follows_filter = $opts['follows_filter'] ?? 'all';
|
|
if ($follows_filter === 'roots') {
|
|
$conds[] = 'e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys WHERE is_root = TRUE)';
|
|
} elseif ($follows_filter === 'follows_no_roots') {
|
|
$conds[] = 'e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys WHERE is_root = FALSE)';
|
|
} elseif ($follows_filter === 'follows') {
|
|
$conds[] = 'e.pubkey IN (SELECT pubkey FROM caching_followed_pubkeys)';
|
|
} elseif ($follows_filter === 'non_follows') {
|
|
$conds[] = 'e.pubkey NOT IN (SELECT pubkey FROM caching_followed_pubkeys)';
|
|
}
|
|
|
|
return [$conds, $params];
|
|
}
|
|
|
|
// ── Build a WHERE clause string from conditions ────────────────────────
|
|
function where_clause(array $conds): string {
|
|
if (empty($conds)) return '';
|
|
return 'AND ' . implode("\n AND ", $conds);
|
|
}
|
|
|
|
// ── Format bytes as human-readable ─────────────────────────────────────
|
|
function format_bytes(int $bytes): string {
|
|
if ($bytes < 1024) return $bytes . ' B';
|
|
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
|
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
|
|
return round($bytes / 1073741824, 2) . ' GB';
|
|
}
|
|
|
|
// ── Shared: run a preview SELECT (raw SQL) and return the standard shape ─
|
|
// $sql must be a SELECT returning match_count + total_size_bytes.
|
|
// $where_for_breakdown is the WHERE clause (without leading WHERE) used to
|
|
// build the kind breakdown; if empty, breakdown is skipped.
|
|
function run_preview_from_sql(PDO $pdo, string $sql, string $where_for_breakdown = ''): array {
|
|
try {
|
|
$stmt = $pdo->query($sql);
|
|
$row = $stmt->fetch();
|
|
$match_count = intval($row['match_count'] ?? 0);
|
|
$total_size_bytes = intval($row['total_size_bytes'] ?? 0);
|
|
} catch (PDOException $e) {
|
|
json_response(['error' => 'Preview query failed: ' . $e->getMessage()]);
|
|
exit;
|
|
}
|
|
|
|
$breakdown = [];
|
|
if ($match_count > 0 && $where_for_breakdown !== '') {
|
|
$breakdown_sql = "SELECT e.kind,\n"
|
|
. " COUNT(*) AS count,\n"
|
|
. " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1 " . $where_for_breakdown . "\n"
|
|
. "GROUP BY e.kind\n"
|
|
. "ORDER BY count DESC\n"
|
|
. "LIMIT 50";
|
|
try {
|
|
$rows = $pdo->query($breakdown_sql)->fetchAll();
|
|
foreach ($rows as $r) {
|
|
$breakdown[] = [
|
|
'kind' => intval($r['kind']),
|
|
'count' => intval($r['count']),
|
|
'size_bytes' => intval($r['size_bytes']),
|
|
];
|
|
}
|
|
} catch (PDOException $e) {
|
|
// Breakdown is non-critical
|
|
}
|
|
}
|
|
|
|
return [
|
|
'match_count' => $match_count,
|
|
'total_size_bytes' => $total_size_bytes,
|
|
'total_size_human' => format_bytes($total_size_bytes),
|
|
'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0,
|
|
'kinds_breakdown' => $breakdown,
|
|
];
|
|
}
|
|
|
|
// ── Shared: extract the WHERE clause (without the WHERE keyword) from a
|
|
// preview SELECT. Returns '' if no WHERE clause is present.
|
|
function extract_where_from_sql(string $sql): string {
|
|
if (preg_match('/\bWHERE\b([\s\S]*)$/i', $sql, $m)) {
|
|
$where = trim($m[1]);
|
|
// Strip a trailing ORDER BY / LIMIT / GROUP BY / HAVING if the user
|
|
// added one to the preview SELECT — we only want the filter conditions.
|
|
$where = preg_replace('/\b(GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b[\s\S]*$/i', '', $where);
|
|
return trim($where);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ── Shared: sanitize a raw WHERE clause for use in the safe DELETE template.
|
|
// Returns [true, $where] or [false, $reason].
|
|
function sanitize_where_clause(string $where): array {
|
|
if ($where === '') return [true, ''];
|
|
// Deny dangerous keywords and statement separators / comments.
|
|
$deny = '/\b(DELETE|UPDATE|INSERT|DROP|TRUNCATE|GRANT|REVOKE|ALTER|CREATE)\b/i';
|
|
if (preg_match($deny, $where)) {
|
|
return [false, 'Disallowed keyword in WHERE clause'];
|
|
}
|
|
if (strpos($where, ';') !== false
|
|
|| strpos($where, '--') !== false
|
|
|| strpos($where, '/*') !== false) {
|
|
return [false, 'Statement separators / comments are not allowed in WHERE clause'];
|
|
}
|
|
return [true, $where];
|
|
}
|
|
|
|
// ── GET: Preview (form filters) ────────────────────────────────────────
|
|
if ($method === 'GET') {
|
|
$opts = [
|
|
'follows_filter' => $_GET['follows_filter'] ?? 'all',
|
|
'kinds' => $_GET['kinds'] ?? '',
|
|
'from_date' => $_GET['from_date'] ?? '',
|
|
'to_date' => $_GET['to_date'] ?? '',
|
|
'max_events' => intval($_GET['max_events'] ?? 0),
|
|
];
|
|
|
|
list($conds, $params) = build_filters($opts);
|
|
$where = where_clause($conds);
|
|
|
|
// Preview SQL (for display)
|
|
$sql_preview = "SELECT COUNT(*) AS match_count,\n"
|
|
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1\n"
|
|
. ($where ? " $where\n" : '');
|
|
|
|
// Count + size query
|
|
$count_sql = "SELECT COUNT(*) AS match_count,\n"
|
|
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1 $where";
|
|
|
|
try {
|
|
$stmt = $pdo->prepare($count_sql);
|
|
$stmt->execute($params);
|
|
$row = $stmt->fetch();
|
|
$match_count = intval($row['match_count'] ?? 0);
|
|
$total_size_bytes = intval($row['total_size_bytes'] ?? 0);
|
|
} catch (PDOException $e) {
|
|
json_response(['error' => 'Preview query failed: ' . $e->getMessage()]);
|
|
exit;
|
|
}
|
|
|
|
// Kind breakdown
|
|
$breakdown = [];
|
|
if ($match_count > 0) {
|
|
$breakdown_sql = "SELECT e.kind,\n"
|
|
. " COUNT(*) AS count,\n"
|
|
. " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1 $where\n"
|
|
. "GROUP BY e.kind\n"
|
|
. "ORDER BY count DESC\n"
|
|
. "LIMIT 50";
|
|
try {
|
|
$stmt = $pdo->prepare($breakdown_sql);
|
|
$stmt->execute($params);
|
|
$rows = $stmt->fetchAll();
|
|
foreach ($rows as $r) {
|
|
$breakdown[] = [
|
|
'kind' => intval($r['kind']),
|
|
'count' => intval($r['count']),
|
|
'size_bytes' => intval($r['size_bytes']),
|
|
];
|
|
}
|
|
} catch (PDOException $e) {
|
|
// Breakdown is non-critical
|
|
}
|
|
}
|
|
|
|
// If a query_id was provided, update last_preview_count and last_preview_size_bytes
|
|
$query_id = intval($_GET['query_id'] ?? 0);
|
|
if ($query_id > 0) {
|
|
try {
|
|
$pdo->prepare(
|
|
"UPDATE cleanup_saved_queries\n"
|
|
. " SET last_preview_count = :count,\n"
|
|
. " last_preview_size_bytes = :size\n"
|
|
. " WHERE id = :id"
|
|
)->execute([
|
|
':id' => $query_id,
|
|
':count' => $match_count,
|
|
':size' => $total_size_bytes,
|
|
]);
|
|
} catch (PDOException $e) {}
|
|
}
|
|
|
|
json_response([
|
|
'match_count' => $match_count,
|
|
'total_size_bytes' => $total_size_bytes,
|
|
'total_size_human' => format_bytes($total_size_bytes),
|
|
'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0,
|
|
'kinds_breakdown' => $breakdown,
|
|
'sql_preview' => $sql_preview,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// ── POST: Preview-from-SQL / Execute-from-WHERE / Execute (or dry-run) ──
|
|
if ($method === 'POST') {
|
|
$body = json_decode(file_get_contents('php://input'), true);
|
|
if (!$body) {
|
|
json_response(['error' => 'Invalid JSON body']);
|
|
exit;
|
|
}
|
|
|
|
$action = $body['action'] ?? '';
|
|
|
|
// ── action: preview_sql — run an edited SELECT directly ─────────────
|
|
// The SQL box is the source of truth. Must be a SELECT returning
|
|
// match_count + total_size_bytes.
|
|
if ($action === 'preview_sql') {
|
|
$sql = trim($body['sql'] ?? '');
|
|
if ($sql === '') {
|
|
json_response(['error' => 'No SQL provided']);
|
|
exit;
|
|
}
|
|
if (!preg_match('/^\s*SELECT\b/i', $sql)) {
|
|
json_response(['error' => 'Only SELECT queries are allowed']);
|
|
exit;
|
|
}
|
|
// Defense in depth: block writes / chaining.
|
|
$deny = '/\b(DELETE|UPDATE|INSERT|DROP|TRUNCATE|GRANT|REVOKE|ALTER|CREATE)\b/i';
|
|
if (preg_match($deny, $sql)) {
|
|
json_response(['error' => 'Disallowed keyword in query']);
|
|
exit;
|
|
}
|
|
if (strpos($sql, ';') !== false || strpos($sql, '--') !== false || strpos($sql, '/*') !== false) {
|
|
json_response(['error' => 'Statement separators / comments are not allowed']);
|
|
exit;
|
|
}
|
|
|
|
$where_for_breakdown = extract_where_from_sql($sql);
|
|
$result = run_preview_from_sql($pdo, $sql, $where_for_breakdown);
|
|
$result['sql_preview'] = $sql;
|
|
$result['dry_run'] = true;
|
|
|
|
// If a query_id was provided, update last_preview_count/size on the saved query
|
|
$query_id = intval($body['query_id'] ?? 0);
|
|
if ($query_id > 0) {
|
|
try {
|
|
$pdo->prepare(
|
|
"UPDATE cleanup_saved_queries\n"
|
|
. " SET last_preview_count = :count,\n"
|
|
. " last_preview_size_bytes = :size\n"
|
|
. " WHERE id = :id"
|
|
)->execute([
|
|
':id' => $query_id,
|
|
':count' => $result['match_count'],
|
|
':size' => $result['total_size_bytes'],
|
|
]);
|
|
} catch (PDOException $e) {}
|
|
}
|
|
|
|
json_response($result);
|
|
exit;
|
|
}
|
|
|
|
// ── action: execute_where — extract WHERE, run safe DELETE template ─
|
|
// The destructive path never runs user SQL directly; it injects the
|
|
// extracted WHERE into the fixed DELETE ... id IN (subquery) template.
|
|
if ($action === 'execute_where') {
|
|
$sql = trim($body['sql'] ?? '');
|
|
$max_events = intval($body['max_events'] ?? 0);
|
|
|
|
if ($sql === '') {
|
|
json_response(['error' => 'No SQL provided']);
|
|
exit;
|
|
}
|
|
if (!preg_match('/^\s*SELECT\b/i', $sql)) {
|
|
json_response(['error' => 'Only SELECT queries are allowed']);
|
|
exit;
|
|
}
|
|
|
|
$where_raw = extract_where_from_sql($sql);
|
|
list($ok, $where_or_reason) = sanitize_where_clause($where_raw);
|
|
if (!$ok) {
|
|
json_response(['error' => $where_or_reason]);
|
|
exit;
|
|
}
|
|
$where = $where_or_reason;
|
|
// where_clause() prepends "AND "; reuse that shape for consistency.
|
|
$where_clause_str = $where !== '' ? 'AND ' . $where : '';
|
|
|
|
$limit_clause = $max_events > 0 ? 'LIMIT :max_events' : '';
|
|
|
|
// Pre-delete count + size
|
|
$preview_sql = "SELECT COUNT(*) AS match_count,\n"
|
|
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1 $where_clause_str";
|
|
try {
|
|
$stmt = $pdo->prepare($preview_sql);
|
|
if ($max_events > 0) $stmt->bindValue(':max_events', $max_events, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
$row = $stmt->fetch();
|
|
$expected_count = intval($row['match_count'] ?? 0);
|
|
$expected_bytes = intval($row['total_size_bytes'] ?? 0);
|
|
} catch (PDOException $e) {
|
|
json_response(['error' => 'Pre-delete count failed: ' . $e->getMessage()]);
|
|
exit;
|
|
}
|
|
|
|
$delete_sql = "DELETE FROM events e\n"
|
|
. "WHERE e.id IN (\n"
|
|
. " SELECT e2.id FROM events e2\n"
|
|
. " WHERE 1=1 $where_clause_str\n"
|
|
. " ORDER BY e2.created_at ASC\n"
|
|
. " $limit_clause\n"
|
|
. ")";
|
|
|
|
$start_time = microtime(true);
|
|
try {
|
|
$stmt = $pdo->prepare($delete_sql);
|
|
if ($max_events > 0) $stmt->bindValue(':max_events', $max_events, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
$deleted_count = $stmt->rowCount();
|
|
} catch (PDOException $e) {
|
|
json_response(['error' => 'Delete query failed: ' . $e->getMessage()]);
|
|
exit;
|
|
}
|
|
$duration_ms = round((microtime(true) - $start_time) * 1000);
|
|
|
|
$freed_bytes = $expected_count > 0
|
|
? intval($expected_bytes * ($deleted_count / $expected_count))
|
|
: 0;
|
|
|
|
// Clear stats cache so the dashboard reflects changes immediately
|
|
$cache_dir = __DIR__ . '/../cache';
|
|
foreach (['stats_kinds.json', 'stats_pubkeys.json'] as $cache_file) {
|
|
$path = $cache_dir . '/' . $cache_file;
|
|
if (is_file($path)) @unlink($path);
|
|
}
|
|
|
|
json_response([
|
|
'deleted_count' => $deleted_count,
|
|
'freed_bytes' => $freed_bytes,
|
|
'freed_human' => format_bytes($freed_bytes),
|
|
'duration_ms' => $duration_ms,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// ── Default: form-filter execute (or dry-run) ───────────────────────
|
|
$dry_run = !empty($body['dry_run']);
|
|
$opts = [
|
|
'follows_filter' => $body['follows_filter'] ?? 'all',
|
|
'kinds' => $body['kinds'] ?? [],
|
|
'from_date' => $body['from_date'] ?? '',
|
|
'to_date' => $body['to_date'] ?? '',
|
|
'max_events' => intval($body['max_events'] ?? 0),
|
|
];
|
|
|
|
list($conds, $params) = build_filters($opts);
|
|
$where = where_clause($conds);
|
|
|
|
// If dry_run, return preview (same as GET)
|
|
if ($dry_run) {
|
|
$count_sql = "SELECT COUNT(*) AS match_count,\n"
|
|
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1 $where";
|
|
try {
|
|
$stmt = $pdo->prepare($count_sql);
|
|
$stmt->execute($params);
|
|
$row = $stmt->fetch();
|
|
$match_count = intval($row['match_count'] ?? 0);
|
|
$total_size_bytes = intval($row['total_size_bytes'] ?? 0);
|
|
} catch (PDOException $e) {
|
|
json_response(['error' => 'Preview query failed: ' . $e->getMessage()]);
|
|
exit;
|
|
}
|
|
|
|
// Kind breakdown
|
|
$breakdown = [];
|
|
if ($match_count > 0) {
|
|
$breakdown_sql = "SELECT e.kind, COUNT(*) AS count,\n"
|
|
. " COALESCE(SUM(pg_column_size(e.event_json)), 0) AS size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1 $where\n"
|
|
. "GROUP BY e.kind\n"
|
|
. "ORDER BY count DESC\n"
|
|
. "LIMIT 50";
|
|
try {
|
|
$stmt = $pdo->prepare($breakdown_sql);
|
|
$stmt->execute($params);
|
|
foreach ($stmt->fetchAll() as $r) {
|
|
$breakdown[] = [
|
|
'kind' => intval($r['kind']),
|
|
'count' => intval($r['count']),
|
|
'size_bytes' => intval($r['size_bytes']),
|
|
];
|
|
}
|
|
} catch (PDOException $e) {}
|
|
}
|
|
|
|
json_response([
|
|
'match_count' => $match_count,
|
|
'total_size_bytes' => $total_size_bytes,
|
|
'total_size_human' => format_bytes($total_size_bytes),
|
|
'avg_size_per_event' => $match_count > 0 ? intval($total_size_bytes / $match_count) : 0,
|
|
'kinds_breakdown' => $breakdown,
|
|
'dry_run' => true,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// ── Actual DELETE ──────────────────────────────────────────────────
|
|
$max_events = intval($body['max_events'] ?? 0);
|
|
$limit_clause = $max_events > 0 ? 'LIMIT :max_events' : '';
|
|
|
|
// First, get the count and size of what will be deleted
|
|
$preview_sql = "SELECT COUNT(*) AS match_count,\n"
|
|
. " COALESCE(SUM(pg_column_size(event_json)), 0) AS total_size_bytes\n"
|
|
. "FROM events e\n"
|
|
. "WHERE 1=1 $where";
|
|
try {
|
|
$stmt = $pdo->prepare($preview_sql);
|
|
$stmt->execute($params);
|
|
$row = $stmt->fetch();
|
|
$expected_count = intval($row['match_count'] ?? 0);
|
|
$expected_bytes = intval($row['total_size_bytes'] ?? 0);
|
|
} catch (PDOException $e) {
|
|
json_response(['error' => 'Pre-delete count failed: ' . $e->getMessage()]);
|
|
exit;
|
|
}
|
|
|
|
// Build the DELETE using id IN (subquery) for safe LIMIT + ORDER BY
|
|
$delete_sql = "DELETE FROM events e\n"
|
|
. "WHERE e.id IN (\n"
|
|
. " SELECT e2.id FROM events e2\n"
|
|
. " WHERE 1=1 $where\n"
|
|
. " ORDER BY e2.created_at ASC\n"
|
|
. " $limit_clause\n"
|
|
. ")";
|
|
|
|
$start_time = microtime(true);
|
|
try {
|
|
$stmt = $pdo->prepare($delete_sql);
|
|
if ($max_events > 0) {
|
|
$params[':max_events'] = $max_events;
|
|
}
|
|
$stmt->execute($params);
|
|
$deleted_count = $stmt->rowCount();
|
|
} catch (PDOException $e) {
|
|
json_response(['error' => 'Delete query failed: ' . $e->getMessage()]);
|
|
exit;
|
|
}
|
|
$duration_ms = round((microtime(true) - $start_time) * 1000);
|
|
|
|
// If we deleted fewer than expected, the actual freed bytes are proportional
|
|
$freed_bytes = $expected_count > 0
|
|
? intval($expected_bytes * ($deleted_count / $expected_count))
|
|
: 0;
|
|
|
|
// Clear stats cache so the dashboard reflects changes immediately
|
|
$cache_dir = __DIR__ . '/../cache';
|
|
foreach (['stats_kinds.json', 'stats_pubkeys.json'] as $cache_file) {
|
|
$path = $cache_dir . '/' . $cache_file;
|
|
if (is_file($path)) @unlink($path);
|
|
}
|
|
|
|
json_response([
|
|
'deleted_count' => $deleted_count,
|
|
'freed_bytes' => $freed_bytes,
|
|
'freed_human' => format_bytes($freed_bytes),
|
|
'duration_ms' => $duration_ms,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Unsupported method
|
|
json_response(['error' => 'Method not allowed']);
|