Files
c-relay-pg/admin/api/cleanup_queries.php
T

325 lines
12 KiB
PHP

<?php
/**
* admin2/api/cleanup_queries.php — Saved Cleanup Query CRUD.
*
* GET — List all saved queries.
* POST — Create/update/delete/execute saved queries.
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
$method = $_SERVER['REQUEST_METHOD'];
/** Format bytes as human-readable. */
function fmt_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';
}
// ── GET: List all saved queries ────────────────────────────────────────
if ($method === 'GET') {
try {
$rows = $pdo->query(
"SELECT id, name, follows_filter, kinds, max_age_days, max_events,\n"
. " last_preview_count, last_preview_size_bytes,\n"
. " last_executed_at, created_at, updated_at\n"
. " FROM cleanup_saved_queries\n"
. " ORDER BY updated_at DESC"
)->fetchAll();
} catch (PDOException $e) {
// Table may not exist yet
json_response(['queries' => []]);
exit;
}
$queries = [];
foreach ($rows as $r) {
$kinds = $r['kinds'];
if (is_string($kinds)) {
// PostgreSQL returns {1,7} format — parse it
$kinds = trim($kinds, '{}');
$kinds = $kinds !== '' ? array_map('intval', explode(',', $kinds)) : [];
} elseif (is_resource($kinds)) {
$kinds = [];
}
$queries[] = [
'id' => intval($r['id']),
'name' => $r['name'],
'follows_filter' => $r['follows_filter'],
'kinds' => $kinds,
'max_age_days' => intval($r['max_age_days']),
'max_events' => intval($r['max_events']),
'last_preview_count' => intval($r['last_preview_count']),
'last_preview_size_human' => fmt_bytes(intval($r['last_preview_size_bytes'])),
'last_executed_at' => intval($r['last_executed_at']) > 0
? date('Y-m-d H:i:s', intval($r['last_executed_at']))
: null,
'created_at' => date('Y-m-d H:i:s', intval($r['created_at'])),
'updated_at' => date('Y-m-d H:i:s', intval($r['updated_at'])),
];
}
json_response(['queries' => $queries]);
exit;
}
// ── POST: Actions ──────────────────────────────────────────────────────
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
if (!$body || empty($body['action'])) {
json_response(['error' => 'Missing action']);
exit;
}
$action = $body['action'];
// ── Save (create or update) ────────────────────────────────────────
if ($action === 'save') {
$id = $body['id'] ?? null;
$name = trim($body['name'] ?? '');
if ($name === '') {
json_response(['error' => 'Name is required']);
exit;
}
$follows_filter = $body['follows_filter'] ?? 'all';
if (!in_array($follows_filter, ['all', 'follows', 'non_follows'])) {
$follows_filter = 'all';
}
$kinds = $body['kinds'] ?? [];
if (is_array($kinds)) {
$kinds = array_map('intval', $kinds);
$kinds = array_filter($kinds, fn($v) => $v > 0);
$kinds = array_values($kinds);
} else {
$kinds = [];
}
// PostgreSQL array literal
$kinds_pg = '{' . implode(',', $kinds) . '}';
$max_age_days = intval($body['max_age_days'] ?? 0);
$max_events = intval($body['max_events'] ?? 0);
try {
if ($id) {
// Update existing
$stmt = $pdo->prepare(
"UPDATE cleanup_saved_queries\n"
. " SET name = :name,\n"
. " follows_filter = :follows_filter,\n"
. " kinds = :kinds::integer[],\n"
. " max_age_days = :max_age_days,\n"
. " max_events = :max_events,\n"
. " updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT\n"
. " WHERE id = :id"
);
$stmt->execute([
':id' => $id,
':name' => $name,
':follows_filter' => $follows_filter,
':kinds' => $kinds_pg,
':max_age_days' => $max_age_days,
':max_events' => $max_events,
]);
} else {
// Insert new
$stmt = $pdo->prepare(
"INSERT INTO cleanup_saved_queries\n"
. " (name, follows_filter, kinds, max_age_days, max_events)\n"
. "VALUES (:name, :follows_filter, :kinds::integer[],\n"
. " :max_age_days, :max_events)\n"
. "ON CONFLICT (name) DO UPDATE SET\n"
. " follows_filter = EXCLUDED.follows_filter,\n"
. " kinds = EXCLUDED.kinds,\n"
. " max_age_days = EXCLUDED.max_age_days,\n"
. " max_events = EXCLUDED.max_events,\n"
. " updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT"
);
$stmt->execute([
':name' => $name,
':follows_filter' => $follows_filter,
':kinds' => $kinds_pg,
':max_age_days' => $max_age_days,
':max_events' => $max_events,
]);
$id = $pdo->lastInsertId();
}
} catch (PDOException $e) {
json_response(['error' => 'Save failed: ' . $e->getMessage()]);
exit;
}
json_response(['ok' => true, 'id' => intval($id)]);
exit;
}
// ── Delete ─────────────────────────────────────────────────────────
if ($action === 'delete') {
$id = intval($body['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => 'Invalid id']);
exit;
}
try {
$stmt = $pdo->prepare("DELETE FROM cleanup_saved_queries WHERE id = :id");
$stmt->execute([':id' => $id]);
} catch (PDOException $e) {
json_response(['error' => 'Delete failed: ' . $e->getMessage()]);
exit;
}
json_response(['ok' => true]);
exit;
}
// ── Execute (run a saved query as non-dry-run DELETE) ──────────────
if ($action === 'execute') {
$id = intval($body['id'] ?? 0);
if ($id <= 0) {
json_response(['error' => 'Invalid id']);
exit;
}
// Load the saved query
try {
$stmt = $pdo->prepare(
"SELECT id, name, follows_filter, kinds, max_age_days, max_events\n"
. " FROM cleanup_saved_queries\n"
. " WHERE id = :id"
);
$stmt->execute([':id' => $id]);
$query = $stmt->fetch();
} catch (PDOException $e) {
json_response(['error' => 'Query load failed: ' . $e->getMessage()]);
exit;
}
if (!$query) {
json_response(['error' => 'Saved query not found']);
exit;
}
// Parse kinds from PG array
$kinds_raw = $query['kinds'];
if (is_string($kinds_raw)) {
$kinds_raw = trim($kinds_raw, '{}');
$kinds = $kinds_raw !== '' ? array_map('intval', explode(',', $kinds_raw)) : [];
} else {
$kinds = [];
}
// Build filters (same logic as cleanup.php)
$conds = [];
$params = [];
if (!empty($kinds)) {
$kind_placeholders = [];
foreach ($kinds as $i => $k) {
$key = ':kind_' . $i;
$kind_placeholders[] = $key;
$params[$key] = $k;
}
$conds[] = 'e.kind IN (' . implode(',', $kind_placeholders) . ')';
}
$max_age_days = intval($query['max_age_days']);
if ($max_age_days > 0) {
$conds[] = 'e.created_at < EXTRACT(EPOCH FROM NOW())::BIGINT - :max_age_seconds';
$params[':max_age_seconds'] = $max_age_days * 86400;
}
$follows_filter = $query['follows_filter'];
if ($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)';
}
$where = !empty($conds) ? 'AND ' . implode("\n AND ", $conds) : '';
$max_events = intval($query['max_events']);
$limit_clause = $max_events > 0 ? 'LIMIT :max_events' : '';
// Pre-delete count
$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;
}
// Execute DELETE
$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 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;
// Update last_preview_count, last_preview_size_bytes, last_executed_at
try {
$pdo->prepare(
"UPDATE cleanup_saved_queries\n"
. " SET last_preview_count = :count,\n"
. " last_preview_size_bytes = :size,\n"
. " last_executed_at = EXTRACT(EPOCH FROM NOW())::BIGINT\n"
. " WHERE id = :id"
)->execute([
':id' => $id,
':count' => $expected_count,
':size' => $expected_bytes,
]);
} catch (PDOException $e) {}
// 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' => fmt_bytes($freed_bytes),
'duration_ms' => $duration_ms,
]);
exit;
}
json_response(['error' => 'Unknown action: ' . $action]);
exit;
}
json_response(['error' => 'Method not allowed']);