query( "SELECT id, name, follows_filter, kinds, from_date, to_date, 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, 'from_date' => $r['from_date'] ?? '', 'to_date' => $r['to_date'] ?? '', '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) . '}'; $from_date = $body['from_date'] ?? ''; $to_date = $body['to_date'] ?? ''; $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" . " from_date = :from_date,\n" . " to_date = :to_date,\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, ':from_date' => $from_date, ':to_date' => $to_date, ':max_events' => $max_events, ]); } else { // Insert new $stmt = $pdo->prepare( "INSERT INTO cleanup_saved_queries\n" . " (name, follows_filter, kinds, from_date, to_date, max_events)\n" . "VALUES (:name, :follows_filter, :kinds::integer[],\n" . " :from_date, :to_date, :max_events)\n" . "ON CONFLICT (name) DO UPDATE SET\n" . " follows_filter = EXCLUDED.follows_filter,\n" . " kinds = EXCLUDED.kinds,\n" . " from_date = EXCLUDED.from_date,\n" . " to_date = EXCLUDED.to_date,\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, ':from_date' => $from_date, ':to_date' => $to_date, ':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, from_date, to_date, 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) . ')'; } $from_date = $query['from_date'] ?? ''; $to_date = $query['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 = $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']);