v1.0.0 - Version 1.0.0)

This commit is contained in:
Your Name
2025-10-31 10:39:06 -04:00
parent 5c46a25173
commit ed09bb7370
9 changed files with 814 additions and 119 deletions

View File

@@ -1261,3 +1261,50 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
.header-title.clickable:hover {
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;
}

View File

@@ -212,18 +212,9 @@
<div class="input-group">
<div class="config-table-container">
<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">
<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>
</tbody>
</table>

View File

@@ -4279,101 +4279,185 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
const tableBody = document.getElementById('subscription-details-table-body');
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 = '';
if (subscriptionsData.length === 0) {
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);
return;
}
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';
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);
// 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);
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

View 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

View 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

View File

@@ -1 +1 @@
2726527
3580654

View File

@@ -1503,6 +1503,32 @@ char* generate_config_text(void) {
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
char* generate_stats_text(void) {
char* stats_json = generate_stats_json();
@@ -1575,17 +1601,17 @@ char* generate_stats_text(void) {
// Database Overview section
offset += snprintf(stats_text + offset, 16384 - offset,
"Database Size %.2f MB\n"
"Total Events %lld\n"
"Active Subscriptions %d\n"
"Oldest Event %s\n"
"Newest Event %s\n"
"\n",
db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
"Database Size %.2f MB (%lld bytes)\n"
"Total Events %lld\n"
"Active Subscriptions %d\n"
"Oldest Event %s\n"
"Newest Event %s\n"
"\n",
db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
// Event Kind Distribution section
offset += snprintf(stats_text + offset, 16384 - offset,
"Event Kind Distribution:\n");
"Event_Kind_Distribution:\n");
if (event_kinds && cJSON_IsArray(event_kinds)) {
cJSON* kind_item = NULL;
@@ -1595,11 +1621,45 @@ char* generate_stats_text(void) {
cJSON* percentage = cJSON_GetObjectItem(kind_item, "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,
"%lld\t%lld\t%.1f%%\n",
(long long)cJSON_GetNumberValue(kind),
(long long)cJSON_GetNumberValue(count),
cJSON_GetNumberValue(percentage));
"%s%s_%s\n", kind_str, count_formatted, pct_formatted);
}
}
} else {

File diff suppressed because one or more lines are too long

View File

@@ -10,10 +10,10 @@
#define MAIN_H
// Version information (auto-updated by build system)
#define VERSION_MAJOR 0
#define VERSION_MINOR 8
#define VERSION_PATCH 5
#define VERSION "v0.8.5"
#define VERSION_MAJOR 1
#define VERSION_MINOR 0
#define VERSION_PATCH 0
#define VERSION "v1.0.0"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"