From 1d472082a3eaaec9136011aa6fff61c06235c264 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 7 Sep 2026 12:14:51 -0500 Subject: [PATCH] feat: add System Monitor for ATProto live streams --- .changeset/silly-humans-shout.md | 5 + TODO.md | 4 +- package.json | 3 +- pnpm-lock.yaml | 148 +++- src/lib/atproto/live-stream.test.ts | 105 +++ src/lib/atproto/live-stream.ts | 174 +++++ src/lib/components/GnomeMenuBar.svelte | 6 +- src/lib/components/SystemMonitor.svelte | 874 ++++++++++++++++++++++++ src/lib/launcher.ts | 3 + src/routes/+layout.svelte | 133 ++-- src/routes/live/+page.svelte | 5 + src/routes/live/firehose/+page.svelte | 5 + src/routes/live/jetstream/+page.svelte | 5 + src/routes/live/spacedust/+page.svelte | 5 + 14 files changed, 1380 insertions(+), 95 deletions(-) create mode 100644 .changeset/silly-humans-shout.md create mode 100644 src/lib/atproto/live-stream.test.ts create mode 100644 src/lib/atproto/live-stream.ts create mode 100644 src/lib/components/SystemMonitor.svelte create mode 100644 src/routes/live/+page.svelte create mode 100644 src/routes/live/firehose/+page.svelte create mode 100644 src/routes/live/jetstream/+page.svelte create mode 100644 src/routes/live/spacedust/+page.svelte diff --git a/.changeset/silly-humans-shout.md b/.changeset/silly-humans-shout.md new file mode 100644 index 0000000..c81c705 --- /dev/null +++ b/.changeset/silly-humans-shout.md @@ -0,0 +1,5 @@ +--- +'intrepid-ibex': minor +--- + +system monitor "app" for live streams (firehose, jetstream, spacedust) diff --git a/TODO.md b/TODO.md index 8af07e4..b8cb4a9 100644 --- a/TODO.md +++ b/TODO.md @@ -58,7 +58,7 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - Visualize an account at the center with app namespaces around it. - Show app count, record-type count, PDS hosting status, and app/domain validation. - Let users hide unresolved apps and jump from an app namespace into Nautilus. -- [ ] **System Monitor** +- [x] **System Monitor** - Icon: `/icons/humanity/apps/utilities-system-monitor.svg`. - Read-only live ATProto monitor for Jetstream, Firehose, and Spacedust-style backlink streams. @@ -111,7 +111,7 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - [x] Add canonical `/repos/:did/...` routing. - [x] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs. - [x] Add Identity Inspector. -- [ ] Add System Monitor with Jetstream first. +- [x] Add System Monitor with Jetstream first. - [ ] Add Network Map. - [ ] Add Archive Manager. - [ ] Add Eye of GNOME blob browsing and previews. diff --git a/package.json b/package.json index 6e8e81d..ec0fc5c 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "@atcute/atproto": "^4.0.2", "@atcute/bluesky": "^4.0.7", "@atcute/client": "^5.0.0", - "@atcute/lexicons": "^2.0.0", + "@atcute/firehose": "^1.1.0", + "@atcute/lexicons": "^2.1.0", "@fontsource/ubuntu": "^5.2.8", "@resvg/resvg-js": "^2.6.2", "@shikijs/core": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab4afc0..abeba6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,16 +10,19 @@ importers: dependencies: '@atcute/atproto': specifier: ^4.0.2 - version: 4.0.2(@atcute/lexicons@2.0.0) + version: 4.0.2(@atcute/lexicons@2.1.0) '@atcute/bluesky': specifier: ^4.0.7 - version: 4.0.7(@atcute/lexicons@2.0.0) + version: 4.0.7(@atcute/lexicons@2.1.0) '@atcute/client': specifier: ^5.0.0 - version: 5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) + version: 5.0.0(@atcute/lexicons@2.1.0)(typescript@6.0.3) + '@atcute/firehose': + specifier: ^1.1.0 + version: 1.1.0(@atcute/cid@2.4.2)(@atcute/lexicons@2.1.0) '@atcute/lexicons': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.1.0 + version: 2.1.0 '@fontsource/ubuntu': specifier: ^5.2.8 version: 5.2.8 @@ -142,24 +145,40 @@ packages: peerDependencies: '@atcute/lexicons': ^2.0.0 + '@atcute/cbor@2.3.6': + resolution: {integrity: sha512-Ml/G8i6KK5UWSx1kxgm41eTYm1EQ6O2fNWSXMJ5DxoQv/lmZ0Hmk7dmHda2seRE+Cj2X3HJKAuD9cQRh3m2P7A==} + peerDependencies: + '@atcute/cid': ^2.0.0 + + '@atcute/cid@2.4.2': + resolution: {integrity: sha512-Uy48yfyo/hPQXF+XMXWIGomF6v8IvX5ErjozXMV7rlfU3EV7PSVX3J7plJXV6MRC3iI1z3PgTZTS7V0drCQVVw==} + '@atcute/client@5.0.0': resolution: {integrity: sha512-Dtrc1no1oAtpUTmkBxH0xKSgy8qcnk8orPf/PkEATW+MB3k8FPHtPH2fshiKd55rioZkb+xaN+7A29WtqPQHRA==} peerDependencies: '@atcute/lexicons': ^2.0.0 + '@atcute/firehose@1.1.0': + resolution: {integrity: sha512-/tctw9Q0Qmfggflj6xubaZnI5FJGAFQNzgA4mMvd90qSB3yAadCCL3MByLX3/AgCVmpj1FVruevQ/XpdvnKSEA==} + peerDependencies: + '@atcute/lexicons': ^2.1.0 + '@atcute/identity@2.0.0': resolution: {integrity: sha512-YXFsggO7eJYifqkN85+kUXJE2a1iI9AyuzPTDjtS/4WE1Zs1/XiPkWmwZlAgtp+pYhVtjm3mJqy/h/mZ0OnIVw==} peerDependencies: '@atcute/lexicons': ^2.0.0 - '@atcute/lexicons@2.0.0': - resolution: {integrity: sha512-fIlwP+TPEAGoF5aU5s+f8N5sOjOu8Mww/sQL1B57Dp2hj3G/EWG9XwOHPokzycBCgXx+UxIIrzZCGy8whsVDZw==} + '@atcute/lexicons@2.1.0': + resolution: {integrity: sha512-cqbtPfFctoddrSFBt65xyD7st01d8rNntyinRfRhlRPGlqEg8FWacgc7piBw0SAs1SG+5BoV3d3mhzWfQHcByg==} + + '@atcute/multibase@1.2.5': + resolution: {integrity: sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw==} - '@atcute/uint8array@1.1.2': - resolution: {integrity: sha512-n+lutnbN9mKzSjSVdfsYfzJ40u2971H+iLSL46D6d7zcrA4delxusf/ftGFvj5oGW03OioaFgQOy3Lqa3JmTeA==} + '@atcute/uint8array@1.1.5': + resolution: {integrity: sha512-1SFCXOtjE3ismP92CqzbOfYSwJmPPPFbj798wLXSpLv2hSd5OIaUrTh6EqRVR1VZ3ZhR+vaBmd3kxAN0wkw+gQ==} - '@atcute/util-text@1.3.1': - resolution: {integrity: sha512-MRgJXkx67znuBXuoAYCJkBZyd3OApL7zZlNf5kXhuoCXcdiu1nblRDycYTADSkym4epBSQWxh26kmI9sewaq6A==} + '@atcute/util-text@1.3.4': + resolution: {integrity: sha512-u2UAM7iSM09sQaSG9jtxWFSPgB8boVj50/BoyMvYnhVgGBu+nXIuAcdDUQCsZA44YZgTPPhN/b86JX+jH7SPzQ==} '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} @@ -690,6 +709,12 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mary-ext/event-iterator@1.1.0': + resolution: {integrity: sha512-uL6XFBsfAq+h6wnmioLTDMKzgbuKKQTo/2k512W1w1M5SgVroS6+D4MOD+j1K0loEYUJD9rIKV0Fo5ETZlgafw==} + + '@mary-ext/simple-event-emitter@1.0.2': + resolution: {integrity: sha512-A644JdOf+KfTGyX/EYf8AI1Y18mM7369KrhtOVkdEwRa2QIC0Botv7XWnK6/XU7HuQfvtFpCufmHJC3hi4YMuw==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1426,6 +1451,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1836,6 +1864,14 @@ packages: parse-css-color@0.2.1: resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + partysocket@1.3.0: + resolution: {integrity: sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA==} + peerDependencies: + react: '>=17' + peerDependenciesMeta: + react: + optional: true + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2148,6 +2184,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript-eslint@8.61.0: resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2173,8 +2213,8 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - unicode-segmenter@0.14.5: - resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + unicode-segmenter@0.17.3: + resolution: {integrity: sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw==} unicode-trie@2.0.0: resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} @@ -2392,6 +2432,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -2409,41 +2453,69 @@ packages: snapshots: - '@atcute/atproto@4.0.2(@atcute/lexicons@2.0.0)': + '@atcute/atproto@4.0.2(@atcute/lexicons@2.1.0)': dependencies: - '@atcute/lexicons': 2.0.0 + '@atcute/lexicons': 2.1.0 - '@atcute/bluesky@4.0.7(@atcute/lexicons@2.0.0)': + '@atcute/bluesky@4.0.7(@atcute/lexicons@2.1.0)': dependencies: - '@atcute/atproto': 4.0.2(@atcute/lexicons@2.0.0) - '@atcute/lexicons': 2.0.0 + '@atcute/atproto': 4.0.2(@atcute/lexicons@2.1.0) + '@atcute/lexicons': 2.1.0 - '@atcute/client@5.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3)': + '@atcute/cbor@2.3.6(@atcute/cid@2.4.2)': dependencies: - '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3) - '@atcute/lexicons': 2.0.0 + '@atcute/cid': 2.4.2 + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.1.5 + + '@atcute/cid@2.4.2': + dependencies: + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.1.5 + + '@atcute/client@5.0.0(@atcute/lexicons@2.1.0)(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.0(@atcute/lexicons@2.1.0)(typescript@6.0.3) + '@atcute/lexicons': 2.1.0 transitivePeerDependencies: - typescript - '@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3)': + '@atcute/firehose@1.1.0(@atcute/cid@2.4.2)(@atcute/lexicons@2.1.0)': dependencies: - '@atcute/lexicons': 2.0.0 + '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) + '@atcute/lexicons': 2.1.0 + '@atcute/uint8array': 1.1.5 + '@mary-ext/event-iterator': 1.1.0 + '@mary-ext/simple-event-emitter': 1.0.2 + partysocket: 1.3.0 + type-fest: 4.41.0 + transitivePeerDependencies: + - '@atcute/cid' + - react + + '@atcute/identity@2.0.0(@atcute/lexicons@2.1.0)(typescript@6.0.3)': + dependencies: + '@atcute/lexicons': 2.1.0 valibot: 1.4.1(typescript@6.0.3) transitivePeerDependencies: - typescript - '@atcute/lexicons@2.0.0': + '@atcute/lexicons@2.1.0': dependencies: - '@atcute/uint8array': 1.1.2 - '@atcute/util-text': 1.3.1 + '@atcute/uint8array': 1.1.5 + '@atcute/util-text': 1.3.4 '@standard-schema/spec': 1.1.0 esm-env: 1.2.2 - '@atcute/uint8array@1.1.2': {} + '@atcute/multibase@1.2.5': + dependencies: + '@atcute/uint8array': 1.1.5 + + '@atcute/uint8array@1.1.5': {} - '@atcute/util-text@1.3.1': + '@atcute/util-text@1.3.4': dependencies: - unicode-segmenter: 0.14.5 + unicode-segmenter: 0.17.3 '@babel/runtime@7.29.7': {} @@ -2912,6 +2984,12 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mary-ext/event-iterator@1.1.0': + dependencies: + yocto-queue: 1.2.2 + + '@mary-ext/simple-event-emitter@1.0.2': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -3629,6 +3707,8 @@ snapshots: esutils@2.0.3: {} + event-target-polyfill@0.0.4: {} + expect-type@1.3.0: {} extendable-error@0.1.7: {} @@ -4013,6 +4093,10 @@ snapshots: color-name: 1.1.4 hex-rgb: 4.3.0 + partysocket@1.3.0: + dependencies: + event-target-polyfill: 0.0.4 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4338,6 +4422,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + typescript-eslint@8.61.0(eslint@10.4.1)(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3) @@ -4361,7 +4447,7 @@ snapshots: dependencies: pathe: 2.0.3 - unicode-segmenter@0.14.5: {} + unicode-segmenter@0.17.3: {} unicode-trie@2.0.0: dependencies: @@ -4528,6 +4614,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoga-layout@3.2.1: {} youch-core@0.3.3: diff --git a/src/lib/atproto/live-stream.test.ts b/src/lib/atproto/live-stream.test.ts new file mode 100644 index 0000000..c28d793 --- /dev/null +++ b/src/lib/atproto/live-stream.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { buildLiveStreamUrl, parseFirehoseEvent, parseTextLiveEvent } from './live-stream'; + +describe('live stream helpers', () => { + it('builds Jetstream v2 URLs with server-side filters and a cursor', () => { + const url = new URL( + buildLiveStreamUrl('jetstream', { + instance: 'wss://jetstream.us-east.bsky.network', + did: 'did:plc:example', + collection: 'app.bsky.feed.post', + cursor: '12345' + }) + ); + + expect(url.pathname).toBe('/xrpc/network.bsky.jetstream.subscribeEvents'); + expect(url.searchParams.get('dids')).toBe('did:plc:example'); + expect(url.searchParams.get('collections')).toBe('app.bsky.feed.post'); + expect(url.searchParams.get('cursor')).toBe('12345'); + }); + + it('uses Spacedust backlink filters but does not add unsupported cursors', () => { + const url = new URL( + buildLiveStreamUrl('spacedust', { + instance: 'wss://spacedust.microcosm.blue', + did: 'did:plc:target', + collection: 'app.bsky.feed.like:subject.uri', + cursor: '12345' + }) + ); + + expect(url.pathname).toBe('/subscribe'); + expect(url.searchParams.get('wantedSubjectDids')).toBe('did:plc:target'); + expect(url.searchParams.get('wantedSources')).toBe('app.bsky.feed.like:subject.uri'); + expect(url.searchParams.has('cursor')).toBe(false); + }); + + it('normalizes Jetstream and Spacedust JSON events', () => { + const jetstream = parseTextLiveEvent( + 'jetstream', + JSON.stringify({ + $type: 'message', + payload: { + $type: 'network.bsky.jetstream.subscribeEvents#commit', + did: 'did:plc:author', + seq: 42, + time: '2026-08-13T06:47:43.959Z', + operation: 'create', + collection: 'app.bsky.feed.post', + rkey: '3example' + } + }) + ); + const spacedust = parseTextLiveEvent( + 'spacedust', + JSON.stringify({ + kind: 'link', + origin: 'live', + link: { + operation: 'create', + source: 'app.bsky.feed.like:subject.uri', + source_record: 'at://did:plc:author/app.bsky.feed.like/3example', + subject: 'at://did:plc:target/app.bsky.feed.post/3target' + } + }) + ); + + expect(jetstream).toMatchObject({ + kind: 'commit', + operation: 'create', + did: 'did:plc:author', + collection: 'app.bsky.feed.post' + }); + expect(spacedust).toMatchObject({ + kind: 'link', + did: 'did:plc:author', + collection: 'app.bsky.feed.like', + source: 'app.bsky.feed.like:subject.uri' + }); + }); + + it('normalizes decoded firehose commits and summarizes binary fields', () => { + const event = parseFirehoseEvent({ + $type: 'com.atproto.sync.subscribeRepos#commit', + seq: 123, + repo: 'did:plc:author', + time: '2026-01-01T00:00:00Z', + ops: [{ action: 'create', path: 'app.bsky.feed.post/3example' }], + blocks: new Uint8Array(2048) + }); + + expect(event).toMatchObject({ + kind: 'commit', + operation: 'create', + did: 'did:plc:author', + collection: 'app.bsky.feed.post', + source: 'app.bsky.feed.post', + summary: 'create app.bsky.feed.post/3example' + }); + expect(event.json).toContain('[2048 bytes]'); + }); + + it('rejects non-WebSocket instances', () => { + expect(() => buildLiveStreamUrl('jetstream', { instance: 'https://example.com' })).toThrow('WebSocket URL'); + }); +}); diff --git a/src/lib/atproto/live-stream.ts b/src/lib/atproto/live-stream.ts new file mode 100644 index 0000000..55d5e37 --- /dev/null +++ b/src/lib/atproto/live-stream.ts @@ -0,0 +1,174 @@ +export type LiveStreamKind = 'jetstream' | 'firehose' | 'spacedust'; + +export type LiveEvent = { + id: string; + stream: LiveStreamKind; + time: string; + kind: string; + operation: string | null; + did: string | null; + collection: string | null; + source: string | null; + summary: string; + bytes: number | null; + json: string; +}; + +export const streamDefaults: Record = { + jetstream: { instance: 'wss://jetstream.us-east.bsky.network', collection: 'app.bsky.feed.post' }, + firehose: { instance: 'wss://relay1.us-east.bsky.network', collection: '' }, + spacedust: { instance: 'wss://spacedust.microcosm.blue', collection: 'app.bsky.feed.like:subject.uri' } +}; + +const streamPaths: Record = { + jetstream: '/xrpc/network.bsky.jetstream.subscribeEvents', + firehose: '/xrpc/com.atproto.sync.subscribeRepos', + spacedust: '/subscribe' +}; + +export function buildLiveStreamUrl( + kind: LiveStreamKind, + options: { instance: string; did?: string; collection?: string; cursor?: string } +) { + const url = new URL(options.instance.trim()); + if (url.protocol !== 'wss:' && url.protocol !== 'ws:') { + throw new Error('Stream instances must use a WebSocket URL (wss:// or ws://).'); + } + + if (url.pathname === '/' || url.pathname === '') url.pathname = streamPaths[kind]; + + const did = options.did?.trim(); + const collection = options.collection?.trim(); + const cursor = options.cursor?.trim(); + + if (kind === 'jetstream') { + if (did) url.searchParams.append('dids', did); + if (collection) url.searchParams.append('collections', collection); + } + + if (kind === 'spacedust') { + if (did) url.searchParams.append('wantedSubjectDids', did); + if (collection?.includes(':')) url.searchParams.append('wantedSources', collection); + } + + if (kind !== 'spacedust' && cursor) url.searchParams.set('cursor', cursor); + return url.toString(); +} + +export function parseTextLiveEvent(kind: Exclude, raw: string, receivedAt = new Date()) { + const parsed: unknown = JSON.parse(raw); + if (!isObject(parsed)) throw new Error('The stream returned a JSON value that was not an event object.'); + + if (kind === 'spacedust') return parseSpacedustEvent(parsed, raw, receivedAt); + return parseJetstreamEvent(parsed, raw, receivedAt); +} + +export function parseFirehoseEvent(message: unknown, receivedAt = new Date()): LiveEvent { + if (!isObject(message)) throw new Error('The firehose returned a value that was not an event object.'); + + const type = stringValue(message.$type) ?? 'event'; + const kind = type.includes('#') ? (type.split('#').pop() ?? type) : type; + const did = stringValue(message.repo) ?? stringValue(message.did); + const operations = Array.isArray(message.ops) ? message.ops.filter(isObject) : []; + const firstOperation = operations[0]; + const operation = stringValue(firstOperation?.action); + const path = stringValue(firstOperation?.path); + const collection = path?.split('/')[0] ?? null; + const time = stringValue(message.time) ?? receivedAt.toISOString(); + const json = JSON.stringify(message, firehoseJsonReplacer, 2); + const operationSummary = + operations.length === 1 ? path : operations.length > 1 ? `${operations.length} record operations` : null; + + return { + id: `firehose-${String(message.seq ?? receivedAt.getTime())}-${cryptoId()}`, + stream: 'firehose', + time, + kind, + operation, + did, + collection, + source: collection ?? 'Relay event', + summary: [operation, operationSummary ?? did ?? kind].filter(Boolean).join(' '), + bytes: new Blob([json]).size, + json + }; +} + +function parseJetstreamEvent(parsed: Record, raw: string, receivedAt: Date): LiveEvent { + const payload = isObject(parsed.payload) ? parsed.payload : parsed; + const nestedCommit = isObject(payload.commit) ? payload.commit : null; + const type = stringValue(payload.$type) ?? stringValue(payload.kind) ?? 'event'; + const kind = type.includes('#') ? (type.split('#').pop() ?? type) : type; + const operation = stringValue(payload.operation) ?? stringValue(nestedCommit?.operation); + const collection = stringValue(payload.collection) ?? stringValue(nestedCommit?.collection); + const did = stringValue(payload.did) ?? stringValue(payload.repo); + const rkey = stringValue(payload.rkey) ?? stringValue(nestedCommit?.rkey); + const time = stringValue(payload.time) ?? microsecondsToIso(payload.time_us) ?? receivedAt.toISOString(); + const subject = [collection, rkey].filter(Boolean).join('/'); + + return { + id: `jetstream-${String(payload.seq ?? payload.cursor ?? receivedAt.getTime())}-${cryptoId()}`, + stream: 'jetstream', + time, + kind, + operation, + did, + collection, + source: collection, + summary: [operation, subject || kind].filter(Boolean).join(' '), + bytes: new Blob([raw]).size, + json: JSON.stringify(parsed, null, 2) + }; +} + +function parseSpacedustEvent(parsed: Record, raw: string, receivedAt: Date): LiveEvent { + const link = isObject(parsed.link) ? parsed.link : {}; + const source = stringValue(link.source); + const sourceRecord = stringValue(link.source_record); + const subject = stringValue(link.subject); + const collection = source?.split(':', 1)[0] ?? null; + const did = didFromAtUri(sourceRecord); + const operation = stringValue(link.operation); + + return { + id: `spacedust-${receivedAt.getTime()}-${cryptoId()}`, + stream: 'spacedust', + time: receivedAt.toISOString(), + kind: stringValue(parsed.kind) ?? 'link', + operation, + did, + collection, + source, + summary: `${source ?? 'link'} → ${subject ?? 'unknown target'}`, + bytes: new Blob([raw]).size, + json: JSON.stringify(parsed, null, 2) + }; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown) { + return typeof value === 'string' ? value : null; +} + +function microsecondsToIso(value: unknown) { + if (typeof value !== 'number') return null; + return new Date(value / 1000).toISOString(); +} + +function didFromAtUri(value: string | null) { + if (!value?.startsWith('at://')) return null; + return value.slice(5).split('/')[0] || null; +} + +function firehoseJsonReplacer(_key: string, value: unknown) { + if (value instanceof Uint8Array) return `[${value.byteLength} bytes]`; + if (typeof value === 'bigint') return value.toString(); + return value; +} + +function cryptoId() { + return typeof crypto === 'undefined' ? Math.random().toString(36).slice(2) : crypto.randomUUID(); +} diff --git a/src/lib/components/GnomeMenuBar.svelte b/src/lib/components/GnomeMenuBar.svelte index 707c53f..a1500e1 100644 --- a/src/lib/components/GnomeMenuBar.svelte +++ b/src/lib/components/GnomeMenuBar.svelte @@ -12,7 +12,7 @@ import GnomeTray from '$lib/components/GnomeTray.svelte'; type RepoPathname = `/repos/${string}`; - type AppPathname = '/' | '/browse' | '/docs' | '/lexicons' | RepoPathname; + type AppPathname = '/' | '/browse' | '/docs' | '/lexicons' | '/live' | RepoPathname; const navigateTo = goto as (url: string, options?: Parameters[1]) => ReturnType; function changeAccount() { @@ -77,6 +77,9 @@ case 'image-viewer': openImageViewer(); return; + case 'system-monitor': + openPath('/live'); + return; case 'web-browser': openPath('/lexicons'); return; @@ -90,7 +93,6 @@ case 'pds-os': window.open(REPO_URL, '_blank', 'noopener,noreferrer'); } - } diff --git a/src/lib/components/SystemMonitor.svelte b/src/lib/components/SystemMonitor.svelte new file mode 100644 index 0000000..1ff66ce --- /dev/null +++ b/src/lib/components/SystemMonitor.svelte @@ -0,0 +1,874 @@ + + +
+ + +
{ + event.preventDefault(); + connect(); + }}> + + + + +
+ {#if status === 'connected' || status === 'connecting'} + + {:else} + + {/if} +
+
+ +
+ + {streamLabels[stream]} + {descriptions[stream]} + {statusDetail} +
+ +
+
+
+
Event rate{formatCount(currentRate)}events/sec
+
Total events{formatCount(totalEvents)}this connection
+
+ {#if stream === 'firehose'} + Decoded detail + {:else} + Data received + {/if} + {formatBytes(totalBytes)} + {#if stream === 'firehose'} + JSON view size + {:else} + payload bytes + {/if} +
+
+ +
+
+

Event Rate History

+ Peak {formatCount(peakRate)} events/sec +
+ + + + + +
+ +
+
+

Live Events

+ Keeping the newest {MAX_EVENTS} +
+
+ + + + {#each events as event (event.id)} + (selectedEventId = event.id)} + onkeydown={(keyboardEvent) => selectEvent(keyboardEvent, event.id)}> + + + + + + {:else} + + {/each} + +
TimeKindRepositoryEvent
{displayTime(event.time)}{event.kind}{event.did ?? '—'}{event.summary}
No events yet. Connect to begin monitoring this stream.
+
+
+
+ + +
+
+ + diff --git a/src/lib/launcher.ts b/src/lib/launcher.ts index b877c38..2a5825d 100644 --- a/src/lib/launcher.ts +++ b/src/lib/launcher.ts @@ -3,6 +3,7 @@ export type DesktopLauncherId = | 'collections' | 'identity-inspector' | 'image-viewer' + | 'system-monitor' | 'web-browser' | 'pds-os' | 'about-computer' @@ -16,6 +17,7 @@ export const desktopLaunchers: DesktopLauncher[] = [ { id: 'collections', label: 'Collections', icon: '/icons/humanity/places/folder.svg' }, { id: 'identity-inspector', label: 'Identity Inspector', icon: '/icons/humanity/apps/identity-inspector.svg' }, { id: 'image-viewer', label: 'Image Viewer', icon: '/icons/humanity/apps/eog.svg' }, + { id: 'system-monitor', label: 'System Monitor', icon: '/icons/humanity/apps/utilities-system-monitor.svg' }, { id: 'web-browser', label: 'Web Browser', icon: '/icons/humanity/apps/web-browser.svg' }, { id: 'pds-os', label: 'PDS OS', icon: '/logos/apple-classic.svg', href: 'https://pdsos.wisp.place/' }, { id: 'about-computer', label: 'Computer', icon: '/icons/humanity/devices/computer.svg' }, @@ -27,6 +29,7 @@ const leftDesktopLauncherIds = new Set(['home', 'collections' const rightDesktopLauncherIds = new Set([ 'identity-inspector', 'image-viewer', + 'system-monitor', 'web-browser', 'pds-os', 'document-viewer' diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d6e2fa4..158cffe 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -47,6 +47,7 @@ // TODO: this should be slug aware if (page.route.id?.startsWith('/docs')) return 'Document Viewer'; if (page.route.id?.startsWith('/lexicons')) return 'Web Browser'; + if (page.route.id?.startsWith('/live')) return 'System Monitor'; return 'AT Protocol Collections - Intrepid Ibex'; }); @@ -55,6 +56,7 @@ if (page.route.id === '/') return '/icons/humanity/devices/computer.svg'; if (page.route.id?.startsWith('/docs')) return '/icons/humanity/mimes/gnome-mime-application-pdf.svg'; if (page.route.id?.startsWith('/lexicons')) return '/icons/humanity/apps/web-browser.svg'; + if (page.route.id?.startsWith('/live')) return '/icons/humanity/apps/utilities-system-monitor.svg'; return '/icons/humanity/apps/internet-feed-reader.svg'; }); @@ -187,7 +189,12 @@ } function closeMainWindow() { - if (page.route.id === '/browse' || page.route.id?.startsWith('/repos') || page.route.id?.startsWith('/lexicons')) { + if ( + page.route.id === '/browse' || + page.route.id?.startsWith('/repos') || + page.route.id?.startsWith('/lexicons') || + page.route.id?.startsWith('/live') + ) { void goto(resolve('/'), { keepFocus: true, noScroll: true }); } } @@ -197,72 +204,78 @@ } function activateLauncher(id: DesktopLauncherId) { - if (id === 'home') { - windowManager.restore('main'); - void goto(resolve('/')); - return; - } - - if (id === 'collections') { - windowManager.restore('main'); - void goto(resolve('/browse')); - return; - } - - if (id === 'identity-inspector') { - if (repoIdentity) { - void goto(resolve(`/repos/${repoIdentity.did}/identity`), { keepFocus: true, noScroll: true }); + switch (id) { + case 'home': + windowManager.restore('main'); + void goto(resolve('/')); return; - } - - windowManager.restore('main'); - void goto(resolve('/browse')); - return; - } - - if (id === 'image-viewer') { - if (repoIdentity) { - void goto(resolve(`/repos/${repoIdentity.did}/blobs`), { keepFocus: true, noScroll: true }); + case 'collections': + windowManager.restore('main'); + void goto(resolve('/browse')); return; - } - - windowManager.restore('main'); - void goto(resolve('/browse')); - return; - } - - if (id === 'web-browser') { - windowManager.restore('main'); - void goto(resolve('/lexicons')); - return; - } - - if (id === 'about-computer') { - windowManager.open('about-computer'); - return; - } - - if (id === 'document-viewer') { - windowManager.restore('main'); - void goto(resolve('/docs')); - return; + case 'identity-inspector': + if (repoIdentity) { + void goto(resolve(`/repos/${repoIdentity.did}/identity`), { keepFocus: true, noScroll: true }); + return; + } + + windowManager.restore('main'); + void goto(resolve('/browse')); + return; + case 'image-viewer': + if (repoIdentity) { + void goto(resolve(`/repos/${repoIdentity.did}/blobs`), { keepFocus: true, noScroll: true }); + return; + } + + windowManager.restore('main'); + void goto(resolve('/browse')); + return; + case 'system-monitor': + windowManager.restore('main'); + void goto(resolve('/live')); + return; + case 'web-browser': + windowManager.restore('main'); + void goto(resolve('/lexicons')); + return; + case 'about-computer': + windowManager.open('about-computer'); + return; + case 'document-viewer': + windowManager.restore('main'); + void goto(resolve('/docs')); + return; + case 'pds-os': + case 'trash': + window.open('https://github.com/desertthunder/ibex', '_blank', 'noopener,noreferrer'); } - - window.open('https://github.com/desertthunder/ibex', '_blank', 'noopener,noreferrer'); } function isLauncherSelected(id: DesktopLauncherId) { - if (id === 'home') return page.route.id === '/'; - if (id === 'collections') return page.route.id === '/browse' || page.route.id?.startsWith('/repos'); - if (id === 'identity-inspector') return identityInspectorWindow?.isOpen && !identityInspectorWindow.isMinimized; - if (id === 'image-viewer') return eogWindow?.isOpen && !eogWindow.isMinimized; - if (id === 'web-browser') return page.route.id?.startsWith('/lexicons'); - if (id === 'about-computer') return showAboutComputer; - if (id === 'document-viewer') { - return page.route.id?.startsWith('/docs') || (documentViewerWindow?.isOpen && !documentViewerWindow.isMinimized); + switch (id) { + case 'home': + return page.route.id === '/'; + case 'collections': + return page.route.id === '/browse' || page.route.id?.startsWith('/repos'); + case 'identity-inspector': + return identityInspectorWindow?.isOpen && !identityInspectorWindow.isMinimized; + case 'image-viewer': + return eogWindow?.isOpen && !eogWindow.isMinimized; + case 'system-monitor': + return page.route.id?.startsWith('/live'); + case 'web-browser': + return page.route.id?.startsWith('/lexicons'); + case 'about-computer': + return showAboutComputer; + case 'document-viewer': + return ( + page.route.id?.startsWith('/docs') || (documentViewerWindow?.isOpen && !documentViewerWindow.isMinimized) + ); + case 'pds-os': + case 'trash': + return false; } - - return false; } function setMainWindowTitle() { diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte new file mode 100644 index 0000000..3dc3f39 --- /dev/null +++ b/src/routes/live/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/routes/live/firehose/+page.svelte b/src/routes/live/firehose/+page.svelte new file mode 100644 index 0000000..3c6d945 --- /dev/null +++ b/src/routes/live/firehose/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/routes/live/jetstream/+page.svelte b/src/routes/live/jetstream/+page.svelte new file mode 100644 index 0000000..3dc3f39 --- /dev/null +++ b/src/routes/live/jetstream/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/routes/live/spacedust/+page.svelte b/src/routes/live/spacedust/+page.svelte new file mode 100644 index 0000000..5c2e920 --- /dev/null +++ b/src/routes/live/spacedust/+page.svelte @@ -0,0 +1,5 @@ + + + -- knotmirror 0.0.1