Skip to content

Commit d886655

Browse files
committed
fix(amuleapi): align web frontend with backend, fix doc drift
The static web frontend and the docs/api reference had both drifted from the JSON the amuleapi backend actually emits. Verified each field against src/webapi/Api.cpp and src/webapi/EventDiff.cpp. Frontend (src/webapi/static): - downloads.js: register the clients resource under id "client_ecid" (was "ecid"). The backend keys clients by client_ecid, so the old id collapsed the whole uploads/clients list to a single "undefined" row. - events.js: buffer-then-replay on seed() so a delta (especially a *_removed) arriving while a snapshot fetch is in flight is no longer clobbered by the snapshot replacing the collection, per EVENTS.md. - events.js + search.js: wire the SSE search channel (search_result_added / search_finished), reconciling live upserts with the existing poll by hash; poll stays as the full-set reconciler and fallback when the stream isn't live. Docs (docs/api), corrected to match the backend: - REFERENCE.md: /status now shows kad.network; /clients and /servers examples completed; /kad lists firewalled_udp, ip, indexed, buddy; /logs/amule returns {lines,...} not {log}; /logs/serverinfo returns a text string (not a lines array); /stats/tree is {nodes:[{label, children}]} not {tree}; /stats/graphs points are objects {t,t_unix,value} with a full envelope; /search/results items use sources{total,complete}+already_have+rating and progress gains active. - EVENTS.md: log_appended payload is {lines} (no buffer; amule log only); search_result_added carries no ecid and flattens sources to an int.
1 parent 7549586 commit d886655

5 files changed

Lines changed: 175 additions & 36 deletions

File tree

docs/api/EVENTS.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ Both `since_id` and `newest_id` are uint64. The client never has to compute them
214214

215215
## Event catalog
216216

217-
Every event the bus publishes. The `_added` and `_updated` payloads are BYTE-FOR-BYTE identical to the matching REST resource's list-item shape — clients receiving a `*_updated` event get the full new state and never need to re-GET. `_removed` carries only the identity field — `hash` for files (`download_removed`, `shared_removed`), `client_ecid` for `client_removed`, `ecid` for `server_removed` — so the client can drop the cache entry without needing the old object. Two events don't fit the collection-delta model: `status_changed` ships a full status envelope (replace, not merge) and `log_appended` is an append operation (`{buffer, lines}` — push the lines onto the named buffer, don't replace). Branch on the event type in your dispatcher accordingly.
217+
Every event the bus publishes. The `_added` and `_updated` payloads are BYTE-FOR-BYTE identical to the matching REST resource's list-item shape — clients receiving a `*_updated` event get the full new state and never need to re-GET. `_removed` carries only the identity field — `hash` for files (`download_removed`, `shared_removed`), `client_ecid` for `client_removed`, `ecid` for `server_removed` — so the client can drop the cache entry without needing the old object. Two events don't fit the collection-delta model: `status_changed` ships a full status envelope (replace, not merge) and `log_appended` is an append operation (`{lines}` — push the lines onto the amule log buffer, don't replace). Branch on the event type in your dispatcher accordingly.
218218

219219
### `downloads` channel
220220

@@ -384,25 +384,24 @@ Subscribe to this channel alone for a thin "header bar" client that just wants c
384384

385385
#### `log_appended`
386386

387-
Emitted when amuled or the serverinfo buffer appends new lines.
387+
Emitted when the amuled log buffer appends new lines.
388388

389389
```json
390-
{ "buffer": "amule", "lines": ["2026-06-19 11:00:00: line one", "2026-06-19 11:00:01: line two"] }
390+
{ "lines": ["2026-06-19 11:00:00: line one", "2026-06-19 11:00:01: line two"] }
391391
```
392392

393-
`buffer` is `"amule"` or `"serverinfo"`. Multiple lines may be batched into a single event when the underlying buffer landed several lines between refresher ticks. The [Bootstrap example](#bootstrap-snapshot--stream) doesn't pull `/logs/amule` or `/logs/serverinfo` — fetch them in step 2 if your UI shows historical log lines, otherwise treat `log_appended` as a live-only feed.
393+
Only the amuled log has a live channel; the serverinfo buffer has no SSE feed and is fetched by polling [`GET /logs/serverinfo`](REFERENCE.md#get-apiv0logsserverinfo--delete-apiv0logsserverinfo). Multiple lines may be batched into a single event when the buffer landed several lines between refresher ticks. The [Bootstrap example](#bootstrap-snapshot--stream) doesn't pull `/logs/amule` — fetch it in step 2 if your UI shows historical log lines, otherwise treat `log_appended` as a live-only feed.
394394

395395
### `search` channel
396396

397397
Driven by the refresher state machine that owns the `POST /search` → completion lifecycle (see [REFERENCE.md](REFERENCE.md#search-results)). Events only fire while a search is active; the channel is silent at idle. The [Bootstrap example](#bootstrap-snapshot--stream) omits `/search/results` because searches are normally client-initiated post-boot; if your UI persists a "search-in-progress" state across reloads, fetch `/search/results` and `/search/progress` in step 2 too.
398398

399399
#### `search_result_added`
400400

401-
Emitted per new ECID that appears in the results map between refresher ticks.
401+
Emitted per new result that appears in the results map between refresher ticks.
402402

403403
```json
404404
{
405-
"ecid": 42,
406405
"hash": "0123456789abcdef0123456789abcdef",
407406
"name": "ubuntu-24.04-desktop-amd64.iso",
408407
"size": 5765873664,
@@ -413,7 +412,7 @@ Emitted per new ECID that appears in the results map between refresher ticks.
413412
}
414413
```
415414

416-
Payload mirrors a single `/search/results` array entry. ECIDs are amuled-allocated and unique per amuled session, but **not** stable across `POST /search` calls — amuled wipes its searchlist on every new query and re-allocates from the same pool, so subscribers must treat each search as a fresh result space.
415+
Key results by `hash`. The payload carries the same data as a `/search/results` array entry but in a flatter shape: the source counts arrive as a top-level `sources` (total) integer plus a sibling `complete_sources`, whereas the REST endpoint nests them as `sources: {total, complete}`. amuled wipes its searchlist on every new `POST /search`, so subscribers must treat each search as a fresh result space — clear prior results when you start a new query.
417416

418417
#### `search_finished`
419418

docs/api/REFERENCE.md

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,16 @@ curl -s -H "Authorization: Bearer $TOKEN" http://$HOST/api/v0/status
233233
},
234234
"kad": {
235235
"state": "connected",
236-
"firewalled": false
236+
"firewalled": false,
237+
"network": { "users": 5400000, "files": 1400000000, "nodes": 2400 }
237238
},
238239
"speeds": { "download_bps": 4500000, "upload_bps": 50000 },
239240
"queue": { "upload_queue_length": 12, "total_source_count": 1843 }
240241
}
241242
```
242243

244+
`kad.network` is the same `{users, files, nodes}` rollup [`GET /kad`](#get-apiv0kad) serves, surfaced here so `/status` is a one-call dashboard view.
245+
243246
`ec_connected` is `false` while amuleapi can't reach the underlying amuled. Most other endpoints return `503 ec_unavailable` in that state.
244247

245248
**Errors:** `503 ec_unavailable` if amuleapi hasn't received its first EC snapshot yet.
@@ -542,13 +545,22 @@ curl -s -H "Authorization: Bearer $TOKEN" \
542545
"ip": "203.0.113.42",
543546
"port": 4662,
544547
"software": "eMule",
548+
"software_version": "0.50a",
549+
"os_info": "Linux",
545550
"upload_state": "uploading",
546551
"download_state": "idle",
552+
"ident_state": "verified",
553+
"download_file_name": "",
547554
"upload_file_hash": "8b54a3c20fae9e4b9f7e0c2c8c01b6b1",
548555
"download_file_hash": "",
549-
"download_file_name": "",
556+
"xfer": { "up_session": 22000000, "down_session": 0, "up_total": 452000000, "down_total": 189000000 },
550557
"upload_speed_bps": 22000,
551-
"download_speed_bps": 0
558+
"download_speed_bps": 0,
559+
"queue_waiting_position": 0,
560+
"remote_queue_rank": 0,
561+
"score": 150,
562+
"obfuscation_status": "obfuscated",
563+
"friend_slot": false
552564
}
553565
]
554566
}
@@ -645,11 +657,16 @@ Changes the upload priority of a single shared file. `{hash}` is the 32-char MD4
645657
{
646658
"ecid": 1,
647659
"name": "eMule Server",
660+
"description": "Public server",
661+
"version": "17.15",
648662
"address": "203.0.113.5:4242",
649663
"port": 4242,
650664
"users": 312000,
665+
"max_users": 500000,
651666
"files": 75000000,
652667
"priority": "normal",
668+
"ping_ms": 42,
669+
"failed": 0,
653670
"static": false
654671
}
655672
]
@@ -884,13 +901,20 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
884901

885902
**Auth:** `GUEST`
886903

887-
Standalone view of the Kad subtree from `/status`.
904+
Standalone view of the Kad subtree from `/status`, plus the detail fields the
905+
status rollup omits (`firewalled_udp`, `in_lan_mode`, your external `ip`, the
906+
`indexed` Kad-store counters, and `buddy` contact info for low-ID peers).
888907

889908
```json
890909
{
891910
"state": "connected",
892911
"firewalled": false,
893-
"network": { "users": 5400000, "files": 1.4e9, "nodes": 2400 }
912+
"firewalled_udp": false,
913+
"in_lan_mode": false,
914+
"ip": "203.0.113.5",
915+
"network": { "users": 5400000, "files": 1400000000, "nodes": 2400 },
916+
"indexed": { "sources": 12000, "keywords": 8500, "notes": 0, "load": 14 },
917+
"buddy": { "status": "connected", "ip": "203.0.113.9", "port": 4672 }
894918
}
895919
```
896920

@@ -907,9 +931,15 @@ amuled's general log buffer.
907931
**Query parameters:** `tail=N` — return only the last N lines (default: full buffer).
908932

909933
```json
910-
{ "log": ["2026-06-19 11:00:00: line one", "...line two"] }
934+
{
935+
"lines": ["2026-06-19 11:00:00: line one", "...line two"],
936+
"total_cached": 1024,
937+
"returned": 2
938+
}
911939
```
912940

941+
`lines` is the array of log lines; `total_cached` is how many lines are held in the buffer and `returned` how many this response carried (≤ `tail`).
942+
913943
#### `DELETE /api/v0/logs/amule`
914944

915945
**Auth:** `ADMIN`
@@ -924,7 +954,20 @@ Clears the buffer.
924954

925955
**Auth:** `GUEST` / `ADMIN`
926956

927-
Same shape; the ed2k server-info log buffer instead of the general log.
957+
The ed2k server-info log buffer. Unlike `/logs/amule`, amuled ships this one as a
958+
single accumulated text blob (it rotates it server-side), so the GET returns a
959+
`text` **string** rather than a `lines` array. `?tail=N` here clamps the number
960+
of trailing **bytes**, not lines.
961+
962+
```json
963+
{
964+
"text": "Connecting to eMule Server (203.0.113.5:4242)\nConnection established\n",
965+
"total_bytes": 4096,
966+
"returned_bytes": 68
967+
}
968+
```
969+
970+
`DELETE` clears the buffer and returns `{ "ok": true }`.
928971

929972
---
930973

@@ -934,11 +977,25 @@ Same shape; the ed2k server-info log buffer instead of the general log.
934977

935978
**Auth:** `GUEST`
936979

937-
A nested object mirroring amuled's "Statistics" tree (transfers, connections, clients, servers, downloads). Cached with a 1 s TTL.
980+
A tree mirroring amuled's "Statistics" tree (transfers, connections, clients, servers, downloads). Cached with a 1 s TTL.
981+
982+
The envelope is `{ "nodes": [...] }`. Each node is `{ "label": "<text>", "children": [...] }`; a leaf is a node whose `children` array is empty, with its value baked into the `label` string (e.g. `"Total uploaded: 12.4 GB"`).
938983

939984
```json
940985
{
941-
"tree": { "Transfers": { "Uploads": { "Total uploaded": "12.4 GB", "..." : "..." } } }
986+
"nodes": [
987+
{
988+
"label": "Transfers",
989+
"children": [
990+
{
991+
"label": "Uploads",
992+
"children": [
993+
{ "label": "Total uploaded: 12.4 GB", "children": [] }
994+
]
995+
}
996+
]
997+
}
998+
]
942999
}
9431000
```
9441001

@@ -952,11 +1009,22 @@ Time-series points behind the desktop Statistics graphs.
9521009

9531010
`{graph}` is one of `download`, `upload`, `connections`, `kad`.
9541011

1012+
**Query parameters:** `width=N` — clamp the response to the last `N` samples (default/`0` returns the full ~1800-sample window).
1013+
9551014
```json
956-
{ "points": [[1781430000, 4500000], [1781430010, 4800000]] }
1015+
{
1016+
"graph": "download",
1017+
"unit": "bps",
1018+
"interval_seconds": 1,
1019+
"points": [
1020+
{ "t": "2026-06-19T11:00:00Z", "t_unix": 1781430000, "value": 4500000 },
1021+
{ "t": "2026-06-19T11:00:10Z", "t_unix": 1781430010, "value": 4800000 }
1022+
],
1023+
"session": { "download_bytes": 12400000000, "upload_bytes": 980000000, "kad_bytes": 5400000 }
1024+
}
9571025
```
9581026

959-
Each entry is `[unix_timestamp, value]`.
1027+
Each point is an object with `t` (ISO8601 UTC), `t_unix` (unix seconds), and `value`. `unit` is `"bps"` for download/upload and `"count"` for connections/kad. `session` carries this-session byte totals so a client doesn't need a separate roundtrip.
9601028

9611029
**Errors:** `404 not_found` (unknown graph name), `503 ec_unavailable`.
9621030

@@ -1002,21 +1070,25 @@ This endpoint does NOT busy-wait — it returns whatever amuled has in its resul
10021070
{
10031071
"results": [
10041072
{
1005-
"hash": "8b54a3c2...",
1006-
"name": "example-distribution-26.04-amd64.iso",
1007-
"size": 3825205248,
1008-
"sources_count": 217,
1009-
"file_type": "iso"
1073+
"hash": "8b54a3c2...",
1074+
"name": "example-distribution-26.04-amd64.iso",
1075+
"size": 3825205248,
1076+
"sources": { "total": 217, "complete": 142 },
1077+
"already_have": false,
1078+
"rating": 0
10101079
}
10111080
],
10121081
"progress": {
10131082
"percent": 67,
1014-
"complete": false
1083+
"complete": false,
1084+
"active": true
10151085
}
10161086
}
10171087
```
10181088

1019-
`progress.percent` is in the `[0, 100]` range. `progress.complete` is `true` whenever amuled reports the search has finished (matches the raw amuled sentinel values `100`, `0xfffe` Kad-done, and `0xffff` local-done — all surface here as `percent: 100, complete: true`).
1089+
`sources.total` is the swarm size amuled reports for the result; `sources.complete` is how many of those hold the file complete. `already_have` is `true` when the hash is already in your downloads/shared. `rating` is the amuled-aggregated quality rating (`0` when unrated).
1090+
1091+
`progress.percent` is in the `[0, 100]` range. `progress.complete` is `true` whenever amuled reports the search has finished (matches the raw amuled sentinel values `100`, `0xfffe` Kad-done, and `0xffff` local-done — all surface here as `percent: 100, complete: true`). `progress.active` is `true` while a `POST /search` is in flight (the refresher is polling amuled) and `false` once it stops or finishes — use it to tell "no search started" (`active: false`) apart from "search running, no hits yet" (`active: true`).
10201092

10211093
`progress.percent: 0, progress.complete: false` can mean either "no search has been started" OR "Kad search just kicked off but hasn't reached any peers yet" — Kad search progress is not measurable mid-flight, only "started" and "finished". Clients that started the search themselves can treat the second tick onwards as "in progress".
10221094

src/webapi/static/js/events.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const SSE_FAIL_THRESHOLD = 3;
1616
const resources = new Map(); // key -> spec
1717
const collections = new Map(); // key -> Map(id -> item)
1818
const active = new Set(); // keys currently seeded/streamed
19+
const seedBuffers = new Map(); // key -> array of deltas that arrived mid-seed
1920

2021
let es = null;
2122
let sseFails = 0;
@@ -54,14 +55,26 @@ export const data = {
5455
async function seed(key) {
5556
const spec = resources.get(key);
5657
if (!spec) return;
58+
// Buffer-then-replay (EVENTS.md §Bootstrap): start buffering deltas
59+
// *before* awaiting the snapshot, so a delta (esp. a _removed) that lands
60+
// during the fetch isn't clobbered by the snapshot replacing the map.
61+
const buf = [];
62+
seedBuffers.set(key, buf);
5763
try {
5864
const arr = (await spec.list()) || [];
5965
const m = new Map();
6066
for (const it of arr) m.set(String(it[spec.id]), it);
67+
// Drain buffered deltas over the fresh snapshot, in arrival order.
68+
for (const { op, payload } of buf) {
69+
const idv = String(payload[spec.id]);
70+
if (op === "del") m.delete(idv); else m.set(idv, payload);
71+
}
6172
collections.set(key, m);
6273
publish(key);
6374
} catch (e) {
6475
console.error("seed " + key + " failed", e);
76+
} finally {
77+
seedBuffers.delete(key); // flip to direct-apply
6578
}
6679
}
6780

@@ -102,6 +115,18 @@ function openSse() {
102115
try { store.set("log:appended", JSON.parse(ev.data)); } catch (_) {}
103116
});
104117

118+
// Search has its own channel (search_result_added / search_finished) but
119+
// doesn't fit the added/updated/removed resource model (no _removed, the
120+
// payload is keyed by hash and carries `sources` as a flat int), so it's
121+
// surfaced via store keys the search view consumes directly. Inert unless
122+
// a search is active.
123+
es.addEventListener("search_result_added", (ev) => {
124+
try { store.set("search:result", JSON.parse(ev.data)); } catch (_) {}
125+
});
126+
es.addEventListener("search_finished", (ev) => {
127+
try { store.set("search:finished", JSON.parse(ev.data)); } catch (_) {}
128+
});
129+
105130
es.addEventListener("resync", () => {
106131
if (statusActive) refreshStatus();
107132
for (const k of active) seed(k);
@@ -122,6 +147,10 @@ function attachResourceListeners(spec) {
122147
function applyDelta(spec, op, ev) {
123148
let payload;
124149
try { payload = JSON.parse(ev.data); } catch (_) { return; }
150+
// Still seeding this key — buffer instead of applying so seed() can drain
151+
// these over the snapshot once it lands.
152+
const buf = seedBuffers.get(spec.key);
153+
if (buf) { buf.push({ op, payload }); return; }
125154
let m = collections.get(spec.key);
126155
if (!m) { m = new Map(); collections.set(spec.key, m); }
127156
const idv = String(payload[spec.id]);

src/webapi/static/js/views/downloads.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export default function Downloads({ isGuest }) {
3535
useEffect(() => {
3636
data.register({ key: "downloads", eventPrefix: "download", id: "hash",
3737
list: () => api.get("downloads?include_completed=1").then((r) => r.downloads || []) });
38-
data.register({ key: "clients", eventPrefix: "client", id: "ecid",
38+
data.register({ key: "clients", eventPrefix: "client", id: "client_ecid",
3939
list: () => api.get("clients").then((r) => r.clients || []) });
4040
api.get("categories").then((r) => setCategories(r.categories || [])).catch(() => {});
4141
data.ensure("downloads");

0 commit comments

Comments
 (0)