325 lines
12 KiB
PHP
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']);
|