Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ca459593c | ||
|
|
ee4208cc19 | ||
|
|
f6330e4bb8 | ||
|
|
4f3cf10a5c | ||
|
|
aa1954e81e | ||
|
|
482597bd0e | ||
|
|
d4b90e681c | ||
|
|
fcf9e43c4c | ||
|
|
b8d8cd19d3 | ||
|
|
536c2d966c | ||
|
|
f49cb0a5ac | ||
|
|
cef6bb2340 | ||
|
|
4c03253b30 | ||
|
|
ed09bb7370 | ||
|
|
5c46a25173 |
@@ -1261,3 +1261,50 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
|
|||||||
.header-title.clickable:hover {
|
.header-title.clickable:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
SUBSCRIPTION TABLE COLLAPSIBLE GROUPS
|
||||||
|
================================ */
|
||||||
|
|
||||||
|
/* Subscription group header styles */
|
||||||
|
.subscription-group-header {
|
||||||
|
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-group-header:hover {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail row styles */
|
||||||
|
.subscription-detail-row {
|
||||||
|
/* background-color: var(--secondary-color); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-detail-row:hover {
|
||||||
|
background-color: var(--muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail row cell styles */
|
||||||
|
.subscription-detail-prefix {
|
||||||
|
padding-left: 30px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-detail-id {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -212,18 +212,9 @@
|
|||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="config-table-container">
|
<div class="config-table-container">
|
||||||
<table class="config-table" id="subscription-details-table">
|
<table class="config-table" id="subscription-details-table">
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Subscription ID</th>
|
|
||||||
<th>Client IP</th>
|
|
||||||
<th>WSI Pointer</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>Filters</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="subscription-details-table-body">
|
<tbody id="subscription-details-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" style="text-align: center; font-style: italic;">No subscriptions active</td>
|
<td colspan="4" style="text-align: center; font-style: italic;">No subscriptions active</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
256
api/index.js
256
api/index.js
@@ -4279,101 +4279,185 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
|
|||||||
const tableBody = document.getElementById('subscription-details-table-body');
|
const tableBody = document.getElementById('subscription-details-table-body');
|
||||||
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
|
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
|
||||||
|
|
||||||
|
// Store current expand/collapse state before rebuilding
|
||||||
|
const expandedGroups = new Set();
|
||||||
|
const headerRows = tableBody.querySelectorAll('.subscription-group-header');
|
||||||
|
headerRows.forEach(header => {
|
||||||
|
const wsiPointer = header.getAttribute('data-wsi-pointer');
|
||||||
|
const isExpanded = header.getAttribute('data-expanded') === 'true';
|
||||||
|
if (isExpanded) {
|
||||||
|
expandedGroups.add(wsiPointer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
tableBody.innerHTML = '';
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
if (subscriptionsData.length === 0) {
|
if (subscriptionsData.length === 0) {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No active subscriptions</td>';
|
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
|
||||||
tableBody.appendChild(row);
|
tableBody.appendChild(row);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
subscriptionsData.forEach((subscription, index) => {
|
// Sort subscriptions by wsi_pointer to group them together
|
||||||
const row = document.createElement('tr');
|
subscriptionsData.sort((a, b) => {
|
||||||
|
const wsiA = a.wsi_pointer || '';
|
||||||
// Calculate duration
|
const wsiB = b.wsi_pointer || '';
|
||||||
const now = Math.floor(Date.now() / 1000);
|
return wsiA.localeCompare(wsiB);
|
||||||
const duration = now - subscription.created_at;
|
|
||||||
const durationStr = formatDuration(duration);
|
|
||||||
|
|
||||||
// Format client IP (show full IP for admin view)
|
|
||||||
const clientIP = subscription.client_ip || 'unknown';
|
|
||||||
|
|
||||||
// Format wsi_pointer (show full pointer)
|
|
||||||
const wsiPointer = subscription.wsi_pointer || 'N/A';
|
|
||||||
|
|
||||||
// Format filters (show actual filter details)
|
|
||||||
let filtersDisplay = 'None';
|
|
||||||
if (subscription.filters && subscription.filters.length > 0) {
|
|
||||||
const filterDetails = [];
|
|
||||||
subscription.filters.forEach((filter, index) => {
|
|
||||||
const parts = [];
|
|
||||||
|
|
||||||
// Add kinds if present
|
|
||||||
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
|
|
||||||
parts.push(`kinds:[${filter.kinds.join(',')}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add authors if present (truncate for display)
|
|
||||||
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
|
|
||||||
const authorCount = filter.authors.length;
|
|
||||||
if (authorCount === 1) {
|
|
||||||
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
|
|
||||||
parts.push(`authors:[${shortPubkey}]`);
|
|
||||||
} else {
|
|
||||||
parts.push(`authors:[${authorCount} pubkeys]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add ids if present
|
|
||||||
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
|
|
||||||
const idCount = filter.ids.length;
|
|
||||||
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add time range if present
|
|
||||||
const timeParts = [];
|
|
||||||
if (filter.since && filter.since > 0) {
|
|
||||||
const sinceDate = new Date(filter.since * 1000).toLocaleString();
|
|
||||||
timeParts.push(`since:${sinceDate}`);
|
|
||||||
}
|
|
||||||
if (filter.until && filter.until > 0) {
|
|
||||||
const untilDate = new Date(filter.until * 1000).toLocaleString();
|
|
||||||
timeParts.push(`until:${untilDate}`);
|
|
||||||
}
|
|
||||||
if (timeParts.length > 0) {
|
|
||||||
parts.push(timeParts.join(', '));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add limit if present
|
|
||||||
if (filter.limit && filter.limit > 0) {
|
|
||||||
parts.push(`limit:${filter.limit}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tag filters if present
|
|
||||||
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
|
|
||||||
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length > 0) {
|
|
||||||
filterDetails.push(parts.join(', '));
|
|
||||||
} else {
|
|
||||||
filterDetails.push('empty filter');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
filtersDisplay = filterDetails.join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
|
|
||||||
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td>
|
|
||||||
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointer}</td>
|
|
||||||
<td>${durationStr}</td>
|
|
||||||
<td>${filtersDisplay}</td>
|
|
||||||
`;
|
|
||||||
tableBody.appendChild(row);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Group subscriptions by wsi_pointer
|
||||||
|
const groupedSubscriptions = {};
|
||||||
|
subscriptionsData.forEach(sub => {
|
||||||
|
const wsiKey = sub.wsi_pointer || 'N/A';
|
||||||
|
if (!groupedSubscriptions[wsiKey]) {
|
||||||
|
groupedSubscriptions[wsiKey] = [];
|
||||||
|
}
|
||||||
|
groupedSubscriptions[wsiKey].push(sub);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create rows for each group
|
||||||
|
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
|
||||||
|
// Calculate group summary
|
||||||
|
const subCount = subscriptions.length;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
|
||||||
|
const oldestDurationStr = formatDuration(oldestDuration);
|
||||||
|
|
||||||
|
// Create header row (summary)
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
headerRow.className = 'subscription-group-header';
|
||||||
|
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
|
||||||
|
const wasExpanded = expandedGroups.has(wsiPointer);
|
||||||
|
headerRow.setAttribute('data-expanded', wasExpanded ? 'true' : 'false');
|
||||||
|
|
||||||
|
headerRow.innerHTML = `
|
||||||
|
<td colspan="4" style="padding: 8px;">
|
||||||
|
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
|
||||||
|
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">Websocket: ${wsiPointer}</strong>
|
||||||
|
<span style="color: #666; margin-left: 15px;">
|
||||||
|
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add click handler to toggle expansion
|
||||||
|
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
|
||||||
|
|
||||||
|
tableBody.appendChild(headerRow);
|
||||||
|
|
||||||
|
// Create detail rows (initially hidden)
|
||||||
|
subscriptions.forEach((subscription, index) => {
|
||||||
|
const detailRow = document.createElement('tr');
|
||||||
|
detailRow.className = 'subscription-detail-row';
|
||||||
|
detailRow.setAttribute('data-wsi-group', wsiPointer);
|
||||||
|
detailRow.style.display = 'none';
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
const duration = now - subscription.created_at;
|
||||||
|
const durationStr = formatDuration(duration);
|
||||||
|
|
||||||
|
// Format filters
|
||||||
|
let filtersDisplay = 'None';
|
||||||
|
if (subscription.filters && subscription.filters.length > 0) {
|
||||||
|
const filterDetails = [];
|
||||||
|
subscription.filters.forEach((filter) => {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
|
||||||
|
parts.push(`kinds:[${filter.kinds.join(',')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
|
||||||
|
const authorCount = filter.authors.length;
|
||||||
|
if (authorCount === 1) {
|
||||||
|
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
|
||||||
|
parts.push(`authors:[${shortPubkey}]`);
|
||||||
|
} else {
|
||||||
|
parts.push(`authors:[${authorCount} pubkeys]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
|
||||||
|
const idCount = filter.ids.length;
|
||||||
|
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeParts = [];
|
||||||
|
if (filter.since && filter.since > 0) {
|
||||||
|
const sinceDate = new Date(filter.since * 1000).toLocaleString();
|
||||||
|
timeParts.push(`since:${sinceDate}`);
|
||||||
|
}
|
||||||
|
if (filter.until && filter.until > 0) {
|
||||||
|
const untilDate = new Date(filter.until * 1000).toLocaleString();
|
||||||
|
timeParts.push(`until:${untilDate}`);
|
||||||
|
}
|
||||||
|
if (timeParts.length > 0) {
|
||||||
|
parts.push(timeParts.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.limit && filter.limit > 0) {
|
||||||
|
parts.push(`limit:${filter.limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
|
||||||
|
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
filterDetails.push(parts.join(', '));
|
||||||
|
} else {
|
||||||
|
filterDetails.push('empty filter');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
filtersDisplay = filterDetails.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRow.innerHTML = `
|
||||||
|
<td class="subscription-detail-prefix">└─</td>
|
||||||
|
<td class="subscription-detail-id">${subscription.id || 'N/A'}</td>
|
||||||
|
<td class="subscription-detail-duration">${durationStr}</td>
|
||||||
|
<td class="subscription-detail-filters">${filtersDisplay}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tableBody.appendChild(detailRow);
|
||||||
|
|
||||||
|
// Restore expand/collapse state after adding all rows
|
||||||
|
if (wasExpanded) {
|
||||||
|
const detailRows = tableBody.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
|
||||||
|
detailRows.forEach(row => row.style.display = 'table-row');
|
||||||
|
const expandIcon = headerRow.querySelector('.expand-icon');
|
||||||
|
if (expandIcon) {
|
||||||
|
expandIcon.textContent = '▼';
|
||||||
|
expandIcon.style.transform = 'rotate(90deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle function for expanding/collapsing groups
|
||||||
|
function toggleSubscriptionGroup(wsiPointer) {
|
||||||
|
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
|
||||||
|
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
|
||||||
|
const expandIcon = headerRow.querySelector('.expand-icon');
|
||||||
|
|
||||||
|
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse
|
||||||
|
detailRows.forEach(row => row.style.display = 'none');
|
||||||
|
expandIcon.textContent = '▶';
|
||||||
|
expandIcon.style.transform = 'rotate(0deg)';
|
||||||
|
headerRow.setAttribute('data-expanded', 'false');
|
||||||
|
} else {
|
||||||
|
// Expand
|
||||||
|
detailRows.forEach(row => row.style.display = 'table-row');
|
||||||
|
expandIcon.textContent = '▼';
|
||||||
|
expandIcon.style.transform = 'rotate(90deg)';
|
||||||
|
headerRow.setAttribute('data-expanded', 'true');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to format duration in human-readable format
|
// Helper function to format duration in human-readable format
|
||||||
|
|||||||
BIN
c-relay-1.0.0.tar.gz
Normal file
BIN
c-relay-1.0.0.tar.gz
Normal file
Binary file not shown.
BIN
c-relay-2.0.0.tar.gz
Normal file
BIN
c-relay-2.0.0.tar.gz
Normal file
Binary file not shown.
BIN
c-relay-3.0.0.tar.gz
Normal file
BIN
c-relay-3.0.0.tar.gz
Normal file
Binary file not shown.
BIN
c-relay-4.0.0.tar.gz
Normal file
BIN
c-relay-4.0.0.tar.gz
Normal file
Binary file not shown.
BIN
c-relay-5.0.0.tar.gz
Normal file
BIN
c-relay-5.0.0.tar.gz
Normal file
Binary file not shown.
BIN
c-relay-6.0.0.tar.gz
Normal file
BIN
c-relay-6.0.0.tar.gz
Normal file
Binary file not shown.
BIN
c-relay-7.0.0.tar.gz
Normal file
BIN
c-relay-7.0.0.tar.gz
Normal file
Binary file not shown.
358
docs/subscription_table_collapse_plan.md
Normal file
358
docs/subscription_table_collapse_plan.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# Subscription Table Collapsible Groups Implementation Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add collapsible/expandable functionality to subscription groups, where:
|
||||||
|
1. Each WSI Pointer group starts collapsed, showing only a summary row
|
||||||
|
2. Clicking the summary row expands to show all subscription details for that WSI Pointer
|
||||||
|
3. Clicking again collapses the group back to the summary row
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
- All subscriptions are displayed in a flat list
|
||||||
|
- WSI Pointer value shown only on first row of each group
|
||||||
|
- No interaction or collapse functionality
|
||||||
|
|
||||||
|
## Desired Behavior
|
||||||
|
- **Collapsed state**: One row per WSI Pointer showing summary information
|
||||||
|
- **Expanded state**: Header row + detail rows for each subscription in that group
|
||||||
|
- **Toggle**: Click on header row to expand/collapse
|
||||||
|
- **Visual indicator**: Arrow or icon showing expand/collapse state
|
||||||
|
|
||||||
|
## Design Approach
|
||||||
|
|
||||||
|
### Option 1: Summary Row + Detail Rows (Recommended)
|
||||||
|
```
|
||||||
|
[▶] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
|
||||||
|
(detail rows hidden)
|
||||||
|
|
||||||
|
When clicked:
|
||||||
|
[▼] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
|
||||||
|
| sub-001 | 5m 30s | kinds:[1]
|
||||||
|
| sub-002 | 3m 15s | kinds:[3]
|
||||||
|
| sub-003 | 6m 15s | kinds:[1,3]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: First Row as Header (Alternative)
|
||||||
|
```
|
||||||
|
[▶] 0x12345678 | sub-001 | 5m 30s | kinds:[1] (+ 2 more)
|
||||||
|
|
||||||
|
When clicked:
|
||||||
|
[▼] 0x12345678 | sub-001 | 5m 30s | kinds:[1]
|
||||||
|
| sub-002 | 3m 15s | kinds:[3]
|
||||||
|
| sub-003 | 6m 15s | kinds:[1,3]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation**: Option 1 provides clearer UX and better summary information.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
1. `api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384)
|
||||||
|
2. `api/index.css` - Add styles for collapsible rows
|
||||||
|
|
||||||
|
### Data Structure Changes
|
||||||
|
|
||||||
|
Need to group subscriptions by WSI Pointer first:
|
||||||
|
```javascript
|
||||||
|
// Group subscriptions by wsi_pointer
|
||||||
|
const groupedSubscriptions = {};
|
||||||
|
subscriptionsData.forEach(sub => {
|
||||||
|
const wsiKey = sub.wsi_pointer || 'N/A';
|
||||||
|
if (!groupedSubscriptions[wsiKey]) {
|
||||||
|
groupedSubscriptions[wsiKey] = [];
|
||||||
|
}
|
||||||
|
groupedSubscriptions[wsiKey].push(sub);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTML Structure
|
||||||
|
|
||||||
|
#### Summary Row (Header)
|
||||||
|
```html
|
||||||
|
<tr class="subscription-group-header" data-wsi-pointer="0x12345678" data-expanded="false">
|
||||||
|
<td colspan="4" style="cursor: pointer; user-select: none;">
|
||||||
|
<span class="expand-icon">▶</span>
|
||||||
|
<strong>WSI Pointer:</strong> 0x12345678
|
||||||
|
<span class="group-summary">
|
||||||
|
| Subscriptions: 3 | Oldest: 15m 30s
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Detail Rows (Initially Hidden)
|
||||||
|
```html
|
||||||
|
<tr class="subscription-detail-row" data-wsi-group="0x12345678" style="display: none;">
|
||||||
|
<td style="padding-left: 30px;"><!-- Empty for WSI Pointer --></td>
|
||||||
|
<td>sub-001</td>
|
||||||
|
<td>5m 30s</td>
|
||||||
|
<td>kinds:[1]</td>
|
||||||
|
</tr>
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Implementation
|
||||||
|
|
||||||
|
#### Modified populateSubscriptionDetailsTable Function
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function populateSubscriptionDetailsTable(subscriptionsData) {
|
||||||
|
const tableBody = document.getElementById('subscription-details-table-body');
|
||||||
|
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
|
||||||
|
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
if (subscriptionsData.length === 0) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort subscriptions by wsi_pointer to group them together
|
||||||
|
subscriptionsData.sort((a, b) => {
|
||||||
|
const wsiA = a.wsi_pointer || '';
|
||||||
|
const wsiB = b.wsi_pointer || '';
|
||||||
|
return wsiA.localeCompare(wsiB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group subscriptions by wsi_pointer
|
||||||
|
const groupedSubscriptions = {};
|
||||||
|
subscriptionsData.forEach(sub => {
|
||||||
|
const wsiKey = sub.wsi_pointer || 'N/A';
|
||||||
|
if (!groupedSubscriptions[wsiKey]) {
|
||||||
|
groupedSubscriptions[wsiKey] = [];
|
||||||
|
}
|
||||||
|
groupedSubscriptions[wsiKey].push(sub);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create rows for each group
|
||||||
|
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
|
||||||
|
// Calculate group summary
|
||||||
|
const subCount = subscriptions.length;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
|
||||||
|
const oldestDurationStr = formatDuration(oldestDuration);
|
||||||
|
|
||||||
|
// Create header row (summary)
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
headerRow.className = 'subscription-group-header';
|
||||||
|
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
|
||||||
|
headerRow.setAttribute('data-expanded', 'false');
|
||||||
|
headerRow.style.cursor = 'pointer';
|
||||||
|
headerRow.style.userSelect = 'none';
|
||||||
|
headerRow.style.backgroundColor = 'var(--hover-color, #f5f5f5)';
|
||||||
|
|
||||||
|
headerRow.innerHTML = `
|
||||||
|
<td colspan="4" style="padding: 8px;">
|
||||||
|
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
|
||||||
|
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">WSI: ${wsiPointer}</strong>
|
||||||
|
<span style="color: #666; margin-left: 15px;">
|
||||||
|
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add click handler to toggle expansion
|
||||||
|
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
|
||||||
|
|
||||||
|
tableBody.appendChild(headerRow);
|
||||||
|
|
||||||
|
// Create detail rows (initially hidden)
|
||||||
|
subscriptions.forEach((subscription, index) => {
|
||||||
|
const detailRow = document.createElement('tr');
|
||||||
|
detailRow.className = 'subscription-detail-row';
|
||||||
|
detailRow.setAttribute('data-wsi-group', wsiPointer);
|
||||||
|
detailRow.style.display = 'none';
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
const duration = now - subscription.created_at;
|
||||||
|
const durationStr = formatDuration(duration);
|
||||||
|
|
||||||
|
// Format filters
|
||||||
|
let filtersDisplay = 'None';
|
||||||
|
if (subscription.filters && subscription.filters.length > 0) {
|
||||||
|
const filterDetails = [];
|
||||||
|
subscription.filters.forEach((filter) => {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
|
||||||
|
parts.push(`kinds:[${filter.kinds.join(',')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
|
||||||
|
const authorCount = filter.authors.length;
|
||||||
|
if (authorCount === 1) {
|
||||||
|
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
|
||||||
|
parts.push(`authors:[${shortPubkey}]`);
|
||||||
|
} else {
|
||||||
|
parts.push(`authors:[${authorCount} pubkeys]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
|
||||||
|
const idCount = filter.ids.length;
|
||||||
|
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeParts = [];
|
||||||
|
if (filter.since && filter.since > 0) {
|
||||||
|
const sinceDate = new Date(filter.since * 1000).toLocaleString();
|
||||||
|
timeParts.push(`since:${sinceDate}`);
|
||||||
|
}
|
||||||
|
if (filter.until && filter.until > 0) {
|
||||||
|
const untilDate = new Date(filter.until * 1000).toLocaleString();
|
||||||
|
timeParts.push(`until:${untilDate}`);
|
||||||
|
}
|
||||||
|
if (timeParts.length > 0) {
|
||||||
|
parts.push(timeParts.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.limit && filter.limit > 0) {
|
||||||
|
parts.push(`limit:${filter.limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
|
||||||
|
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
filterDetails.push(parts.join(', '));
|
||||||
|
} else {
|
||||||
|
filterDetails.push('empty filter');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
filtersDisplay = filterDetails.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
detailRow.innerHTML = `
|
||||||
|
<td style="padding-left: 30px; font-family: 'Courier New', monospace; font-size: 11px; color: #999;">└─</td>
|
||||||
|
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
|
||||||
|
<td>${durationStr}</td>
|
||||||
|
<td>${filtersDisplay}</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tableBody.appendChild(detailRow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle function for expanding/collapsing groups
|
||||||
|
function toggleSubscriptionGroup(wsiPointer) {
|
||||||
|
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
|
||||||
|
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
|
||||||
|
const expandIcon = headerRow.querySelector('.expand-icon');
|
||||||
|
|
||||||
|
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
// Collapse
|
||||||
|
detailRows.forEach(row => row.style.display = 'none');
|
||||||
|
expandIcon.textContent = '▶';
|
||||||
|
expandIcon.style.transform = 'rotate(0deg)';
|
||||||
|
headerRow.setAttribute('data-expanded', 'false');
|
||||||
|
} else {
|
||||||
|
// Expand
|
||||||
|
detailRows.forEach(row => row.style.display = 'table-row');
|
||||||
|
expandIcon.textContent = '▼';
|
||||||
|
expandIcon.style.transform = 'rotate(90deg)';
|
||||||
|
headerRow.setAttribute('data-expanded', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Additions (api/index.css)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Subscription group header styles */
|
||||||
|
.subscription-group-header {
|
||||||
|
background-color: var(--hover-color, #f5f5f5);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-group-header:hover {
|
||||||
|
background-color: var(--primary-color-light, #e8e8e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail row styles */
|
||||||
|
.subscription-detail-row {
|
||||||
|
background-color: var(--background-color, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-detail-row:hover {
|
||||||
|
background-color: var(--hover-color-light, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
.dark-mode .subscription-group-header {
|
||||||
|
background-color: var(--hover-color-dark, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .subscription-group-header:hover {
|
||||||
|
background-color: var(--primary-color-dark, #333333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .subscription-detail-row {
|
||||||
|
background-color: var(--background-color-dark, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode .subscription-detail-row:hover {
|
||||||
|
background-color: var(--hover-color-dark, #252525);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features Included
|
||||||
|
|
||||||
|
1. ✅ **Collapsible groups**: Each WSI Pointer group can be collapsed/expanded
|
||||||
|
2. ✅ **Visual indicator**: Arrow icon (▶/▼) shows current state
|
||||||
|
3. ✅ **Summary information**: Shows subscription count and oldest duration
|
||||||
|
4. ✅ **Smooth transitions**: Icon rotation animation
|
||||||
|
5. ✅ **Hover effects**: Visual feedback on header rows
|
||||||
|
6. ✅ **Tree structure**: Detail rows indented with └─ character
|
||||||
|
7. ✅ **Dark mode support**: Proper styling for both themes
|
||||||
|
8. ✅ **Keyboard accessible**: Can be enhanced with keyboard navigation
|
||||||
|
|
||||||
|
## User Experience Flow
|
||||||
|
|
||||||
|
1. **Initial load**: All groups collapsed, showing summary rows only
|
||||||
|
2. **Click header**: Group expands, showing all subscription details
|
||||||
|
3. **Click again**: Group collapses back to summary
|
||||||
|
4. **Multiple groups**: Each group can be independently expanded/collapsed
|
||||||
|
5. **Visual feedback**: Hover effects and smooth animations
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
1. ✅ Verify groups start collapsed by default
|
||||||
|
2. ✅ Verify clicking header expands group
|
||||||
|
3. ✅ Verify clicking again collapses group
|
||||||
|
4. ✅ Verify multiple groups can be expanded simultaneously
|
||||||
|
5. ✅ Verify summary information is accurate
|
||||||
|
6. ✅ Verify detail rows display correctly when expanded
|
||||||
|
7. ✅ Verify styling works in both light and dark modes
|
||||||
|
8. ✅ Verify no console errors
|
||||||
|
9. ✅ Verify performance with many subscriptions
|
||||||
|
|
||||||
|
## Optional Enhancements
|
||||||
|
|
||||||
|
1. **Expand/Collapse All**: Add buttons to expand or collapse all groups at once
|
||||||
|
2. **Remember State**: Store expansion state in localStorage
|
||||||
|
3. **Keyboard Navigation**: Add keyboard shortcuts (Space/Enter to toggle)
|
||||||
|
4. **Animation**: Add slide-down/up animation for detail rows
|
||||||
|
5. **Search/Filter**: Add ability to search within subscriptions
|
||||||
|
6. **Export**: Add ability to export subscription data
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review this plan
|
||||||
|
2. Switch to Code mode
|
||||||
|
3. Implement the changes in `api/index.js`
|
||||||
|
4. Add CSS styles in `api/index.css`
|
||||||
|
5. Test the collapsible functionality
|
||||||
|
6. Verify all edge cases work correctly
|
||||||
155
docs/subscription_table_grouping_plan.md
Normal file
155
docs/subscription_table_grouping_plan.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Subscription Table WSI Pointer Grouping Implementation Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Modify the subscription details table to show the WSI Pointer value only once per group - on the first row of each WSI Pointer group, leaving it blank for subsequent rows with the same WSI Pointer.
|
||||||
|
|
||||||
|
## Current Behavior
|
||||||
|
- All rows show the WSI Pointer value
|
||||||
|
- Rows are sorted by WSI Pointer (grouping is working)
|
||||||
|
- Visual grouping is not clear
|
||||||
|
|
||||||
|
## Desired Behavior
|
||||||
|
- First row of each WSI Pointer group shows the full WSI Pointer value
|
||||||
|
- Subsequent rows in the same group have an empty cell for WSI Pointer
|
||||||
|
- This creates a clear visual grouping effect
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File to Modify
|
||||||
|
`api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384)
|
||||||
|
|
||||||
|
### Code Changes Required
|
||||||
|
|
||||||
|
#### Current Code (lines 4291-4383):
|
||||||
|
```javascript
|
||||||
|
// Sort subscriptions by wsi_pointer to group them together
|
||||||
|
subscriptionsData.sort((a, b) => {
|
||||||
|
const wsiA = a.wsi_pointer || '';
|
||||||
|
const wsiB = b.wsi_pointer || '';
|
||||||
|
return wsiA.localeCompare(wsiB);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscriptionsData.forEach((subscription, index) => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const duration = now - subscription.created_at;
|
||||||
|
const durationStr = formatDuration(duration);
|
||||||
|
|
||||||
|
// Format client IP (show full IP for admin view)
|
||||||
|
const clientIP = subscription.client_ip || 'unknown';
|
||||||
|
|
||||||
|
// Format wsi_pointer (show full pointer)
|
||||||
|
const wsiPointer = subscription.wsi_pointer || 'N/A';
|
||||||
|
|
||||||
|
// Format filters (show actual filter details)
|
||||||
|
let filtersDisplay = 'None';
|
||||||
|
// ... filter formatting code ...
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointer}</td>
|
||||||
|
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
|
||||||
|
<!-- <td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td> -->
|
||||||
|
<td>${durationStr}</td>
|
||||||
|
<td>${filtersDisplay}</td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Modified Code:
|
||||||
|
```javascript
|
||||||
|
// Sort subscriptions by wsi_pointer to group them together
|
||||||
|
subscriptionsData.sort((a, b) => {
|
||||||
|
const wsiA = a.wsi_pointer || '';
|
||||||
|
const wsiB = b.wsi_pointer || '';
|
||||||
|
return wsiA.localeCompare(wsiB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track previous WSI Pointer to detect group changes
|
||||||
|
let previousWsiPointer = null;
|
||||||
|
|
||||||
|
subscriptionsData.forEach((subscription, index) => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
// Calculate duration
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const duration = now - subscription.created_at;
|
||||||
|
const durationStr = formatDuration(duration);
|
||||||
|
|
||||||
|
// Format client IP (show full IP for admin view)
|
||||||
|
const clientIP = subscription.client_ip || 'unknown';
|
||||||
|
|
||||||
|
// Format wsi_pointer - only show if it's different from previous row
|
||||||
|
const currentWsiPointer = subscription.wsi_pointer || 'N/A';
|
||||||
|
let wsiPointerDisplay = '';
|
||||||
|
|
||||||
|
if (currentWsiPointer !== previousWsiPointer) {
|
||||||
|
// This is the first row of a new group - show the WSI Pointer
|
||||||
|
wsiPointerDisplay = currentWsiPointer;
|
||||||
|
previousWsiPointer = currentWsiPointer;
|
||||||
|
} else {
|
||||||
|
// This is a continuation of the same group - leave blank
|
||||||
|
wsiPointerDisplay = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format filters (show actual filter details)
|
||||||
|
let filtersDisplay = 'None';
|
||||||
|
// ... filter formatting code remains the same ...
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointerDisplay}</td>
|
||||||
|
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
|
||||||
|
<!-- <td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td> -->
|
||||||
|
<td>${durationStr}</td>
|
||||||
|
<td>${filtersDisplay}</td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Changes Explained
|
||||||
|
|
||||||
|
1. **Add tracking variable**: `let previousWsiPointer = null;` before the forEach loop
|
||||||
|
2. **Store current WSI Pointer**: `const currentWsiPointer = subscription.wsi_pointer || 'N/A';`
|
||||||
|
3. **Compare with previous**: Check if `currentWsiPointer !== previousWsiPointer`
|
||||||
|
4. **Conditional display**:
|
||||||
|
- If different: Show the WSI Pointer value and update `previousWsiPointer`
|
||||||
|
- If same: Show empty string (blank cell)
|
||||||
|
5. **Use display variable**: Replace `${wsiPointer}` with `${wsiPointerDisplay}` in the row HTML
|
||||||
|
|
||||||
|
### Visual Result
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```
|
||||||
|
WSI Pointer | Subscription ID | Duration | Filters
|
||||||
|
0x12345678 | sub-001 | 5m 30s | kinds:[1]
|
||||||
|
0x12345678 | sub-002 | 3m 15s | kinds:[3]
|
||||||
|
0x87654321 | sub-003 | 1m 45s | kinds:[1,3]
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```
|
||||||
|
WSI Pointer | Subscription ID | Duration | Filters
|
||||||
|
0x12345678 | sub-001 | 5m 30s | kinds:[1]
|
||||||
|
| sub-002 | 3m 15s | kinds:[3]
|
||||||
|
0x87654321 | sub-003 | 1m 45s | kinds:[1,3]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
1. ✅ Verify first row of each group shows WSI Pointer
|
||||||
|
2. ✅ Verify subsequent rows in same group are blank
|
||||||
|
3. ✅ Verify grouping works with multiple subscriptions per WSI Pointer
|
||||||
|
4. ✅ Verify single subscription per WSI Pointer still shows the value
|
||||||
|
5. ✅ Verify empty/null WSI Pointers are handled correctly
|
||||||
|
6. ✅ Verify table still displays correctly when no subscriptions exist
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review this plan
|
||||||
|
2. Switch to Code mode
|
||||||
|
3. Implement the changes in `api/index.js`
|
||||||
|
4. Test the implementation
|
||||||
|
5. Verify the visual grouping effect
|
||||||
@@ -8,10 +8,10 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
print_status() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
|
||||||
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
|
||||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
|
||||||
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||||
|
|
||||||
# Global variables
|
# Global variables
|
||||||
COMMIT_MESSAGE=""
|
COMMIT_MESSAGE=""
|
||||||
@@ -366,20 +366,38 @@ upload_release_assets() {
|
|||||||
|
|
||||||
local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
|
local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
|
||||||
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/c-relay"
|
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/c-relay"
|
||||||
|
local assets_url="$api_url/releases/$release_id/assets"
|
||||||
|
print_status "Assets URL: $assets_url"
|
||||||
|
|
||||||
# Upload binary
|
# Upload binary
|
||||||
if [[ -f "$binary_path" ]]; then
|
if [[ -f "$binary_path" ]]; then
|
||||||
print_status "Uploading binary: $(basename "$binary_path")"
|
print_status "Uploading binary: $(basename "$binary_path")"
|
||||||
local binary_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
|
|
||||||
-H "Authorization: token $token" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
-F "attachment=@$binary_path;filename=$(basename "$binary_path")")
|
|
||||||
|
|
||||||
if echo "$binary_response" | grep -q '"id"'; then
|
# Retry loop for eventual consistency
|
||||||
print_success "Uploaded binary successfully"
|
local max_attempts=3
|
||||||
else
|
local attempt=1
|
||||||
print_warning "Failed to upload binary: $binary_response"
|
while [[ $attempt -le $max_attempts ]]; do
|
||||||
fi
|
print_status "Upload attempt $attempt/$max_attempts"
|
||||||
|
local binary_response=$(curl -fS -X POST "$assets_url" \
|
||||||
|
-H "Authorization: token $token" \
|
||||||
|
-F "attachment=@$binary_path;filename=$(basename "$binary_path")" \
|
||||||
|
-F "name=$(basename "$binary_path")")
|
||||||
|
|
||||||
|
if echo "$binary_response" | grep -q '"id"'; then
|
||||||
|
print_success "Uploaded binary successfully"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
print_warning "Upload attempt $attempt failed"
|
||||||
|
if [[ $attempt -lt $max_attempts ]]; then
|
||||||
|
print_status "Retrying in 2 seconds..."
|
||||||
|
sleep 2
|
||||||
|
else
|
||||||
|
print_error "Failed to upload binary after $max_attempts attempts"
|
||||||
|
print_error "Response: $binary_response"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
((attempt++))
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload source tarball
|
# Upload source tarball
|
||||||
@@ -387,7 +405,6 @@ upload_release_assets() {
|
|||||||
print_status "Uploading source tarball: $(basename "$tarball_path")"
|
print_status "Uploading source tarball: $(basename "$tarball_path")"
|
||||||
local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
|
local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
|
||||||
-H "Authorization: token $token" \
|
-H "Authorization: token $token" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")")
|
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")")
|
||||||
|
|
||||||
if echo "$tarball_response" | grep -q '"id"'; then
|
if echo "$tarball_response" | grep -q '"id"'; then
|
||||||
@@ -422,18 +439,20 @@ create_gitea_release() {
|
|||||||
if echo "$response" | grep -q '"id"'; then
|
if echo "$response" | grep -q '"id"'; then
|
||||||
print_success "Created release $NEW_VERSION"
|
print_success "Created release $NEW_VERSION"
|
||||||
# Extract release ID for asset uploads
|
# Extract release ID for asset uploads
|
||||||
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | cut -d':' -f2)
|
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||||
return $release_id
|
echo $release_id
|
||||||
elif echo "$response" | grep -q "already exists"; then
|
elif echo "$response" | grep -q "already exists"; then
|
||||||
print_warning "Release $NEW_VERSION already exists"
|
print_warning "Release $NEW_VERSION already exists"
|
||||||
# Try to get existing release ID
|
# Try to get existing release ID
|
||||||
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
|
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
|
||||||
if echo "$check_response" | grep -q '"id"'; then
|
if echo "$check_response" | grep -q '"id"'; then
|
||||||
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | cut -d':' -f2)
|
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||||
print_status "Using existing release ID: $release_id"
|
print_status "Using existing release ID: $release_id"
|
||||||
return $release_id
|
echo $release_id
|
||||||
|
else
|
||||||
|
print_error "Could not find existing release ID"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
return 0
|
|
||||||
else
|
else
|
||||||
print_error "Failed to create release $NEW_VERSION"
|
print_error "Failed to create release $NEW_VERSION"
|
||||||
print_error "Response: $response"
|
print_error "Response: $response"
|
||||||
@@ -443,8 +462,8 @@ create_gitea_release() {
|
|||||||
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
|
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
|
||||||
if echo "$check_response" | grep -q '"id"'; then
|
if echo "$check_response" | grep -q '"id"'; then
|
||||||
print_warning "Release exists but creation response was unexpected"
|
print_warning "Release exists but creation response was unexpected"
|
||||||
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | cut -d':' -f2)
|
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||||
return $release_id
|
echo $release_id
|
||||||
else
|
else
|
||||||
print_error "Release does not exist and creation failed"
|
print_error "Release does not exist and creation failed"
|
||||||
return 1
|
return 1
|
||||||
@@ -465,6 +484,11 @@ main() {
|
|||||||
# Only increment version if explicitly requested (not just because of -r flag)
|
# Only increment version if explicitly requested (not just because of -r flag)
|
||||||
if [[ "$VERSION_INCREMENT_TYPE" != "patch" ]]; then
|
if [[ "$VERSION_INCREMENT_TYPE" != "patch" ]]; then
|
||||||
increment_version "$VERSION_INCREMENT_TYPE"
|
increment_version "$VERSION_INCREMENT_TYPE"
|
||||||
|
else
|
||||||
|
# In release mode without version increment, get current version
|
||||||
|
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "v0.0.0")
|
||||||
|
NEW_VERSION="$LATEST_TAG"
|
||||||
|
export NEW_VERSION
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create new git tag BEFORE compilation so version.h picks it up
|
# Create new git tag BEFORE compilation so version.h picks it up
|
||||||
@@ -484,7 +508,13 @@ main() {
|
|||||||
local binary_path="build/c_relay_static_x86_64"
|
local binary_path="build/c_relay_static_x86_64"
|
||||||
else
|
else
|
||||||
print_warning "Binary build failed, continuing with release creation"
|
print_warning "Binary build failed, continuing with release creation"
|
||||||
binary_path=""
|
# Check if binary exists from previous build
|
||||||
|
if [[ -f "build/c_relay_static_x86_64" ]]; then
|
||||||
|
print_status "Using existing binary from previous build"
|
||||||
|
binary_path="build/c_relay_static_x86_64"
|
||||||
|
else
|
||||||
|
binary_path=""
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create source tarball
|
# Create source tarball
|
||||||
@@ -498,11 +528,17 @@ main() {
|
|||||||
# Create Gitea release
|
# Create Gitea release
|
||||||
local release_id=""
|
local release_id=""
|
||||||
if release_id=$(create_gitea_release); then
|
if release_id=$(create_gitea_release); then
|
||||||
# Upload assets if we have a release ID and assets
|
# Validate release_id is numeric
|
||||||
if [[ -n "$release_id" && (-n "$binary_path" || -n "$tarball_path") ]]; then
|
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||||
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
|
# Upload assets if we have a release ID and assets
|
||||||
|
if [[ -n "$release_id" && (-n "$binary_path" || -n "$tarball_path") ]]; then
|
||||||
|
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
|
||||||
|
fi
|
||||||
|
print_success "Release $NEW_VERSION completed successfully!"
|
||||||
|
else
|
||||||
|
print_error "Invalid release_id: $release_id"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
print_success "Release $NEW_VERSION completed successfully!"
|
|
||||||
else
|
else
|
||||||
print_error "Release creation failed"
|
print_error "Release creation failed"
|
||||||
fi
|
fi
|
||||||
|
|||||||
84
src/api.c
84
src/api.c
@@ -1503,6 +1503,32 @@ char* generate_config_text(void) {
|
|||||||
return config_text;
|
return config_text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to format numbers with commas
|
||||||
|
void format_number_with_commas(long long num, char* buffer, size_t buffer_size) {
|
||||||
|
char temp[32];
|
||||||
|
int len = snprintf(temp, sizeof(temp), "%lld", num);
|
||||||
|
int comma_count = (len - 1) / 3;
|
||||||
|
int result_len = len + comma_count;
|
||||||
|
|
||||||
|
if (result_len >= (int)buffer_size) {
|
||||||
|
buffer[0] = '\0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int j = result_len - 1;
|
||||||
|
int k = len - 1;
|
||||||
|
int comma_pos = 0;
|
||||||
|
|
||||||
|
while (k >= 0) {
|
||||||
|
buffer[j--] = temp[k--];
|
||||||
|
comma_pos++;
|
||||||
|
if (comma_pos % 3 == 0 && k >= 0) {
|
||||||
|
buffer[j--] = ',';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer[result_len] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
// Generate human-readable stats text
|
// Generate human-readable stats text
|
||||||
char* generate_stats_text(void) {
|
char* generate_stats_text(void) {
|
||||||
char* stats_json = generate_stats_json();
|
char* stats_json = generate_stats_json();
|
||||||
@@ -1575,17 +1601,17 @@ char* generate_stats_text(void) {
|
|||||||
// Database Overview section
|
// Database Overview section
|
||||||
offset += snprintf(stats_text + offset, 16384 - offset,
|
offset += snprintf(stats_text + offset, 16384 - offset,
|
||||||
|
|
||||||
"Database Size %.2f MB\n"
|
"Database Size %.2f MB (%lld bytes)\n"
|
||||||
"Total Events %lld\n"
|
"Total Events %lld\n"
|
||||||
"Active Subscriptions %d\n"
|
"Active Subscriptions %d\n"
|
||||||
"Oldest Event %s\n"
|
"Oldest Event %s\n"
|
||||||
"Newest Event %s\n"
|
"Newest Event %s\n"
|
||||||
"\n",
|
"\n",
|
||||||
db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
|
db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
|
||||||
|
|
||||||
// Event Kind Distribution section
|
// Event Kind Distribution section
|
||||||
offset += snprintf(stats_text + offset, 16384 - offset,
|
offset += snprintf(stats_text + offset, 16384 - offset,
|
||||||
"Event Kind Distribution:\n");
|
"Event_Kind_Distribution:\n");
|
||||||
|
|
||||||
if (event_kinds && cJSON_IsArray(event_kinds)) {
|
if (event_kinds && cJSON_IsArray(event_kinds)) {
|
||||||
cJSON* kind_item = NULL;
|
cJSON* kind_item = NULL;
|
||||||
@@ -1595,11 +1621,45 @@ char* generate_stats_text(void) {
|
|||||||
cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage");
|
cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage");
|
||||||
|
|
||||||
if (kind && count && percentage) {
|
if (kind && count && percentage) {
|
||||||
|
// Format event kind (right-justified, minimum 5 chars wide with underscores)
|
||||||
|
char kind_str[16];
|
||||||
|
int kind_val = (int)cJSON_GetNumberValue(kind);
|
||||||
|
char temp_kind[16];
|
||||||
|
snprintf(temp_kind, sizeof(temp_kind), "%d", kind_val);
|
||||||
|
|
||||||
|
// Calculate padding needed (minimum 5 chars, but more if kind is longer)
|
||||||
|
int kind_len = strlen(temp_kind);
|
||||||
|
int min_width = 5;
|
||||||
|
int total_width = (kind_len > min_width) ? kind_len : min_width;
|
||||||
|
int padding = total_width - kind_len;
|
||||||
|
|
||||||
|
// Create padded string with underscores
|
||||||
|
memset(kind_str, '_', padding);
|
||||||
|
strcpy(kind_str + padding, temp_kind);
|
||||||
|
|
||||||
|
// Format count with commas (right-justified, 11 chars wide with underscores)
|
||||||
|
char count_str[16];
|
||||||
|
format_number_with_commas((long long)cJSON_GetNumberValue(count), count_str, sizeof(count_str));
|
||||||
|
char count_formatted[16];
|
||||||
|
snprintf(count_formatted, sizeof(count_formatted), "%11s", count_str);
|
||||||
|
// Replace spaces with underscores for alignment
|
||||||
|
for (int i = 0; count_formatted[i]; i++) {
|
||||||
|
if (count_formatted[i] == ' ') count_formatted[i] = '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format percentage (right-justified, 7 chars wide including %, with underscores)
|
||||||
|
double pct = cJSON_GetNumberValue(percentage);
|
||||||
|
char pct_str[8];
|
||||||
|
snprintf(pct_str, sizeof(pct_str), "%.1f%%", pct);
|
||||||
|
// Pad with underscores to make 7 chars total (right-justified)
|
||||||
|
char pct_formatted[8];
|
||||||
|
int pct_len = strlen(pct_str);
|
||||||
|
int underscores_needed = 7 - pct_len;
|
||||||
|
memset(pct_formatted, '_', underscores_needed);
|
||||||
|
strcpy(pct_formatted + underscores_needed, pct_str);
|
||||||
|
|
||||||
offset += snprintf(stats_text + offset, 16384 - offset,
|
offset += snprintf(stats_text + offset, 16384 - offset,
|
||||||
"%lld\t%lld\t%.1f%%\n",
|
"%s%s_%s\n", kind_str, count_formatted, pct_formatted);
|
||||||
(long long)cJSON_GetNumberValue(kind),
|
|
||||||
(long long)cJSON_GetNumberValue(count),
|
|
||||||
cJSON_GetNumberValue(percentage));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@
|
|||||||
// Version information (auto-updated by build system)
|
// Version information (auto-updated by build system)
|
||||||
#define VERSION_MAJOR 0
|
#define VERSION_MAJOR 0
|
||||||
#define VERSION_MINOR 8
|
#define VERSION_MINOR 8
|
||||||
#define VERSION_PATCH 4
|
#define VERSION_PATCH 6
|
||||||
#define VERSION "v0.8.4"
|
#define VERSION "v0.8.6"
|
||||||
|
|
||||||
// Relay metadata (authoritative source for NIP-11 information)
|
// Relay metadata (authoritative source for NIP-11 information)
|
||||||
#define RELAY_NAME "C-Relay"
|
#define RELAY_NAME "C-Relay"
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=C Nostr Relay Server (Local Development)
|
|
||||||
Documentation=https://github.com/your-repo/c-relay
|
|
||||||
After=network.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=teknari
|
|
||||||
WorkingDirectory=/home/teknari/Storage/c_relay
|
|
||||||
Environment=DEBUG_LEVEL=0
|
|
||||||
ExecStart=/home/teknari/Storage/c_relay/crelay --port 7777 --debug-level=$DEBUG_LEVEL
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=c-relay-local
|
|
||||||
|
|
||||||
# Security settings (relaxed for local development)
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ReadWritePaths=/home/teknari/Storage/c_relay
|
|
||||||
PrivateTmp=true
|
|
||||||
|
|
||||||
# Network security
|
|
||||||
PrivateNetwork=false
|
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6
|
|
||||||
|
|
||||||
# Resource limits
|
|
||||||
LimitNOFILE=65536
|
|
||||||
LimitNPROC=4096
|
|
||||||
|
|
||||||
# Event-based configuration system
|
|
||||||
# No environment variables needed - all configuration is stored as Nostr events
|
|
||||||
# Database files (<relay_pubkey>.db) are created automatically in WorkingDirectory
|
|
||||||
# Admin keys are generated and displayed only during first startup
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
Reference in New Issue
Block a user