Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ca459593c | ||
|
|
ee4208cc19 | ||
|
|
f6330e4bb8 | ||
|
|
4f3cf10a5c | ||
|
|
aa1954e81e | ||
|
|
482597bd0e | ||
|
|
d4b90e681c | ||
|
|
fcf9e43c4c | ||
|
|
b8d8cd19d3 | ||
|
|
536c2d966c | ||
|
|
f49cb0a5ac | ||
|
|
cef6bb2340 | ||
|
|
4c03253b30 | ||
|
|
ed09bb7370 | ||
|
|
5c46a25173 | ||
|
|
d1538f00df | ||
|
|
afa4acbbfb | ||
|
|
d9a530485f |
@@ -10,18 +10,18 @@ This relay is written in C99 with a sqlite database.
|
||||
|
||||
It implements the following NIPs.
|
||||
|
||||
- [X] NIP-01: Basic protocol flow implementation
|
||||
- [X] NIP-09: Event deletion
|
||||
- [X] NIP-11: Relay information document
|
||||
- [X] NIP-13: Proof of Work
|
||||
- [X] NIP-15: End of Stored Events Notice
|
||||
- [X] NIP-20: Command Results
|
||||
- [X] NIP-33: Parameterized Replaceable Events
|
||||
- [X] NIP-40: Expiration Timestamp
|
||||
- [X] NIP-42: Authentication of clients to relays
|
||||
- [X] NIP-45: Counting results
|
||||
- [X] NIP-50: Keywords filter
|
||||
- [X] NIP-70: Protected Events
|
||||
- [x] NIP-01: Basic protocol flow implementation
|
||||
- [x] NIP-09: Event deletion
|
||||
- [x] NIP-11: Relay information document
|
||||
- [x] NIP-13: Proof of Work
|
||||
- [x] NIP-15: End of Stored Events Notice
|
||||
- [x] NIP-20: Command Results
|
||||
- [x] NIP-33: Parameterized Replaceable Events
|
||||
- [x] NIP-40: Expiration Timestamp
|
||||
- [x] NIP-42: Authentication of clients to relays
|
||||
- [x] NIP-45: Counting results
|
||||
- [x] NIP-50: Keywords filter
|
||||
- [x] NIP-70: Protected Events
|
||||
|
||||
## The unconventional
|
||||
|
||||
@@ -33,10 +33,33 @@ I'm not bothering with other operating systems.
|
||||
|
||||
### The relay is a full nostr citizen with it's own public and private keys.
|
||||
|
||||
For example, you can see my relay (wss://relay.laantungir.net) running here:
|
||||
For example, you can see my relay's profile (wss://relay.laantungir.net) running here:
|
||||
|
||||
[https://primal.net/p/nprofile1qqswn2jsmm8lq8evas0v9vhqkdpn9nuujt90mtz60nqgsxndy66es4qjjnhr7](https://)
|
||||
[Primal link](https://primal.net/p/nprofile1qqswn2jsmm8lq8evas0v9vhqkdpn9nuujt90mtz60nqgsxndy66es4qjjnhr7)
|
||||
|
||||
What this means in practice is that when you start the relay, it generates keys for itself, and for it's administrator (You can specify these if you wish)
|
||||
|
||||
Now the program and the administrator can have verifed communication between the two, simply by using nostr events. For example, the administrator can send DMs to the relay, asking it's status, and changing it's configuration through any client that can handle nip17 DMs. The relay can also send notifications to the administrator about it's current status, or it can publish it's status on a regular schedule directly to NOSTR as kind-1 notes.
|
||||
|
||||
Also included is a more standard administrative web front end. This front end communicates to the relay using an extensive api, which again is nostr events signed by the administrator. You can completely control the relay through signed nostr events.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
Main page with real time updates.
|
||||
|
||||

|
||||
Set your configuration preferences.
|
||||
|
||||

|
||||
View current subscriptions
|
||||
|
||||

|
||||
Add npubs to white or black lists.
|
||||
|
||||

|
||||
Run sql queries on the database.
|
||||
|
||||

|
||||
Light mode.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
256
api/index.js
256
api/index.js
@@ -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
|
||||
|
||||
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.
72
debug.log
72
debug.log
@@ -1,72 +0,0 @@
|
||||
|
||||
=== NOSTR WebSocket Debug Log Started ===
|
||||
[14:16:28.243] SEND localhost:8888: ["EVENT", {
|
||||
"pubkey": "193279d1459ba1399aadb954422bf8595aa77367dccf482c682f5f208e435844",
|
||||
"created_at": 1761499411,
|
||||
"kind": 1059,
|
||||
"tags": [["p", "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"]],
|
||||
"content": "AmWNi4P5J126kk69XH2o5mYvGj+69+Fjfr/nZx892I2z8edkwtp2IH7XAnPUqdGPu7x1xiZF1sNfr21VKThOhE54K/uQHLFydZN3acgUfX13sCeWhrvnQD0EvjvZC6QzW9DfFayYoYl+rEPYcra1/N68a+N1R7XnNcf1K/ZFh5Grcnln0H5YdXKRBhQI9aai4iFp1VGy2V0IR+6gDJGbJ7TbAbD3wgGWv1i77C03skH3RgzH+f2b7VBtm+vjKX6q7v6v8j3w1lRFE5Qh0Tqgedh3+UsnwqQta7OCzF9OyAVPK7EqKQBss5LzYSRUpcCE1vw5b7I7yeBFwU9WfnGLUW+uZxMJ2C3P4NBBrVO8UFIkBrPL2cqkoD5c8DgMLJjXGmc4EWfB4ZWb3KjbfLbgi6DVQ++cDjBbnCOPhX+/4qOnWq+gI28e/xk3cBvQtgUOkvWX3oGl3/Q33u4UGtxkFEXGfzdKHVDkR86kqf7RMZjIwTjLGpx4uov0cNmzj07hYEdoG/lJ4yA1v/GyF7viJdnnz3tE0hCZaViqSCev0rfUHWRDDMXJzJ9SS+OwpVswSG4NKvYsDhDM89BjhFs08HshTFdIh2AY45jR/16CsZM9JudH5BwqcX23wToYdZ+lrerOA0EkYb0DJUzGVe4lMpdJZoB8qXLHxMAKwKu0UEWEkeBnnZbvTGwCRbfGorxwPrnyqUCy9tzJx0GOLhRIzBmt6lki607VLDYjK97VIz0dff3fyWPAfy/yBlO2nHhVubUgpPaAjcaYNkO/iZwuP8oJkClWWmKwAQoNoxt+Ly2llrkz+Ne8oXMQdJSq416x6MLHo2JbKH8uwjx0yKG0oldLyWaz3A8OHYkJuxOi7HPVTlOOJrsjG4kMn2g97rVUXLs5v9F/StOjzxtiQWmCBtCsvK2LEEK/DzfavcJstEMxQztJjhiYRO3MJanL7lN2zu1ZHO149FJrgqGV6RQ8DDXf55yuabqHilBuUSDKpI0gl0+Efuor1my+L9J7MjJQ83aSwGizX7uXedMsGQRcvU3++Uvbw7sd2l67fb7IoYU04TPGZkIm120qwf7GAUpnDL7Lhulu/9LFMFs3UnGl9cLzY6EAJtDANHjMAoXbGbYclnoSiNW4yr3X9PBHO5o2YhIxfpTyEgLebJLOkzoziuCTpX8/MdhOhFtlIyo5B8Mbt5GDOHh4x1ZMKOl02J00Vvgui0hLw4Vri8Lz/ErPIRSlrEOB+8K5zPzJy/bD8XrOKlOwSbF5j9dsqs+8uCTC/v9YNQ0cC9wP7gVAxErQ3suJVeV7pzY+eGR051AcW7ppTs1gShhxDDaSaKdMlrkBdFDZcCJ+tomSgW56bOi45erpmk8Lcv4RrBzjBtq1hz+XSaTBAtEnGtHNH2uOn7KP/NNaD38dYkpb3N1VR3zuV67RcuPZeB+5WR9jhnLoSMGox2s=",
|
||||
"id": "c6c18d902744fc0aaa4ca9172b3bcd0dde3fd7d943b41b2a39a16927ede67804",
|
||||
"sig": "d67e0e914aa361c528510efd216548b6734a5fa68c46426571fbc87626bf19a9ec46e16883e7fad700f4fee5cfffd9bba03c3c08e57938fbca77a28b30a32bb7"
|
||||
}]
|
||||
[14:16:28.256] RECV localhost:8888: ["OK", "c6c18d902744fc0aaa4ca9172b3bcd0dde3fd7d943b41b2a39a16927ede67804", true, ""]
|
||||
|
||||
=== NOSTR WebSocket Debug Log Started ===
|
||||
[15:01:18.592] SEND localhost:8888: ["EVENT", {
|
||||
"pubkey": "ec9578ade9e74358ed35d8091d41bfa277e86d649614a8865e3725e38ebe5bc9",
|
||||
"created_at": 1761502101,
|
||||
"kind": 1059,
|
||||
"tags": [["p", "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"]],
|
||||
"content": "AlgLnVwti8Dk2nu0e4bMrXeZiR/u+RPnA85kpts2svaFGfMByS4iap7xqdiSrXpSQPjQsix6jP9Qiy1a6rrvC6MutqTi3JfsMexLR61/ZKTK41sWTXNDTT3keH543vx3fVQH1mq+LgG4mjNzkPe0RqkYFvC8R0nxyAcCecHDxZUlmXQmAGiB5JB2GvstA4eoZLP1OI3fcLA3qaITLNRJOwRUoTYKqUkENHwz74CW0TnYDrKRZVe9zKNWQBLmtsgVoGd5CXNAVgXwmm2h0eCNIcRGnFqDHzpegpEGO+A7tvB0KJwlj4j/GmRgmnWO4pkrM2fmsTdlb5KNqe7NPuTVgYfdvld70zWpenp7jF/0psaEQEl8R7FbG2rNCv8fXtH+womvJQj4S0eUBxfvsUU1wWYmhusEzvyTfpV/nw0Er+pmAUZ2eGk7LEB2GMsJrkT+G5oohm0n+5c72iWJqW9A1eAzjR6Z21FkH4kAEJOl70fw9Xeig+s9rYk3GcKlMvj42zf7DepMXHPy62TbqUeclcm5W/semyasGP521GBuw152IN+dS67OVVmEvEJ89xhwiTeIty78enR4Gq7d1eNK+rqStdtJ7FN6kD/8gv4sFojUXyi0sIxzaSPrwI3ohOqbpEK1dTs6fmTUiyT/Buq++IhD9UwsZgz/kYpZfm1NVnWx+yTEv4I1H80FDxmMzbYnTHuIdRJFeh/NJRy9h+gXoZlZnteHkbwm1w2AejTkVnGs2Pz7aUZgC+1Za8fhtXq3Z9N3R8f8gtVFnnjRzApg5U89QrXUBS0R6F9dTqINk4qti94JWO4dYcPuudCutME5hfCYBoo+LHuRmdPKry7vSK1WgLYQsHuG+r313Ak8DhZYNbL+0d5UJ9kDFlFKaP3xLahSbEc/7u+AyuN68IyM1NwEehllxqVUsX8dsD4bZ2yW5rVjAQm9tT8Ypm73kJEb+DYVqT0WjFx88ee+HX89a9NgszWf1HE1KNQ9gWjn4eH6xbwrOkS4/v2O2tQoAd00vyPKAWly3Zlrz2cRnrSnxTZ5Lt7HtwAt6Err8MhD/w5rMLXHTBCMrroG1VfMo1OgL1YPafKDZmwVcHWacqtZiB0heRx742WipmTonqMjCOTNufdwxQcRPLLio0mtqiIrzgJqqIQenBXSa1jaG6Lvb5PCUKThbg4sSFfgssoUNKM7ytmBAe+PPmOVe11/gGaFWoQeUordbvmiCtzIPUYiKsuhfeK4I3jKvEofQU37hOam8ZxUczXvX6dgOOto002EWyCVfAzFgyey6wI+FGEbhXqlw7nB+azpqLQMJnHg7pfb1stXk3d8rjgVrRsVRJe/5KrXyZ5cd7ftJuJLxpTYmfFu6CKoUE0L5eRxXiwa16Pi0BehxOLaZteiTzttyfj+ClMKs2J/2/T1BVya1oGUW2Wg6ri/qS8oXv8bqiXBZ1/BwfI=",
|
||||
"id": "ea5bb419a8efea8ee86bb8696406a70a0387a7d0ac6e60760026d1aea28b427f",
|
||||
"sig": "0ffde3fd0d83c80693aa656668f2553807f8d474738ff3d9676090a5b8748a8e8e0c75a1d64963e4604046e18a806c4371a9cf2af2fd72f9db50f15bc78a4e25"
|
||||
}]
|
||||
[15:01:18.604] RECV localhost:8888: ["OK", "ea5bb419a8efea8ee86bb8696406a70a0387a7d0ac6e60760026d1aea28b427f", true, ""]
|
||||
|
||||
=== NOSTR WebSocket Debug Log Started ===
|
||||
[07:46:36.863] SEND localhost:8888: ["EVENT", {
|
||||
"pubkey": "99e37bc774d260b464e936ad8945deec62e8f5f8af53e9db662038a717d39bd5",
|
||||
"created_at": 1761562419,
|
||||
"kind": 1059,
|
||||
"tags": [["p", "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"]],
|
||||
"content": "AjYCV8Esqa1L9LQE2G8cDVn+hSXjAJlFVp5nAaC6nuag/1AphKpsAJFGrWZvJ+rte5+4dbmdk+osvlxxfRQHtaZqjaTbVDKJA2b2KTYLgICe7O9rqTqR8oC4sYOVQViEjo30ox3IfDgdR5ONlaprvQ8r71E+oplWOjahUNvf5Yb9OOHbFphOqqWtbYRYqAqvO0bj3rB+tmyUJ8v3mU8NsKJtOTOIvN+jTIwU8cbN6AM6/A87WSi7J7X9/wLpFigBNxrx82MJ025ryApNWyt5PuBia3krPDa51F/A+jFVp1QicVwSt6tP01ktYJn3uyR3qizIiZiXzmxV9+TXopq+mOTlAiwcZBm1ZkS/PgfoUMDUOOVcCAW6ppZlg3oK5jScuDl1d4cZgTmmAPneaHhgB9A/DbWWr03W1vJAXCmDQRUoACfwsvLQf5esXkPcJV+ANgLl8sKd4EPmDAzr946uDcs2BUDftr4++jbdTkg9yIHb4SHnI10osbsqP7BqTrF1TbZHnxev4l5XyaIqhGm6WdQ90uGn1VDSUXXuou3IbqwhZReifYQAL9/5PAeSH6RrrL4neEzBjosSNkcMmtAxqBfd8dOHfqT6r15osKXc1eSmO9qDjZxUHUb5zIJjrkDW0jY8vAfiMqZhKd5Vl7stYf2iJJxdm04r4zpxGBjlYQva2LyrPclBIGxax6sTQTxoRyUBwyis7OnxUC0HO7bqr464RzCX1/OHMOYhFQu3BY8Rvytl9E1hS+rJpWgNkHsbjr5zs1l/B+qwt+zqnYlAtjZZ2T56Pjd+jZcRTt/NKDtyQnLPxreBWgNy8IykNI0q2fgFiWJwh7GnlbYrx5zco04Ory/P+nW2/Xosp0232I48e2KhxtH6L1e6dOWqbZXFQXBqzKNlVTRkPyS9ykSSs7NAVknRz/vF86+jJVJa2z32Y4oQtJna8vK5J5HA3rRSlSEINwmcSiFUmuxeFAcFjYjyVGlBhmH3B/98CtT2+JHgUYpMiG51+HR+OI9qBGgsF5SI9JKai7CFC3OqfaW1rZHN96uta4VVGQ1mJetz/xB3W+QThsZ0IJ6/wBnbUpPBoab4rfnYeeVwOMxiK5B2UIZ1+ihRrSMsjMC8DAEbUAn9XNABJHhDo0KJcYqtpHBIkQgbqfuSKTLmc4mZNJCp8wmry9Tc9ZQo2jT0dZa/NZO+qtqWXWqZWbMngXFer4AtR+Vethhg6BdhYOYI/j8gOW1m8qodBlj9BHiKEU3Ig9z6WawsOD95VosxhqrQDuyO07igXNWMNK5exRfvp2QiHgILuC9diZZGBXPRLIDlKERTotPc5IdutkTG6qVh6+r6wbwtVhiWJVmfVy/D0hvDvlaqzVk3FRVuRuMZI+LmF3OdNGIf0+lfMUeMAABhDNTWyyS8gG21JJZQOBxGc12x49xWvMLbXaPCKBKrqw4FLF4PTCc=",
|
||||
"id": "9899324517c0e1796ea513cfc9fa0a2592cf5532774abc7e2a1bac7bb16c4fbb",
|
||||
"sig": "0d73ac599d0d6d99dd9afa0c92d741e459bc53102557acba5d868089776bb36a521ae800303ce5ceceabc8d643116a74560744243b3a1c7749d6a52117343637"
|
||||
}]
|
||||
[07:46:36.876] RECV localhost:8888: ["OK", "9899324517c0e1796ea513cfc9fa0a2592cf5532774abc7e2a1bac7bb16c4fbb", true, ""]
|
||||
|
||||
=== NOSTR WebSocket Debug Log Started ===
|
||||
[07:46:57.426] SEND localhost:8888: ["EVENT", {
|
||||
"pubkey": "a1efe929139f3f195159389a6eb7199c127c88e32a0264cd826e95806a7c7db3",
|
||||
"created_at": 1761562440,
|
||||
"kind": 1059,
|
||||
"tags": [["p", "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"]],
|
||||
"content": "ArP2HEobkU/QYXy2R94zSkKM1OfQT5SabPeebj9dQVGbUKaKDwuN2RUYTRJ5rD8euyiXat8YzYO7PJ0CHzxclXxO8AWpdN4P76srm3zJ5z6kpQpcCFgInV7k4v6LZmmCrrtdTWqLjuLTPJJd6W9J7HqbTA3Dt4200BSpA4Por1TQAncplwH7O4vBfbPYtdv9w1RL1uSInMWcGwxttTXlyTtAJ0G0hQNofowFCMWuQCKjV5LxPfoXdOCrsp/We6x+hYKDxphsDjQ1tbdtYFmj/YRy6MJm1id4mr+i8fEnyimshE/fAhavOXUg6239MmYj8nR2RT9LMuhVckX+V5MZnVyC3mZlfzkPJiTHxiDkEREjljNOX9I+9yChg6MvyU41s7GjBlPyWiyeXedPcU2Q8ypGsFLhBl5i+IGSn6wCcBH8+h3euG8jtBgKxIP1qBYsXPTlYpSXQcisIZlW2Rubcawf/RF7HYbIRu884mpdVnURcHqfN9yquoVyfvIgQR5Fs6IbKatJ64LfMLkLNs4UIlumtQRdW3NFjglgb/rF9btKYVFHRG9dWwOpZBd0zvXtWKbts2AKQFU30/WegaPh2LT5rN9HfMsA1tI9YwZm//T2NiLaPwJCuOFWBOUiB7jIObQKtOHrICI/jXIGOAfgox5+fcAE6CaysHHzluVcwiw7GioShidaIDsZ10rWJOv1HeRpuiAJJTWk2FOBJzpOxli6s6jGj2S481Xa99I13TihgL0wAPhjsnQhz0kh40g89mipzVO5hbki101zIJCEBrDeT4Ptabc9GminXedq9k1G98usM0JSHsgtdZdztme/UyvYyAKMdez1yNgOp7YgOU15Rpz/KGL6W5Wk3MbUwpuVRzUWEMoBcyMzssn5Sa3mkh1RQqpTcoQaktTNwkhR1R5bgedka61JmcK4Uq3Hi/HfKYHbeUeta6Olu+U19PEwZia1iq+y0ZQm5gMwCK8BsoV44OLsjeDKlyRGCtIjkTc/L2LyuAZFhw560vKflkigQVcajaQVtEDgaT5odgFwvYEMOjbBDloDs589hAn8ZLyRJo3tIXNwqhctKTSqbit5qs85pOHkXSC3gsRQvDfq4qVh8iWXFotmOHlBEh4OZk89xwAnP0wiv5kd8N2c2CTB84SB224GinMhs0gkaCIXPPYv8IfVcow9+3sjnNov4dRRIB80fRXP9X3IyR7tXYCuq1uQO2iWiWKhNaqJRoTM1BUhLv0ebKYjfPevSVHUuV51CcsoFakNT8S0UnW7QHfmsESvCJLLT8ttrJqpRX2tf6SpzofHmzQHVrHFn8C7WKMVelndptmaOt/9Lek2UrZiKmzRP0CtBL+HoPRmZHF9t7y0qEhoApkrB9FPukH/IGV6jx891rH4nC1fLKc6zgkdjnYB7HDB+lWp2JKpV8Z3CbZXtR28kwIvZZIABZ23/U5cFds=",
|
||||
"id": "c8cdf8992fbc17a0ccb74f6dcb7b851f3fdd53317f5a5ea4e202a91b22e15ac6",
|
||||
"sig": "b9efba3448d67de8855838044427396af1958269642a975129fe877e48e5c0e0818d638264f8aa80404886559a7d29464339f63704044dbf11ff09eb0bdeda2b"
|
||||
}]
|
||||
[07:46:57.439] RECV localhost:8888: ["OK", "c8cdf8992fbc17a0ccb74f6dcb7b851f3fdd53317f5a5ea4e202a91b22e15ac6", true, ""]
|
||||
|
||||
=== NOSTR WebSocket Debug Log Started ===
|
||||
[07:48:51.631] SEND localhost:8888: ["EVENT", {
|
||||
"pubkey": "52feea8d0da247ed1537c88e12b2f6bc88697b69abe33bf4f059f9f10c0f2b43",
|
||||
"created_at": 1761562554,
|
||||
"kind": 1059,
|
||||
"tags": [["p", "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"]],
|
||||
"content": "AoU024VIO6IgceC43yYPvKxOb5PuuZQRAUQLC6Crdn5dtIVuHE8M/UUmmNXmWq3jB6kFbFWxNgFWuxCEG9sQHDEngk+tDOGlt+r0vx3jUZG09lzNzcghl/4l/Do48rcy0cfSm+mSHrJsy7N+MSAXQ1heKahKF2fSyfYFM+6EOSEl0sJSq09iWGFft0lWfeZ3AFpji0gp0Z5QY1hQPH3Te/TRuDCoR8GXG3NguLD22Ed8byOQzf/b7nWr70z2Sqg15zhMwyqkl//dOp9iIXE69FONqDfvFF0xttQ1L9PbzQizMt65CqbMuxGMiMA1zZsgQ8iN+xbIN4xv4DrzCtBZnYt3aJSt7cv+8Co5OmGNXu1RNUvxpZZTO8Dq/m08Y5JDYiRCvdh8kTASMVt0MfGsvWgmcHiiCINQUe7n5ynieayFpbl9j1Vtml0lmrfIOYnDQYmuqDDyG7PxhRt3G/SpiabWBwsqmTqCvrclXfTm4t5YYSr/5lKbwHzPk4qtdEs+LqH2/zd4egZnT4Xt/vIP/c55NrEWPmr50G37DpsSVbDxMQXs4dpldDntjEDFuL8VTkAzqibmiZSQnb6l+DpKYNdQCyg1S1ttnzYp2cRTAPzAVbRMqk1R6jagWnZyOs/JIK4on51JHczaUTCMypDLJFoOaFPTAedHfR/Hn2Nm05W8oQ/m/RGmxLgok6WgH3KJ8wvN+8X+XYpTgyxej/hYPqJnq3uaNqMbcD5katWRBmZtyZa3Cn2nZDqmFFJSABWacXNCyHL41Z+MhyYalYzvUev1ozUgx2NEWwxnSTvMkpOfvSDrs5Ncosle0itL9j0QVBrKFjHgq2BJ4FApv/Iq0af+8JEqhVEsMNpGwRJst1kn7kO+Q7O68PQF6PPlNqh7DNea0Bz1sN1QDt2yZTApi2b3IJTsbekye/WsOB0J+bLvxNQ/UoULcyq3SLRSqQEQMLBPz7JrijMzBdPglWpeZ58UDrbmd1KhnHzx7o1NvHyjPRuKj4M094+2/mTEFGOOIF+Ogjqj+wCSDnT5C0d/l2llQkIXCKcLONWT4bKkmTOjvNs6lX+VpBynaegGjzGOvJw1beRZIkRegTpV4pnMZH9833s175rcMcDjPnfT9FD+pDv1DkmXfww1k6MgfHTbwjSgj0K9862xFNwL3mC2g8XFNlcflC0Rd8PzXRg7TBn7855r+urqujCUqZrzdUMHvp08rEEzSJKljk4XN4DqZeWn2evv7UCbYjq46sVf2lEHCvHdqKoPf5ENU72y5Fb9tQJAyUoBTdPdb2SZ2Y2jSF+6+H2wVXrOlm8EwBquaREl25fs7Yqwjru7qz1rO9EA1jlNybFvALHEFQzHEpi8JeNi5T/mI+VleoUDrk/og2mucQVFqAQzRjASsaeDq9fZNqhv3Q3DBIpftmI4g6ZXqJhPRK8wF7Ym3mmC7eFHwalUprA=",
|
||||
"id": "9bc4b5ad293085272bf52ff17abb585f7e63bc155a5a39cfe1a5c046f141e571",
|
||||
"sig": "ee6b917761031a06bc50da0173aef881a61213473d4f533a8a4a96247edcdbd17dbf87919c4d92f8ea8719d5311d51a8028fbf62e3f40f9b8004ccbe9f3adabd"
|
||||
}]
|
||||
[07:49:01.659] RECV localhost:8888: ["OK", "9bc4b5ad293085272bf52ff17abb585f7e63bc155a5a39cfe1a5c046f141e571", false, "error: failed to store gift wrap event"]
|
||||
|
||||
=== NOSTR WebSocket Debug Log Started ===
|
||||
[07:50:47.319] SEND localhost:8888: ["EVENT", {
|
||||
"pubkey": "f206ef335cc3b360cf739680cd4540b852fb9d75aac552b58014a41cfc4c6c65",
|
||||
"created_at": 1761562670,
|
||||
"kind": 1059,
|
||||
"tags": [["p", "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"]],
|
||||
"content": "AuzXkvYpH0IX/T0BKtOAjO5QtglT3nXGXF20awgDX6T1qoWV8qykYY01qPlGLSDkOuOvhG5NZuFPs/hPMnctmnskvHTHqKJdUeT10Qe0JmZiP5y6fZSlrtLMKfyoLpNYFDXOfwooSD2Q0UN8ePfTkkB61ri7avsq8w1WjoVTUSG5kouJfQAgvh75uXNkcvTNWSX5gbCXxoL1D7twPSUXvuZBJTN0iVdh3jg8X1uZWhvpZJIIXceZIUdxaIp2EmrYVW0VEZZSbAFGAldtKasHrP3cJKwTk1IenMFXaPkJnsbvyzUZWKBwTCeBLBhMNzbBWOp5A7SgFW1vf9gm00MSQE/JjwOzDZIaIQ3vRCMbO29XLlcOCevs8FwusZ86LuXZ8EQac9vnc/7gEul/SkOQaSq7v4oGzwfY4iQ8c8VdX/+syE+ClUEjiKdN3/lcRGWhdMGYgK/uajLd9jpfY8CBzP0BZM0Oq6ZgSJt4ydntPfiUq4PtDww+56+bUUQb5V3eZ9SUnQgebO1doRgtvb6LiLGN3D9XolEiHBE3KDt/InfWPUeuf1HEf5IbDc2w7zhgzWbvXK9G4NnsAmOHQNIGbPHXLWEOOhRtcEnrHILKYgs4wfuvSnfSUzfWxHVlhXkuXj4pq/EmJKmQg3zB1C2QzKMx7O/oHplnQFGUfAMDQY9GpBWLCOhQH3ZiHWQ02AjXSze7PGB9ac7KoPmyKafUAgXbASp/G9t7n3pxarzwBNm4zQ6wPRpR5OF+mFYQ9ClJ+3MlUDNq1T8fGuJVIduxFgyWMAgKoJBQe+xP5qHEuCxhG1B2KtoKHbzAtXpV+sduXQOlm0Jq7req59VgtgrIVVoLNyn7ulUFmJvWbaPWyuMrC1z+MdjNw/oJ1mE4+zYSB4Yho7DCwVdIxWrkFSx1yH9s1WPPyERCy4/UQfHpPmAD/JyAnnkKsc7v3MpAWAYCsiil1/PgFFPRRrO7jS+Ez+veQl5tx376ac9MwaN+ZbFADqYaf8CCcWXhMlAYl/zcMWLXKqL/wKb6orpTTHiWU/iJIvbuT0MIN68LIX+G/S5QCIcAQez+G35n5pDUkKikVQguKcJG51iDZqRAc+fnjSa7ifu8HBJ8HIKZjHEEyp6oGU0LCEWH60iIa7toKAwRx8rLPP2tWo+5u41nUrhpUXhUquQu8Dr+LrNdB30qYlH123R0NBBtXG7ngW8WDv2GQcul33ftiI/14QofOthA8SiExW5B7OsWJQON8sS1ZTc5l/M6f5B17CwqmAGd4NdKPQy1SZWGD61jkefwzKW/w4fZFXfploGwuYvFI/G8/YnaJ60p/k+2Aftcst9ikAHZF4xBtuJr4IrT6/f+snv12G4EdowmaSMjXRZv30d4yKwFmwiuoDHWLyYVwBkO+UO3r0WEe1DId0Z1FZnXfgdnM+zAZwITtCVQjZMcsOSNskKd1eE=",
|
||||
"id": "ce28dc9c653a4f5451266bc215942be9a54e4777a27862fddce351a59cc2dbf3",
|
||||
"sig": "539f314c0f0fd685647da358c4153272baf671f1a1bc42b8ff61231c4b5f1f03cb8d15a36fb78437dbf094c546e9ffe8e03de7ddb3b62a981c135a714ec57f93"
|
||||
}]
|
||||
[07:50:47.325] RECV localhost:8888: ["OK", "ce28dc9c653a4f5451266bc215942be9a54e4777a27862fddce351a59cc2dbf3", true, ""]
|
||||
@@ -4,16 +4,16 @@
|
||||
cp build/c_relay_x86 ~/Storage/c_relay/crelay
|
||||
|
||||
# Copy the local service file to systemd
|
||||
sudo cp systemd/c-relay-local.service /etc/systemd/system/
|
||||
# sudo cp systemd/c-relay-local.service /etc/systemd/system/
|
||||
|
||||
# Reload systemd daemon to pick up the new service
|
||||
sudo systemctl daemon-reload
|
||||
# sudo systemctl daemon-reload
|
||||
|
||||
# Enable the service (if not already enabled)
|
||||
sudo systemctl enable c-relay-local.service
|
||||
# sudo systemctl enable c-relay-local.service
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart c-relay-local.service
|
||||
# sudo systemctl restart c-relay-local.service
|
||||
|
||||
# Show service status
|
||||
sudo systemctl status c-relay-local.service --no-pager -l
|
||||
# sudo systemctl status c-relay-local.service --no-pager -l
|
||||
|
||||
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,36 +8,59 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_status() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
|
||||
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
|
||||
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
|
||||
# Global variables
|
||||
COMMIT_MESSAGE=""
|
||||
RELEASE_MODE=false
|
||||
VERSION_INCREMENT_TYPE="patch" # "patch", "minor", or "major"
|
||||
|
||||
show_usage() {
|
||||
echo "C-Relay Increment and Push Script"
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " $0 \"commit message\" - Default: increment patch, commit & push"
|
||||
echo " $0 -r \"commit message\" - Release: increment minor, create release"
|
||||
echo "USAGE:"
|
||||
echo " $0 [OPTIONS] \"commit message\""
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo "COMMANDS:"
|
||||
echo " $0 \"commit message\" Default: increment patch, commit & push"
|
||||
echo " $0 -p \"commit message\" Increment patch version"
|
||||
echo " $0 -m \"commit message\" Increment minor version"
|
||||
echo " $0 -M \"commit message\" Increment major version"
|
||||
echo " $0 -r \"commit message\" Create release with assets (no version increment)"
|
||||
echo " $0 -r -m \"commit message\" Create release with minor version increment"
|
||||
echo " $0 -h Show this help message"
|
||||
echo ""
|
||||
echo "OPTIONS:"
|
||||
echo " -p, --patch Increment patch version (default)"
|
||||
echo " -m, --minor Increment minor version"
|
||||
echo " -M, --major Increment major version"
|
||||
echo " -r, --release Create release with assets"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "EXAMPLES:"
|
||||
echo " $0 \"Fixed event validation bug\""
|
||||
echo " $0 --release \"Major release with new features\""
|
||||
echo " $0 -m \"Added new features\""
|
||||
echo " $0 -M \"Breaking API changes\""
|
||||
echo " $0 -r \"Release current version\""
|
||||
echo " $0 -r -m \"Release with minor increment\""
|
||||
echo ""
|
||||
echo "Default Mode (patch increment):"
|
||||
echo " - Increment patch version (v1.2.3 → v1.2.4)"
|
||||
echo " - Git add, commit with message, and push"
|
||||
echo "VERSION INCREMENT MODES:"
|
||||
echo " -p, --patch (default): Increment patch version (v1.2.3 → v1.2.4)"
|
||||
echo " -m, --minor: Increment minor version, zero patch (v1.2.3 → v1.3.0)"
|
||||
echo " -M, --major: Increment major version, zero minor+patch (v1.2.3 → v2.0.0)"
|
||||
echo ""
|
||||
echo "Release Mode (-r flag):"
|
||||
echo " - Increment minor version, zero patch (v1.2.3 → v1.3.0)"
|
||||
echo " - Git add, commit, push, and create Gitea release"
|
||||
echo "RELEASE MODE (-r flag):"
|
||||
echo " - Build static binary using build_static.sh"
|
||||
echo " - Create source tarball"
|
||||
echo " - Git add, commit, push, and create Gitea release with assets"
|
||||
echo " - Can be combined with version increment flags"
|
||||
echo ""
|
||||
echo "Requirements for Release Mode:"
|
||||
echo "REQUIREMENTS FOR RELEASE MODE:"
|
||||
echo " - Gitea token in ~/.gitea_token for release uploads"
|
||||
echo " - Docker installed for static binary builds"
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
@@ -47,6 +70,18 @@ while [[ $# -gt 0 ]]; do
|
||||
RELEASE_MODE=true
|
||||
shift
|
||||
;;
|
||||
-p|--patch)
|
||||
VERSION_INCREMENT_TYPE="patch"
|
||||
shift
|
||||
;;
|
||||
-m|--minor)
|
||||
VERSION_INCREMENT_TYPE="minor"
|
||||
shift
|
||||
;;
|
||||
-M|--major)
|
||||
VERSION_INCREMENT_TYPE="major"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_usage
|
||||
exit 0
|
||||
@@ -79,7 +114,7 @@ check_git_repo() {
|
||||
|
||||
# Function to get current version and increment appropriately
|
||||
increment_version() {
|
||||
local increment_type="$1" # "patch" or "minor"
|
||||
local increment_type="$1" # "patch", "minor", or "major"
|
||||
|
||||
print_status "Getting current version..."
|
||||
|
||||
@@ -105,24 +140,34 @@ increment_version() {
|
||||
fi
|
||||
|
||||
# Increment version based on type
|
||||
if [[ "$increment_type" == "minor" ]]; then
|
||||
if [[ "$increment_type" == "major" ]]; then
|
||||
# Major release: increment major, zero minor and patch
|
||||
NEW_MAJOR=$((MAJOR + 1))
|
||||
NEW_MINOR=0
|
||||
NEW_PATCH=0
|
||||
NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
||||
print_status "Major version increment: incrementing major version"
|
||||
elif [[ "$increment_type" == "minor" ]]; then
|
||||
# Minor release: increment minor, zero patch
|
||||
NEW_MAJOR=$MAJOR
|
||||
NEW_MINOR=$((MINOR + 1))
|
||||
NEW_PATCH=0
|
||||
NEW_VERSION="v${MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
||||
print_status "Release mode: incrementing minor version"
|
||||
NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
||||
print_status "Minor version increment: incrementing minor version"
|
||||
else
|
||||
# Default: increment patch
|
||||
NEW_MAJOR=$MAJOR
|
||||
NEW_MINOR=$MINOR
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||
print_status "Default mode: incrementing patch version"
|
||||
NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
||||
print_status "Patch version increment: incrementing patch version"
|
||||
fi
|
||||
|
||||
print_status "Current version: $LATEST_TAG"
|
||||
print_status "New version: $NEW_VERSION"
|
||||
|
||||
# Update version in src/main.h
|
||||
update_version_in_header "$NEW_VERSION" "$MAJOR" "${NEW_MINOR:-$MINOR}" "${NEW_PATCH:-$PATCH}"
|
||||
update_version_in_header "$NEW_VERSION" "$NEW_MAJOR" "$NEW_MINOR" "$NEW_PATCH"
|
||||
|
||||
# Export for use in other functions
|
||||
export NEW_VERSION
|
||||
@@ -261,6 +306,115 @@ git_commit_and_push_no_tag() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to build release binary
|
||||
build_release_binary() {
|
||||
print_status "Building release binary..."
|
||||
|
||||
# Check if build_static.sh exists
|
||||
if [[ ! -f "build_static.sh" ]]; then
|
||||
print_error "build_static.sh not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Run the static build script
|
||||
if ./build_static.sh > /dev/null 2>&1; then
|
||||
print_success "Built static binary successfully"
|
||||
return 0
|
||||
else
|
||||
print_error "Failed to build static binary"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to create source tarball
|
||||
create_source_tarball() {
|
||||
print_status "Creating source tarball..."
|
||||
|
||||
local tarball_name="c-relay-${NEW_VERSION#v}.tar.gz"
|
||||
|
||||
# Create tarball excluding build artifacts and git files
|
||||
if tar -czf "$tarball_name" \
|
||||
--exclude='build/*' \
|
||||
--exclude='.git*' \
|
||||
--exclude='*.db' \
|
||||
--exclude='*.db-*' \
|
||||
--exclude='*.log' \
|
||||
--exclude='*.tar.gz' \
|
||||
. > /dev/null 2>&1; then
|
||||
print_success "Created source tarball: $tarball_name"
|
||||
echo "$tarball_name"
|
||||
return 0
|
||||
else
|
||||
print_error "Failed to create source tarball"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to upload release assets to Gitea
|
||||
upload_release_assets() {
|
||||
local release_id="$1"
|
||||
local binary_path="$2"
|
||||
local tarball_path="$3"
|
||||
|
||||
print_status "Uploading release assets..."
|
||||
|
||||
# Check for Gitea token
|
||||
if [[ ! -f "$HOME/.gitea_token" ]]; then
|
||||
print_warning "No ~/.gitea_token found. Skipping asset uploads."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
|
||||
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
|
||||
if [[ -f "$binary_path" ]]; then
|
||||
print_status "Uploading binary: $(basename "$binary_path")"
|
||||
|
||||
# Retry loop for eventual consistency
|
||||
local max_attempts=3
|
||||
local attempt=1
|
||||
while [[ $attempt -le $max_attempts ]]; do
|
||||
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
|
||||
|
||||
# Upload source tarball
|
||||
if [[ -f "$tarball_path" ]]; then
|
||||
print_status "Uploading source tarball: $(basename "$tarball_path")"
|
||||
local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
|
||||
-H "Authorization: token $token" \
|
||||
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")")
|
||||
|
||||
if echo "$tarball_response" | grep -q '"id"'; then
|
||||
print_success "Uploaded source tarball successfully"
|
||||
else
|
||||
print_warning "Failed to upload source tarball: $tarball_response"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to create Gitea release
|
||||
create_gitea_release() {
|
||||
print_status "Creating Gitea release..."
|
||||
@@ -278,16 +432,27 @@ create_gitea_release() {
|
||||
# Create release
|
||||
print_status "Creating release $NEW_VERSION..."
|
||||
local response=$(curl -s -X POST "$api_url/releases" \
|
||||
-H "Authorization: token $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}")
|
||||
-H "Authorization: token $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}")
|
||||
|
||||
if echo "$response" | grep -q '"id"'; then
|
||||
print_success "Created release $NEW_VERSION"
|
||||
return 0
|
||||
# Extract release ID for asset uploads
|
||||
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||
echo $release_id
|
||||
elif echo "$response" | grep -q "already exists"; then
|
||||
print_warning "Release $NEW_VERSION already exists"
|
||||
return 0
|
||||
# Try to get existing release ID
|
||||
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
|
||||
if echo "$check_response" | grep -q '"id"'; then
|
||||
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||
print_status "Using existing release ID: $release_id"
|
||||
echo $release_id
|
||||
else
|
||||
print_error "Could not find existing release ID"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
print_error "Failed to create release $NEW_VERSION"
|
||||
print_error "Response: $response"
|
||||
@@ -297,7 +462,8 @@ create_gitea_release() {
|
||||
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
|
||||
if echo "$check_response" | grep -q '"id"'; then
|
||||
print_warning "Release exists but creation response was unexpected"
|
||||
return 0
|
||||
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||
echo $release_id
|
||||
else
|
||||
print_error "Release does not exist and creation failed"
|
||||
return 1
|
||||
@@ -315,8 +481,15 @@ main() {
|
||||
if [[ "$RELEASE_MODE" == true ]]; then
|
||||
print_status "=== RELEASE MODE ==="
|
||||
|
||||
# Increment minor version for releases
|
||||
increment_version "minor"
|
||||
# Only increment version if explicitly requested (not just because of -r flag)
|
||||
if [[ "$VERSION_INCREMENT_TYPE" != "patch" ]]; then
|
||||
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
|
||||
|
||||
# Create new git tag BEFORE compilation so version.h picks it up
|
||||
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
|
||||
@@ -330,9 +503,42 @@ main() {
|
||||
# Commit and push (but skip tag creation since we already did it)
|
||||
git_commit_and_push_no_tag
|
||||
|
||||
# Build release binary
|
||||
if build_release_binary; then
|
||||
local binary_path="build/c_relay_static_x86_64"
|
||||
else
|
||||
print_warning "Binary build failed, continuing with release creation"
|
||||
# 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
|
||||
|
||||
# Create source tarball
|
||||
local tarball_path=""
|
||||
if tarball_path=$(create_source_tarball); then
|
||||
: # tarball_path is set by the function
|
||||
else
|
||||
print_warning "Source tarball creation failed, continuing with release creation"
|
||||
fi
|
||||
|
||||
# Create Gitea release
|
||||
if create_gitea_release; then
|
||||
print_success "Release $NEW_VERSION completed successfully!"
|
||||
local release_id=""
|
||||
if release_id=$(create_gitea_release); then
|
||||
# Validate release_id is numeric
|
||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||
# 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
|
||||
else
|
||||
print_error "Release creation failed"
|
||||
fi
|
||||
@@ -340,8 +546,8 @@ main() {
|
||||
else
|
||||
print_status "=== DEFAULT MODE ==="
|
||||
|
||||
# Increment patch version for regular commits
|
||||
increment_version "patch"
|
||||
# Increment version based on type (default to patch)
|
||||
increment_version "$VERSION_INCREMENT_TYPE"
|
||||
|
||||
# Create new git tag BEFORE compilation so version.h picks it up
|
||||
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
|
||||
|
||||
BIN
screenshots/2025-10-29_15-33.png
Normal file
BIN
screenshots/2025-10-29_15-33.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
screenshots/2025-10-29_15-35.png
Normal file
BIN
screenshots/2025-10-29_15-35.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
screenshots/2025-10-29_15-35_1.png
Normal file
BIN
screenshots/2025-10-29_15-35_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
screenshots/2025-10-29_16-04.png
Normal file
BIN
screenshots/2025-10-29_16-04.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
screenshots/main-light.png
Normal file
BIN
screenshots/main-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
84
src/api.c
84
src/api.c
@@ -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
@@ -12,8 +12,8 @@
|
||||
// Version information (auto-updated by build system)
|
||||
#define VERSION_MAJOR 0
|
||||
#define VERSION_MINOR 8
|
||||
#define VERSION_PATCH 1
|
||||
#define VERSION "v0.8.1"
|
||||
#define VERSION_PATCH 6
|
||||
#define VERSION "v0.8.6"
|
||||
|
||||
// Relay metadata (authoritative source for NIP-11 information)
|
||||
#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