Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe27b5e41a |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,4 +3,4 @@ logs/
|
||||
nostr_core_lib/
|
||||
blobs/
|
||||
c-relay/
|
||||
|
||||
text_graph/
|
||||
|
||||
@@ -97,12 +97,12 @@
|
||||
<td id="total-events">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Process ID</td>
|
||||
<td id="process-id">-</td>
|
||||
<td>Total Size</td>
|
||||
<td id="total-size">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Active Connections</td>
|
||||
<td id="active-subscriptions">-</td>
|
||||
<td>Process ID</td>
|
||||
<td id="process-id">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Memory Usage</td>
|
||||
@@ -188,7 +188,8 @@
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Pubkey</th>
|
||||
<th>Event Count</th>
|
||||
<th>Blob Count</th>
|
||||
<th>Total Size</th>
|
||||
<th>Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
318
api/index.js
318
api/index.js
@@ -136,7 +136,7 @@ async function fetchRelayInfo(relayUrl) {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/nostr+json',
|
||||
'User-Agent': 'C-Relay-Admin-API/1.0'
|
||||
'User-Agent': 'Blossom-Admin-API/1.0'
|
||||
},
|
||||
timeout: 10000 // 10 second timeout
|
||||
});
|
||||
@@ -511,10 +511,8 @@ function updateAdminSectionsVisibility() {
|
||||
function loadCurrentPageData() {
|
||||
switch (currentPage) {
|
||||
case 'statistics':
|
||||
// Load statistics immediately (no auto-refresh - using real-time monitoring events)
|
||||
sendStatsQuery().catch(error => {
|
||||
console.log('Auto-fetch statistics failed: ' + error.message);
|
||||
});
|
||||
// Start HTTP polling for statistics (polls every 10 seconds)
|
||||
startStatsPolling();
|
||||
break;
|
||||
case 'configuration':
|
||||
// Load configuration
|
||||
@@ -1311,7 +1309,7 @@ function initializeEventRateChart() {
|
||||
eventRateChart = new ASCIIBarChart('event-rate-chart', {
|
||||
maxHeight: 11, // Chart height in lines
|
||||
maxDataPoints: 76, // Show last 76 bins (5+ minutes of history)
|
||||
title: 'New Events', // Chart title
|
||||
title: 'New Blobs', // Chart title
|
||||
xAxisLabel: '', // No X-axis label
|
||||
yAxisLabel: '', // No Y-axis label
|
||||
autoFitWidth: true, // Enable responsive font sizing
|
||||
@@ -3298,7 +3296,7 @@ async function testPostEvent() {
|
||||
["t", "test"],
|
||||
["client", "c-relay-admin-api"]
|
||||
],
|
||||
content: `Test event from C-Relay Admin API at ${new Date().toISOString()}`
|
||||
content: `Test event from Blossom Admin API at ${new Date().toISOString()}`
|
||||
};
|
||||
|
||||
logTestEvent('SENT', `Test event (before signing): ${JSON.stringify(testEvent)}`, 'EVENT');
|
||||
@@ -3642,8 +3640,8 @@ function updateRelayInfoInHeader() {
|
||||
|
||||
// Get relay info from NIP-11 data or use defaults
|
||||
const relayInfo = getRelayInfo();
|
||||
const relayName = relayInfo.name || 'C-Relay';
|
||||
const relayDescription = relayInfo.description || 'Nostr Relay';
|
||||
const relayName = relayInfo.name || 'Blossom';
|
||||
const relayDescription = relayInfo.description || 'Blob Storage Server';
|
||||
|
||||
// Convert relay pubkey to npub
|
||||
let relayNpub = 'Loading...';
|
||||
@@ -3682,8 +3680,8 @@ function getRelayInfo() {
|
||||
|
||||
// Default values
|
||||
return {
|
||||
name: 'C-Relay',
|
||||
description: 'Nostr Relay',
|
||||
name: 'Blossom',
|
||||
description: 'Blob Storage Server',
|
||||
pubkey: relayPubkey
|
||||
};
|
||||
}
|
||||
@@ -3692,17 +3690,17 @@ function getRelayInfo() {
|
||||
function updateStoredRelayInfo(configData) {
|
||||
if (configData && configData.data) {
|
||||
// Extract relay info from config data - handle both object and array formats
|
||||
let relayName = 'C-Relay';
|
||||
let relayDescription = 'Nostr Relay';
|
||||
let relayName = 'Blossom';
|
||||
let relayDescription = 'Blob Storage Server';
|
||||
|
||||
if (Array.isArray(configData.data)) {
|
||||
// Array format: [{key: 'x', value: 'y'}, ...]
|
||||
relayName = configData.data.find(item => item.key === 'relay_name')?.value || 'C-Relay';
|
||||
relayDescription = configData.data.find(item => item.key === 'relay_description')?.value || 'Nostr Relay';
|
||||
relayName = configData.data.find(item => item.key === 'relay_name')?.value || 'Blossom';
|
||||
relayDescription = configData.data.find(item => item.key === 'relay_description')?.value || 'Blob Storage Server';
|
||||
} else {
|
||||
// Object format: {key1: 'value1', key2: 'value2', ...}
|
||||
relayName = configData.data.relay_name || 'C-Relay';
|
||||
relayDescription = configData.data.relay_description || 'Nostr Relay';
|
||||
relayName = configData.data.relay_name || 'Blossom';
|
||||
relayDescription = configData.data.relay_description || 'Blob Storage Server';
|
||||
}
|
||||
|
||||
relayInfoData = {
|
||||
@@ -3837,7 +3835,7 @@ async function sendRestartCommand() {
|
||||
}
|
||||
}
|
||||
|
||||
// Send stats_query command to get database statistics using Administrator API (inner events)
|
||||
// Send query_view commands to get database statistics via HTTP POST
|
||||
async function sendStatsQuery() {
|
||||
if (!isLoggedIn || !userPubkey) {
|
||||
log('Must be logged in to query database statistics', 'ERROR');
|
||||
@@ -3845,74 +3843,81 @@ async function sendStatsQuery() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!relayPool) {
|
||||
log('SimplePool connection not available', 'ERROR');
|
||||
updateStatsStatus('error', 'No relay connection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateStatsStatus('loading', 'Querying database...');
|
||||
|
||||
// Create command array for stats query
|
||||
const command_array = ["stats_query", "all"];
|
||||
// Query blob_overview view for basic stats
|
||||
const overviewData = await sendAdminCommandHTTP(['query_view', 'blob_overview']);
|
||||
handleViewQueryResponse('blob_overview', overviewData);
|
||||
|
||||
// Encrypt the command array directly using NIP-44
|
||||
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||
if (!encrypted_content) {
|
||||
throw new Error('Failed to encrypt command array');
|
||||
}
|
||||
// Query blob_type_distribution view
|
||||
const typeData = await sendAdminCommandHTTP(['query_view', 'blob_type_distribution']);
|
||||
handleViewQueryResponse('blob_type_distribution', typeData);
|
||||
|
||||
// Create single kind 23456 admin event
|
||||
const statsEvent = {
|
||||
kind: 23456,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", getRelayPubkey()]],
|
||||
content: encrypted_content
|
||||
};
|
||||
// Query blob_time_stats view
|
||||
const timeData = await sendAdminCommandHTTP(['query_view', 'blob_time_stats']);
|
||||
handleViewQueryResponse('blob_time_stats', timeData);
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await window.nostr.signEvent(statsEvent);
|
||||
if (!signedEvent || !signedEvent.sig) {
|
||||
throw new Error('Event signing failed');
|
||||
}
|
||||
|
||||
log('Sending stats query command...', 'INFO');
|
||||
|
||||
// Publish via SimplePool
|
||||
const url = relayConnectionUrl.value.trim();
|
||||
const publishPromises = relayPool.publish([url], signedEvent);
|
||||
|
||||
// Use Promise.allSettled to capture per-relay outcomes
|
||||
const results = await Promise.allSettled(publishPromises);
|
||||
|
||||
// Check if any relay accepted the event
|
||||
let successCount = 0;
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++;
|
||||
log(`Stats query published successfully to relay ${index}`, 'INFO');
|
||||
} else {
|
||||
log(`Stats query failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount === 0) {
|
||||
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
||||
throw new Error(`All relays rejected stats query event. Details: ${errorDetails}`);
|
||||
}
|
||||
|
||||
log('Stats query command sent successfully - waiting for response...', 'INFO');
|
||||
updateStatsStatus('waiting', 'Waiting for response...');
|
||||
// Query top_uploaders view
|
||||
const uploadersData = await sendAdminCommandHTTP(['query_view', 'top_uploaders']);
|
||||
handleViewQueryResponse('top_uploaders', uploadersData);
|
||||
|
||||
log('All view queries completed successfully', 'INFO');
|
||||
updateStatsStatus('loaded');
|
||||
|
||||
} catch (error) {
|
||||
log(`Failed to send stats query: ${error.message}`, 'ERROR');
|
||||
log(`Failed to send view queries: ${error.message}`, 'ERROR');
|
||||
updateStatsStatus('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stats_query response and populate tables
|
||||
// Handle query_view response and populate appropriate table
|
||||
function handleViewQueryResponse(viewName, responseData) {
|
||||
try {
|
||||
console.log(`Processing view query response: ${viewName}`, responseData);
|
||||
|
||||
if (responseData.query_type !== 'query_view') {
|
||||
log('Ignoring non-view-query response', 'WARNING');
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseData.status !== 'success') {
|
||||
log(`View query failed: ${responseData.error || 'Unknown error'}`, 'ERROR');
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to appropriate handler based on view name
|
||||
switch (viewName) {
|
||||
case 'blob_overview':
|
||||
if (responseData.data && Array.isArray(responseData.data) && responseData.data.length > 0) {
|
||||
populateStatsOverview(responseData.data[0]);
|
||||
}
|
||||
break;
|
||||
case 'blob_type_distribution':
|
||||
if (responseData.data && Array.isArray(responseData.data)) {
|
||||
populateStatsKinds(responseData.data);
|
||||
}
|
||||
break;
|
||||
case 'blob_time_stats':
|
||||
if (responseData.data && Array.isArray(responseData.data) && responseData.data.length > 0) {
|
||||
populateStatsTime(responseData.data[0]);
|
||||
}
|
||||
break;
|
||||
case 'top_uploaders':
|
||||
if (responseData.data && Array.isArray(responseData.data)) {
|
||||
populateStatsPubkeys(responseData.data);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown view name: ${viewName}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`Error processing ${viewName} response: ${error.message}`, 'ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy handler for backward compatibility
|
||||
function handleStatsQueryResponse(responseData) {
|
||||
try {
|
||||
log('Processing stats query response...', 'INFO');
|
||||
@@ -3923,20 +3928,30 @@ function handleStatsQueryResponse(responseData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate overview table
|
||||
populateStatsOverview(responseData);
|
||||
// Extract the actual data object
|
||||
const statsData = responseData.data || responseData;
|
||||
console.log('Extracted stats data:', statsData);
|
||||
|
||||
// Populate event kinds table
|
||||
populateStatsKinds(responseData);
|
||||
// Populate overview table with blob statistics
|
||||
populateStatsOverview(statsData);
|
||||
|
||||
// Populate blob type distribution table
|
||||
if (statsData.type_distribution && Array.isArray(statsData.type_distribution)) {
|
||||
populateStatsKinds(statsData.type_distribution);
|
||||
}
|
||||
|
||||
// Populate time-based statistics
|
||||
populateStatsTime(responseData);
|
||||
if (statsData.blobs_24h !== undefined) {
|
||||
populateStatsTime(statsData);
|
||||
}
|
||||
|
||||
// Populate top pubkeys table
|
||||
populateStatsPubkeys(responseData);
|
||||
// Populate top uploaders table
|
||||
if (statsData.top_uploaders && Array.isArray(statsData.top_uploaders)) {
|
||||
populateStatsPubkeys(statsData.top_uploaders);
|
||||
}
|
||||
|
||||
updateStatsStatus('loaded');
|
||||
log('Database statistics updated successfully', 'INFO');
|
||||
log('Blob statistics updated successfully', 'INFO');
|
||||
|
||||
} catch (error) {
|
||||
log(`Error processing stats response: ${error.message}`, 'ERROR');
|
||||
@@ -4110,32 +4125,44 @@ function populateStatsOverview(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Update individual cells with flash animation for changed values
|
||||
updateStatsCell('db-size', data.database_size_bytes ? formatFileSize(data.database_size_bytes) : '-');
|
||||
updateStatsCell('total-events', data.total_events || '-');
|
||||
updateStatsCell('oldest-event', data.database_created_at ? formatTimestamp(data.database_created_at) : '-');
|
||||
updateStatsCell('newest-event', data.latest_event_at ? formatTimestamp(data.latest_event_at) : '-');
|
||||
// Backend sends: total_bytes, total_blobs, first_upload, last_upload
|
||||
updateStatsCell('db-size', data.total_bytes ? formatFileSize(data.total_bytes) : '-');
|
||||
updateStatsCell('total-size', data.total_bytes ? formatFileSize(data.total_bytes) : '-');
|
||||
updateStatsCell('total-events', data.total_blobs || '-');
|
||||
updateStatsCell('oldest-event', data.first_upload ? formatTimestamp(data.first_upload) : '-');
|
||||
updateStatsCell('newest-event', data.last_upload ? formatTimestamp(data.last_upload) : '-');
|
||||
}
|
||||
|
||||
// Populate event kinds distribution table
|
||||
function populateStatsKinds(data) {
|
||||
const tableBody = document.getElementById('stats-kinds-table-body');
|
||||
if (!tableBody || !data.event_kinds) return;
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (data.event_kinds.length === 0) {
|
||||
// Handle both old format (data.event_kinds) and new format (direct array from query_view)
|
||||
const kindsData = data.event_kinds || data;
|
||||
|
||||
if (!Array.isArray(kindsData) || kindsData.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="3" style="text-align: center; font-style: italic;">No event data</td>';
|
||||
row.innerHTML = '<td colspan="3" style="text-align: center; font-style: italic;">No blob type data</td>';
|
||||
tableBody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
data.event_kinds.forEach(kind => {
|
||||
// Calculate total for percentages if not provided
|
||||
const total = kindsData.reduce((sum, item) => sum + (item.blob_count || item.count || 0), 0);
|
||||
|
||||
kindsData.forEach(item => {
|
||||
const row = document.createElement('tr');
|
||||
const mimeType = item.mime_type || item.kind || '-';
|
||||
const count = item.blob_count || item.count || 0;
|
||||
const percentage = item.percentage || (total > 0 ? ((count / total) * 100).toFixed(1) : 0);
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${kind.kind}</td>
|
||||
<td>${kind.count}</td>
|
||||
<td>${kind.percentage}%</td>
|
||||
<td>${mimeType}</td>
|
||||
<td>${count}</td>
|
||||
<td>${percentage}%</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
@@ -4145,48 +4172,59 @@ function populateStatsKinds(data) {
|
||||
function populateStatsTime(data) {
|
||||
if (!data) return;
|
||||
|
||||
// Access the nested time_stats object from backend response
|
||||
const timeStats = data.time_stats || {};
|
||||
|
||||
// Update cells with flash animation for changed values
|
||||
updateStatsCell('events-24h', timeStats.last_24h || '0');
|
||||
updateStatsCell('events-7d', timeStats.last_7d || '0');
|
||||
updateStatsCell('events-30d', timeStats.last_30d || '0');
|
||||
updateStatsCell('events-24h', data.blobs_24h || '0');
|
||||
updateStatsCell('events-7d', data.blobs_7d || '0');
|
||||
updateStatsCell('events-30d', data.blobs_30d || '0');
|
||||
}
|
||||
|
||||
// Populate top pubkeys table
|
||||
function populateStatsPubkeys(data) {
|
||||
const tableBody = document.getElementById('stats-pubkeys-table-body');
|
||||
if (!tableBody || !data.top_pubkeys) return;
|
||||
if (!tableBody) return;
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
|
||||
if (data.top_pubkeys.length === 0) {
|
||||
// Handle both old format (data.top_pubkeys) and new format (direct array from query_view)
|
||||
const pubkeysData = data.top_pubkeys || data;
|
||||
|
||||
if (!Array.isArray(pubkeysData) || pubkeysData.length === 0) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
|
||||
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No uploader data</td>';
|
||||
tableBody.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
data.top_pubkeys.forEach((pubkey, index) => {
|
||||
// Calculate total for percentages if not provided
|
||||
const total = pubkeysData.reduce((sum, item) => sum + (item.blob_count || 0), 0);
|
||||
|
||||
pubkeysData.forEach((item, index) => {
|
||||
const row = document.createElement('tr');
|
||||
// Handle both uploader_pubkey (new) and pubkey (old) field names
|
||||
const pubkeyValue = item.uploader_pubkey || item.pubkey || '-';
|
||||
const count = item.blob_count || 0;
|
||||
const totalBytes = item.total_bytes || 0;
|
||||
const percentage = item.percentage || (total > 0 ? ((count / total) * 100).toFixed(1) : 0);
|
||||
|
||||
// Convert hex pubkey to npub for display
|
||||
let displayPubkey = pubkey.pubkey || '-';
|
||||
let displayPubkey = pubkeyValue;
|
||||
let npubLink = displayPubkey;
|
||||
try {
|
||||
if (pubkey.pubkey && pubkey.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey.pubkey)) {
|
||||
const npub = window.NostrTools.nip19.npubEncode(pubkey.pubkey);
|
||||
if (pubkeyValue && pubkeyValue.length === 64 && /^[0-9a-fA-F]+$/.test(pubkeyValue)) {
|
||||
const npub = window.NostrTools.nip19.npubEncode(pubkeyValue);
|
||||
displayPubkey = npub;
|
||||
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to encode pubkey to npub:', error.message);
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
|
||||
<td>${pubkey.event_count}</td>
|
||||
<td>${pubkey.percentage}%</td>
|
||||
<td>${count}</td>
|
||||
<td>${formatFileSize(totalBytes)}</td>
|
||||
<td>${percentage}%</td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
@@ -4467,15 +4505,68 @@ function updateStatsCell(cellId, newValue) {
|
||||
}
|
||||
}
|
||||
|
||||
// Start auto-refreshing database statistics every 10 seconds
|
||||
// Start polling for statistics (every 10 seconds)
|
||||
function startStatsPolling() {
|
||||
console.log('=== STARTING STATISTICS POLLING ===');
|
||||
console.log('Current page:', currentPage);
|
||||
console.log('Is logged in:', isLoggedIn);
|
||||
console.log('User pubkey:', userPubkey);
|
||||
console.log('Relay pubkey:', relayPubkey);
|
||||
|
||||
// Stop any existing polling first
|
||||
stopStatsPolling();
|
||||
|
||||
// Fetch immediately
|
||||
console.log('Fetching statistics immediately...');
|
||||
sendStatsQuery().catch(error => {
|
||||
console.error('Initial stats fetch failed:', error);
|
||||
});
|
||||
|
||||
// Set up polling interval (10 seconds)
|
||||
console.log('Setting up 10-second polling interval...');
|
||||
statsAutoRefreshInterval = setInterval(() => {
|
||||
console.log('⏰ Polling interval triggered - fetching statistics...');
|
||||
sendStatsQuery().catch(error => {
|
||||
console.error('Polling stats fetch failed:', error);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
console.log('Statistics polling started successfully');
|
||||
console.log('Interval ID:', statsAutoRefreshInterval);
|
||||
log('Statistics polling started (10 second interval)', 'INFO');
|
||||
}
|
||||
|
||||
// Stop polling for statistics
|
||||
function stopStatsPolling() {
|
||||
if (statsAutoRefreshInterval) {
|
||||
clearInterval(statsAutoRefreshInterval);
|
||||
statsAutoRefreshInterval = null;
|
||||
log('Statistics polling stopped', 'INFO');
|
||||
}
|
||||
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
|
||||
// Reset countdown display
|
||||
updateCountdownDisplay();
|
||||
}
|
||||
|
||||
// Legacy function - kept for backward compatibility
|
||||
function startStatsAutoRefresh() {
|
||||
// DISABLED - Using real-time monitoring events instead of polling
|
||||
// This function is kept for backward compatibility but no longer starts auto-refresh
|
||||
log('Database statistics auto-refresh DISABLED - using real-time monitoring events', 'INFO');
|
||||
}
|
||||
|
||||
// Stop auto-refreshing database statistics
|
||||
// Legacy function - kept for backward compatibility
|
||||
function stopStatsAutoRefresh() {
|
||||
stopStatsPolling();
|
||||
}
|
||||
|
||||
// Original stopStatsAutoRefresh implementation (now unused)
|
||||
function stopStatsAutoRefresh_ORIGINAL() {
|
||||
if (statsAutoRefreshInterval) {
|
||||
clearInterval(statsAutoRefreshInterval);
|
||||
statsAutoRefreshInterval = null;
|
||||
@@ -4659,6 +4750,11 @@ function closeSideNav() {
|
||||
}
|
||||
|
||||
function switchPage(pageName) {
|
||||
// Stop statistics polling if leaving statistics page
|
||||
if (currentPage === 'statistics' && pageName !== 'statistics') {
|
||||
stopStatsPolling();
|
||||
}
|
||||
|
||||
// Update current page
|
||||
currentPage = pageName;
|
||||
|
||||
@@ -4728,7 +4824,7 @@ function switchPage(pageName) {
|
||||
|
||||
// Initialize the app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('C-Relay Admin API interface loaded');
|
||||
console.log('Blossom Admin API interface loaded');
|
||||
|
||||
// Initialize dark mode
|
||||
initializeDarkMode();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
build/main.o
BIN
build/main.o
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,8 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/types.h>
|
||||
#include "ginxsom.h"
|
||||
|
||||
// Forward declarations for nostr_core_lib functions
|
||||
@@ -27,6 +29,7 @@ extern char g_db_path[];
|
||||
static int get_server_privkey(unsigned char* privkey_bytes);
|
||||
static int get_server_pubkey(char* pubkey_hex, size_t size);
|
||||
static int handle_config_query_command(cJSON* response_data);
|
||||
static int handle_query_view_command(cJSON* command_array, cJSON* response_data);
|
||||
static int send_admin_response_event(const char* admin_pubkey, const char* request_id,
|
||||
cJSON* response_data);
|
||||
static cJSON* parse_authorization_header(void);
|
||||
@@ -269,9 +272,13 @@ static int process_admin_event(cJSON* event) {
|
||||
return -1;
|
||||
}
|
||||
content_to_parse = decrypted_content;
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Decrypted content: %s", decrypted_content);
|
||||
} else {
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Using plaintext content (starts with '['): %s", encrypted_content);
|
||||
}
|
||||
|
||||
// Parse command array (either decrypted or plaintext)
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Parsing command array from: %s", content_to_parse);
|
||||
cJSON* command_array = cJSON_Parse(content_to_parse);
|
||||
if (!command_array || !cJSON_IsArray(command_array)) {
|
||||
printf("Status: 400 Bad Request\r\n");
|
||||
@@ -300,19 +307,30 @@ static int process_admin_event(cJSON* event) {
|
||||
// Handle command
|
||||
int result = -1;
|
||||
if (strcmp(cmd, "config_query") == 0) {
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Handling config_query command");
|
||||
result = handle_config_query_command(response_data);
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: config_query result: %d", result);
|
||||
} else if (strcmp(cmd, "query_view") == 0) {
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Handling query_view command");
|
||||
result = handle_query_view_command(command_array, response_data);
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: query_view result: %d", result);
|
||||
} else {
|
||||
app_log(LOG_WARN, "ADMIN_EVENT: Unknown command: %s", cmd);
|
||||
cJSON_AddStringToObject(response_data, "status", "error");
|
||||
cJSON_AddStringToObject(response_data, "error", "Unknown command");
|
||||
result = -1;
|
||||
}
|
||||
|
||||
cJSON_Delete(command_array);
|
||||
|
||||
if (result == 0) {
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Sending Kind 23459 response");
|
||||
// Send Kind 23459 response
|
||||
send_admin_response_event(admin_pubkey, request_id, response_data);
|
||||
return 0;
|
||||
int send_result = send_admin_response_event(admin_pubkey, request_id, response_data);
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Response sent with result: %d", send_result);
|
||||
return send_result;
|
||||
} else {
|
||||
app_log(LOG_ERROR, "ADMIN_EVENT: Command processing failed");
|
||||
cJSON_Delete(response_data);
|
||||
printf("Status: 500 Internal Server Error\r\n");
|
||||
printf("Content-Type: application/json\r\n\r\n");
|
||||
@@ -415,6 +433,125 @@ static int handle_config_query_command(cJSON* response_data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query_view command - returns data from a specified database view
|
||||
* Command format: ["query_view", "view_name"]
|
||||
*/
|
||||
static int handle_query_view_command(cJSON* command_array, cJSON* response_data) {
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: handle_query_view_command called");
|
||||
|
||||
// Get view name from command array
|
||||
cJSON* view_name_obj = cJSON_GetArrayItem(command_array, 1);
|
||||
if (!view_name_obj || !cJSON_IsString(view_name_obj)) {
|
||||
app_log(LOG_ERROR, "ADMIN_EVENT: View name missing or not a string");
|
||||
cJSON_AddStringToObject(response_data, "status", "error");
|
||||
cJSON_AddStringToObject(response_data, "error", "View name required");
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* view_name = cJSON_GetStringValue(view_name_obj);
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Querying view: %s", view_name);
|
||||
|
||||
// Validate view name (whitelist approach for security)
|
||||
const char* allowed_views[] = {
|
||||
"blob_overview",
|
||||
"blob_type_distribution",
|
||||
"blob_time_stats",
|
||||
"top_uploaders",
|
||||
NULL
|
||||
};
|
||||
|
||||
int view_allowed = 0;
|
||||
for (int i = 0; allowed_views[i] != NULL; i++) {
|
||||
if (strcmp(view_name, allowed_views[i]) == 0) {
|
||||
view_allowed = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!view_allowed) {
|
||||
cJSON_AddStringToObject(response_data, "status", "error");
|
||||
cJSON_AddStringToObject(response_data, "error", "Invalid view name");
|
||||
app_log(LOG_WARN, "ADMIN_EVENT: Attempted to query invalid view: %s", view_name);
|
||||
return -1;
|
||||
}
|
||||
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: View '%s' is allowed, opening database: %s", view_name, g_db_path);
|
||||
|
||||
// Open database
|
||||
sqlite3* db;
|
||||
int rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
app_log(LOG_ERROR, "ADMIN_EVENT: Failed to open database: %s (error: %s)", g_db_path, sqlite3_errmsg(db));
|
||||
cJSON_AddStringToObject(response_data, "status", "error");
|
||||
cJSON_AddStringToObject(response_data, "error", "Database error");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Build SQL query
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "SELECT * FROM %s", view_name);
|
||||
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Executing SQL: %s", sql);
|
||||
|
||||
sqlite3_stmt* stmt;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
app_log(LOG_ERROR, "ADMIN_EVENT: Failed to prepare query: %s (error: %s)", sql, sqlite3_errmsg(db));
|
||||
sqlite3_close(db);
|
||||
cJSON_AddStringToObject(response_data, "status", "error");
|
||||
cJSON_AddStringToObject(response_data, "error", "Failed to prepare query");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Get column count and names
|
||||
int col_count = sqlite3_column_count(stmt);
|
||||
|
||||
// Create results array
|
||||
cJSON* results = cJSON_CreateArray();
|
||||
|
||||
// Fetch all rows
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
cJSON* row = cJSON_CreateObject();
|
||||
|
||||
for (int i = 0; i < col_count; i++) {
|
||||
const char* col_name = sqlite3_column_name(stmt, i);
|
||||
int col_type = sqlite3_column_type(stmt, i);
|
||||
|
||||
switch (col_type) {
|
||||
case SQLITE_INTEGER:
|
||||
cJSON_AddNumberToObject(row, col_name, (double)sqlite3_column_int64(stmt, i));
|
||||
break;
|
||||
case SQLITE_FLOAT:
|
||||
cJSON_AddNumberToObject(row, col_name, sqlite3_column_double(stmt, i));
|
||||
break;
|
||||
case SQLITE_TEXT:
|
||||
cJSON_AddStringToObject(row, col_name, (const char*)sqlite3_column_text(stmt, i));
|
||||
break;
|
||||
case SQLITE_NULL:
|
||||
cJSON_AddNullToObject(row, col_name);
|
||||
break;
|
||||
default:
|
||||
// For BLOB or unknown types, skip
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(results, row);
|
||||
}
|
||||
|
||||
sqlite3_finalize(stmt);
|
||||
sqlite3_close(db);
|
||||
|
||||
// Build response
|
||||
cJSON_AddStringToObject(response_data, "status", "success");
|
||||
cJSON_AddStringToObject(response_data, "view_name", view_name);
|
||||
cJSON_AddItemToObject(response_data, "data", results);
|
||||
|
||||
app_log(LOG_DEBUG, "ADMIN_EVENT: Query view '%s' returned %d rows", view_name, cJSON_GetArraySize(results));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Kind 23459 admin response event
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@
|
||||
// Version information (auto-updated by build system)
|
||||
#define VERSION_MAJOR 0
|
||||
#define VERSION_MINOR 1
|
||||
#define VERSION_PATCH 15
|
||||
#define VERSION "v0.1.15"
|
||||
#define VERSION_PATCH 16
|
||||
#define VERSION "v0.1.16"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
81
src/main.c
81
src/main.c
@@ -248,7 +248,7 @@ int initialize_database(const char *db_path) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create storage_stats view
|
||||
// Create storage_stats view (legacy - kept for backward compatibility)
|
||||
const char *create_view =
|
||||
"CREATE VIEW IF NOT EXISTS storage_stats AS "
|
||||
"SELECT "
|
||||
@@ -268,6 +268,85 @@ int initialize_database(const char *db_path) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create blob_overview view for admin dashboard
|
||||
const char *create_overview_view =
|
||||
"CREATE VIEW IF NOT EXISTS blob_overview AS "
|
||||
"SELECT "
|
||||
" COUNT(*) as total_blobs, "
|
||||
" COALESCE(SUM(size), 0) as total_bytes, "
|
||||
" MIN(uploaded_at) as first_upload, "
|
||||
" MAX(uploaded_at) as last_upload "
|
||||
"FROM blobs;";
|
||||
|
||||
rc = sqlite3_exec(db, create_overview_view, NULL, NULL, &err_msg);
|
||||
if (rc != SQLITE_OK) {
|
||||
fprintf(stderr, "Failed to create blob_overview view: %s\n", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create blob_type_distribution view for MIME type statistics
|
||||
const char *create_type_view =
|
||||
"CREATE VIEW IF NOT EXISTS blob_type_distribution AS "
|
||||
"SELECT "
|
||||
" type as mime_type, "
|
||||
" COUNT(*) as blob_count, "
|
||||
" SUM(size) as total_bytes, "
|
||||
" ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM blobs), 2) as percentage "
|
||||
"FROM blobs "
|
||||
"GROUP BY type "
|
||||
"ORDER BY blob_count DESC;";
|
||||
|
||||
rc = sqlite3_exec(db, create_type_view, NULL, NULL, &err_msg);
|
||||
if (rc != SQLITE_OK) {
|
||||
fprintf(stderr, "Failed to create blob_type_distribution view: %s\n", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create blob_time_stats view for time-based statistics
|
||||
const char *create_time_view =
|
||||
"CREATE VIEW IF NOT EXISTS blob_time_stats AS "
|
||||
"SELECT "
|
||||
" COUNT(CASE WHEN uploaded_at >= strftime('%s', 'now', '-1 day') THEN 1 END) as blobs_24h, "
|
||||
" COUNT(CASE WHEN uploaded_at >= strftime('%s', 'now', '-7 days') THEN 1 END) as blobs_7d, "
|
||||
" COUNT(CASE WHEN uploaded_at >= strftime('%s', 'now', '-30 days') THEN 1 END) as blobs_30d "
|
||||
"FROM blobs;";
|
||||
|
||||
rc = sqlite3_exec(db, create_time_view, NULL, NULL, &err_msg);
|
||||
if (rc != SQLITE_OK) {
|
||||
fprintf(stderr, "Failed to create blob_time_stats view: %s\n", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Create top_uploaders view for pubkey statistics
|
||||
const char *create_uploaders_view =
|
||||
"CREATE VIEW IF NOT EXISTS top_uploaders AS "
|
||||
"SELECT "
|
||||
" uploader_pubkey, "
|
||||
" COUNT(*) as blob_count, "
|
||||
" SUM(size) as total_bytes, "
|
||||
" ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM blobs), 2) as percentage, "
|
||||
" MIN(uploaded_at) as first_upload, "
|
||||
" MAX(uploaded_at) as last_upload "
|
||||
"FROM blobs "
|
||||
"WHERE uploader_pubkey IS NOT NULL "
|
||||
"GROUP BY uploader_pubkey "
|
||||
"ORDER BY blob_count DESC "
|
||||
"LIMIT 20;";
|
||||
|
||||
rc = sqlite3_exec(db, create_uploaders_view, NULL, NULL, &err_msg);
|
||||
if (rc != SQLITE_OK) {
|
||||
fprintf(stderr, "Failed to create top_uploaders view: %s\n", err_msg);
|
||||
sqlite3_free(err_msg);
|
||||
sqlite3_close(db);
|
||||
return -1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "Database schema initialized successfully\n");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user