diff --git a/Cargo.lock b/Cargo.lock index a885adcbb7..74ea9de375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "librad" version = "0.1.0" -source = "git+https://github.com/radicle-dev/radicle-link.git?rev=49562b86e14889bbf8f96d846d46d94bc32468c0#49562b86e14889bbf8f96d846d46d94bc32468c0" +source = "git+https://github.com/radicle-dev/radicle-link.git?rev=f9fa8303cd33096d7818f0dcb0a24d3a9f618fab#f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" dependencies = [ "async-stream", "async-trait", @@ -2024,8 +2024,9 @@ dependencies = [ [[package]] name = "radicle-daemon" version = "0.1.0" -source = "git+https://github.com/radicle-dev/radicle-link.git?rev=49562b86e14889bbf8f96d846d46d94bc32468c0#49562b86e14889bbf8f96d846d46d94bc32468c0" +source = "git+https://github.com/radicle-dev/radicle-link.git?rev=f9fa8303cd33096d7818f0dcb0a24d3a9f618fab#f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" dependencies = [ + "anyhow", "async-stream", "either", "futures 0.3.14", @@ -2047,7 +2048,7 @@ dependencies = [ [[package]] name = "radicle-data" version = "0.1.0" -source = "git+https://github.com/radicle-dev/radicle-link.git?rev=49562b86e14889bbf8f96d846d46d94bc32468c0#49562b86e14889bbf8f96d846d46d94bc32468c0" +source = "git+https://github.com/radicle-dev/radicle-link.git?rev=f9fa8303cd33096d7818f0dcb0a24d3a9f618fab#f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" dependencies = [ "minicbor", "nonempty 0.6.0", @@ -2058,7 +2059,7 @@ dependencies = [ [[package]] name = "radicle-git-ext" version = "0.1.0" -source = "git+https://github.com/radicle-dev/radicle-link.git?rev=49562b86e14889bbf8f96d846d46d94bc32468c0#49562b86e14889bbf8f96d846d46d94bc32468c0" +source = "git+https://github.com/radicle-dev/radicle-link.git?rev=f9fa8303cd33096d7818f0dcb0a24d3a9f618fab#f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" dependencies = [ "git2", "minicbor", @@ -2073,7 +2074,7 @@ dependencies = [ [[package]] name = "radicle-git-helpers" version = "0.1.0" -source = "git+https://github.com/radicle-dev/radicle-link.git?rev=49562b86e14889bbf8f96d846d46d94bc32468c0#49562b86e14889bbf8f96d846d46d94bc32468c0" +source = "git+https://github.com/radicle-dev/radicle-link.git?rev=f9fa8303cd33096d7818f0dcb0a24d3a9f618fab#f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" dependencies = [ "anyhow", "git2", @@ -2106,7 +2107,7 @@ dependencies = [ [[package]] name = "radicle-macros" version = "0.1.0" -source = "git+https://github.com/radicle-dev/radicle-link.git?rev=49562b86e14889bbf8f96d846d46d94bc32468c0#49562b86e14889bbf8f96d846d46d94bc32468c0" +source = "git+https://github.com/radicle-dev/radicle-link.git?rev=f9fa8303cd33096d7818f0dcb0a24d3a9f618fab#f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" dependencies = [ "quote", "radicle-git-ext", @@ -2132,7 +2133,7 @@ dependencies = [ [[package]] name = "radicle-std-ext" version = "0.1.0" -source = "git+https://github.com/radicle-dev/radicle-link.git?rev=49562b86e14889bbf8f96d846d46d94bc32468c0#49562b86e14889bbf8f96d846d46d94bc32468c0" +source = "git+https://github.com/radicle-dev/radicle-link.git?rev=f9fa8303cd33096d7818f0dcb0a24d3a9f618fab#f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" [[package]] name = "radicle-surf" diff --git a/package.json b/package.json index 19f47f8359..4cd044c17e 100644 --- a/package.json +++ b/package.json @@ -183,6 +183,7 @@ "radicle-contracts": "github:radicle-dev/radicle-contracts#commit=752cf0767c6ba7428c626abf91ae1874de613f26", "semver": "^7.3.5", "stream-browserify": "^3.0.0", + "svelte-json-tree": "^0.1.0", "svelte-persistent-store": "^0.1.6", "timeago.js": "^4.0.2", "twemoji": "13.0.2", diff --git a/proxy/api/Cargo.toml b/proxy/api/Cargo.toml index e2a856723d..10dbfcd071 100644 --- a/proxy/api/Cargo.toml +++ b/proxy/api/Cargo.toml @@ -39,11 +39,11 @@ warp = { version = "0.3", default-features = false } [dependencies.radicle-daemon] git = "https://github.com/radicle-dev/radicle-link.git" -rev = "49562b86e14889bbf8f96d846d46d94bc32468c0" +rev = "f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" [dependencies.radicle-git-ext] git = "https://github.com/radicle-dev/radicle-link.git" -rev = "49562b86e14889bbf8f96d846d46d94bc32468c0" +rev = "f9fa8303cd33096d7818f0dcb0a24d3a9f618fab" [dependencies.radicle-avatar] git = "https://github.com/radicle-dev/radicle-avatar.git" diff --git a/proxy/api/src/notification.rs b/proxy/api/src/notification.rs index 19a99dd930..dc9687e34f 100644 --- a/proxy/api/src/notification.rs +++ b/proxy/api/src/notification.rs @@ -1,11 +1,14 @@ //! Machinery to signal significant events to clients. +use radicle_daemon::request::{RequestState, SomeRequest, Status as PeerRequestStatus}; +use radicle_git_ext::Oid; use std::{ collections::HashMap, sync::{ atomic::{AtomicUsize, Ordering}, Arc, }, + time::SystemTime, }; use serde::Serialize; @@ -63,6 +66,12 @@ pub enum LocalPeer { /// The new [`PeerStatus`]. new: PeerStatus, }, + WaitingRoomTransition { + event: radicle_daemon::peer::WaitingRoomEvent, + state_before: SerializableWaitingRoomState, + state_after: SerializableWaitingRoomState, + timestamp: u128, + }, } #[allow(clippy::wildcard_enum_match_arm)] @@ -90,6 +99,18 @@ impl MaybeFrom for Notification { PeerEvent::StatusChanged { old, new } => { Some(Self::LocalPeer(LocalPeer::StatusChanged { old, new })) }, + PeerEvent::WaitingRoomTransition(t) => { + let since_the_epoch = t + .timestamp + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards"); + Some(Self::LocalPeer(LocalPeer::WaitingRoomTransition { + event: t.event, + state_before: t.state_before.into(), + state_after: t.state_after.into(), + timestamp: since_the_epoch.as_millis(), + })) + }, _ => None, } } @@ -129,3 +150,33 @@ impl Subscriptions { receiver } } + +#[derive(Clone, Debug, Serialize)] +pub struct SerializableWaitingRoomState(HashMap); + +#[derive(Debug, Clone, Serialize)] +pub struct SerializedRequestState { + state: String, + peers: HashMap, +} + +impl From>> for SerializableWaitingRoomState { + fn from(raw: HashMap>) -> Self { + let inner: HashMap = raw + .iter() + .map(|(urn, request)| { + ( + urn.to_string(), + SerializedRequestState { + state: RequestState::from(request).to_string(), + peers: request + .peers() + .cloned() + .unwrap_or_else(std::collections::HashMap::new), + }, + ) + }) + .collect(); + Self(inner) + } +} diff --git a/tsconfig.json b/tsconfig.json index a7de600754..b5ee09d771 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -71,7 +71,8 @@ "baseUrl": "./", /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ "paths": { - "ui/*": ["ui/*"] + "ui/*": ["ui/*"], + "*": ["./typings/*"] }, /* List of root folders whose combined content represents the structure of the project at runtime. */ // "rootDirs": [], diff --git a/typings/svelte-json-tree/index.d.ts b/typings/svelte-json-tree/index.d.ts new file mode 100644 index 0000000000..10540ea518 --- /dev/null +++ b/typings/svelte-json-tree/index.d.ts @@ -0,0 +1,8 @@ +declare module "svelte-json-tree" { + import type { SvelteComponentTyped } from "svelte"; + interface Props { + key?: string; + value: any; // eslint-disable-line @typescript-eslint/no-explicit-any + } + export default class JSONTree extends SvelteComponentTyped {} +} diff --git a/ui/App.svelte b/ui/App.svelte index cebd227a1a..7be91117ae 100644 --- a/ui/App.svelte +++ b/ui/App.svelte @@ -32,6 +32,7 @@ import Project from "./Screen/Project.svelte"; import Settings from "./Screen/Settings.svelte"; import UserProfile from "./Screen/UserProfile.svelte"; + import NetworkDiagnostics from "./Screen/NetworkDiagnostics.svelte"; const routes = { "/": Blank, @@ -44,6 +45,7 @@ "/user/:urn": UserProfile, "/user/:urn/*": UserProfile, "/design-system-guide": DesignSystemGuide, + "/network-diagnostics/*": NetworkDiagnostics, "*": NotFound, }; diff --git a/ui/DesignSystem/Component/ConnectionStatusIndicator.svelte b/ui/DesignSystem/Component/ConnectionStatusIndicator.svelte index 9a0263bd66..ee2b7af22c 100644 --- a/ui/DesignSystem/Component/ConnectionStatusIndicator.svelte +++ b/ui/DesignSystem/Component/ConnectionStatusIndicator.svelte @@ -7,6 +7,11 @@ import Syncing from "./ConnectionStatusIndicator/Syncing.svelte"; import Offline from "./ConnectionStatusIndicator/Offline.svelte"; + const connectedPeerCount = (peers: { [peerId: string]: string[] }) => { + const count = Object.keys(peers).length; + peerCount(count); + }; + const peerCount = (count: number) => { if (count === 1) { return "1 peer"; @@ -31,7 +36,10 @@
{#if $status.type === StatusType.Online} - +
diff --git a/ui/Hotkeys.svelte b/ui/Hotkeys.svelte index 0abeba41a1..1756b67718 100644 --- a/ui/Hotkeys.svelte +++ b/ui/Hotkeys.svelte @@ -83,6 +83,9 @@ case hotkeys.ShortcutKey.NewProjects: toggleModal(ModalNewProject); break; + case hotkeys.ShortcutKey.NetworkDiagnostics: + toggle(path.networkDiagnosticsConnectedPeers()); + break; } }; diff --git a/ui/Screen/NetworkDiagnostics.svelte b/ui/Screen/NetworkDiagnostics.svelte new file mode 100644 index 0000000000..17acc8f4a0 --- /dev/null +++ b/ui/Screen/NetworkDiagnostics.svelte @@ -0,0 +1,61 @@ + + + + + +
+
+

Status: {$store.type}

+
+
+ + +
+ +
+
+ + +
diff --git a/ui/Screen/NetworkDiagnostics/ConnectedPeers.svelte b/ui/Screen/NetworkDiagnostics/ConnectedPeers.svelte new file mode 100644 index 0000000000..6a5d239dec --- /dev/null +++ b/ui/Screen/NetworkDiagnostics/ConnectedPeers.svelte @@ -0,0 +1,47 @@ + + + + +{#if isOnline} +
+

Connected Peers

+ + + + + + + + + {#each Object.keys(connectedPeers).sort() as peerId} + + + + + {/each} + +
PeerConnections
{peerId} +
    + {#each connectedPeers[peerId].sort() as address} +
  • {address}
  • + {/each} +
+
+
+{/if} diff --git a/ui/Screen/NetworkDiagnostics/StateTable.svelte b/ui/Screen/NetworkDiagnostics/StateTable.svelte new file mode 100644 index 0000000000..ff819ca892 --- /dev/null +++ b/ui/Screen/NetworkDiagnostics/StateTable.svelte @@ -0,0 +1,43 @@ + + + + + + + + + + + + {#each Object.keys(state).sort() as urn} + + + + + + {/each} + +
UrnStatePeer States
+ + {state[urn].state} + + + {#each Object.keys(state[urn].peers).sort() as peerId} + + + + + {/each} + +
+ + {JSON.stringify(state[urn].peers[peerId])}
+
diff --git a/ui/Screen/NetworkDiagnostics/WaitingRoom.svelte b/ui/Screen/NetworkDiagnostics/WaitingRoom.svelte new file mode 100644 index 0000000000..35f4ba156b --- /dev/null +++ b/ui/Screen/NetworkDiagnostics/WaitingRoom.svelte @@ -0,0 +1,58 @@ + + + + +
+

Last known waiting room state

+ {#if $waitingRoomState} + + {:else} +
None
+ {/if} + + + + + + + + + + + {#each $waitingRoomEventLog as transition} + + + + + + + {/each} + +
EventTimestampState BeforeState After
+ + {new Date(transition.timestamp).toISOString()} + + + +
+
diff --git a/ui/src/hotkeys.ts b/ui/src/hotkeys.ts index 1bd0bb3a98..267fc24818 100644 --- a/ui/src/hotkeys.ts +++ b/ui/src/hotkeys.ts @@ -23,6 +23,7 @@ export enum ShortcutKey { NewProjects = "n", Search = "p", Settings = ",", + NetworkDiagnostics = "i", } export interface KeyboardShortcut { @@ -49,6 +50,11 @@ export const devShortcuts: KeyboardShortcut[] = [ key: ShortcutKey.DesignSystem, modifierKey: true, }, + { + description: "Network diagnostics", + key: ShortcutKey.NetworkDiagnostics, + modifierKey: true, + }, ]; export const escape: KeyboardShortcut = { diff --git a/ui/src/localPeer.ts b/ui/src/localPeer.ts index 661e324faa..e7eedbca0a 100644 --- a/ui/src/localPeer.ts +++ b/ui/src/localPeer.ts @@ -1,4 +1,6 @@ import { push } from "svelte-spa-router"; +import type { Readable, Writable } from "svelte/store"; +import { writable } from "svelte/store"; import * as zod from "zod"; import * as svelteStore from "svelte/store"; @@ -11,6 +13,8 @@ import * as session from "./session"; import type * as urn from "./urn"; import * as error from "./error"; import * as bacon from "./bacon"; +import type { Event as RoomEvent, RoomState } from "./waitingRoom"; +import { eventSchema as roomEventSchema, roomStateSchema } from "./waitingRoom"; // TYPES export enum StatusType { @@ -40,10 +44,10 @@ interface Syncing { interface Online { type: StatusType.Online; - connected: number; + connectedPeers: { [peerId: string]: string[] }; } -type Status = Stopped | Offline | Started | Syncing | Online; +export type Status = Stopped | Offline | Started | Syncing | Online; const statusSchema = zod.union([ zod.object({ @@ -61,7 +65,7 @@ const statusSchema = zod.union([ }), zod.object({ type: zod.literal(StatusType.Online), - connected: zod.number(), + connectedPeers: zod.record(zod.array(zod.string())), }), ]); @@ -72,6 +76,7 @@ enum EventType { RequestCloned = "requestCloned", RequestTimedOut = "requestTimedOut", StatusChanged = "statusChanged", + WaitingRoomTransition = "waitingRoomTransition", } interface ProjectUpdated { @@ -101,6 +106,14 @@ interface RequestTimedOut { urn: urn.Urn; } +export interface WaitingRoomTransition { + type: EventType.WaitingRoomTransition; + event: RoomEvent; + timestamp: number; + state_before: RoomState; + state_after: RoomState; +} + type RequestEvent = | RequestCreated | RequestCloned @@ -110,6 +123,7 @@ type RequestEvent = export type Event = | ProjectUpdated | RequestEvent + | WaitingRoomTransition | { type: EventType.StatusChanged; old: Status; new: Status }; const eventSchema: zod.Schema = zod.union([ @@ -135,6 +149,13 @@ const eventSchema: zod.Schema = zod.union([ type: zod.literal(EventType.RequestTimedOut), urn: zod.string(), }), + zod.object({ + type: zod.literal(EventType.WaitingRoomTransition), + timestamp: zod.number(), + state_before: roomStateSchema, + state_after: roomStateSchema, + event: roomEventSchema, + }), zod.object({ type: zod.literal(EventType.StatusChanged), old: statusSchema, @@ -165,6 +186,7 @@ session.session.subscribe(sess => { code: error.Code.ProxyEventParseFailure, message: "Failed to parse proxy event", details: { + event: data, errors: result.error.errors, }, }) @@ -228,8 +250,37 @@ export const status = svelteStore.writable({ type: StatusType.Offline, }); +const internalWaitingRoomState = svelteStore.writable(null); +export const waitingRoomState: Readable = + internalWaitingRoomState; + eventBus.onValue(event => { if (event.type === EventType.StatusChanged) { status.set(event.new); } + if (event.type === EventType.WaitingRoomTransition) { + internalWaitingRoomState.set(event.state_after); + } }); + +const waitingRoomEvents: bacon.Property = bacon + .filterMap(eventBus, event => { + if ( + event.type === EventType.WaitingRoomTransition && + event.event.type !== "tick" + ) { + return event; + } else { + return undefined; + } + }) + .scan([], (acc, event) => + [...acc, event].slice(-200) + ); + +const waitingRoomEventLogStore: Writable = writable( + [] +); +waitingRoomEvents.onValue(events => waitingRoomEventLogStore.set(events)); +export const waitingRoomEventLog: Readable = + waitingRoomEventLogStore; diff --git a/ui/src/path.ts b/ui/src/path.ts index f37efa7717..85389c5819 100644 --- a/ui/src/path.ts +++ b/ui/src/path.ts @@ -25,3 +25,8 @@ export const projectSourceCommits = (urn: Urn): string => export const project = projectSourceFiles; export const designSystemGuide = (): string => "/design-system-guide"; + +export const networkDiagnosticsConnectedPeers = (): string => + "/network-diagnostics/connected-peers"; +export const networkDiagnosticsWaitingRoom = (): string => + "/network-diagnostics/waiting-room"; diff --git a/ui/src/waitingRoom.ts b/ui/src/waitingRoom.ts new file mode 100644 index 0000000000..09508282c4 --- /dev/null +++ b/ui/src/waitingRoom.ts @@ -0,0 +1,143 @@ +// Client representation of proxy waiting room requests, subscriptions, etc. +import * as zod from "zod"; + +export enum Status { + Created = "created", + Requested = "requested", + Found = "found", + Cloning = "cloning", + Cloned = "cloned", + Cancelled = "cancelled", + Failed = "failed", + TimedOut = "timedOut", +} + +export interface ProjectRequest { + type: Status; + urn: string; +} + +export enum PeerStatus { + Available = "available", + InProgress = "inProgress", + Failed = "failed", +} + +interface TickEvent { + type: "tick"; +} +interface CreatedEvent { + type: "created"; +} +interface QueriedEvent { + type: "queried"; +} +interface FoundEvent { + type: "found"; +} +interface CloningEvent { + type: "cloning"; +} +interface CloningFailedEvent { + type: "cloningFailed"; +} +interface ClonedEvent { + type: "cloned"; +} +interface CanceledEvent { + type: "canceled"; +} +export type Event = + | TickEvent + | CreatedEvent + | QueriedEvent + | FoundEvent + | CloningEvent + | CloningFailedEvent + | ClonedEvent + | CanceledEvent; + +export const eventSchema: zod.Schema = zod.union([ + zod.object({ + type: zod.literal("tick"), + }), + zod.object({ + type: zod.literal("created"), + urn: zod.string(), + }), + zod.object({ + type: zod.literal("queried"), + urn: zod.string(), + }), + zod.object({ + type: zod.literal("found"), + urn: zod.string(), + peer: zod.string(), + }), + zod.object({ + type: zod.literal("cloning"), + urn: zod.string(), + peer: zod.string(), + }), + zod.object({ + type: zod.literal("cloningFailed"), + urn: zod.string(), + peer: zod.string(), + reason: zod.string(), + }), + zod.object({ + type: zod.literal("cloned"), + urn: zod.string(), + peer: zod.string(), + }), + zod.object({ + type: zod.literal("canceled"), + urn: zod.string(), + }), +]); + +export interface RoomState { + [revision: string]: { + state: + | "Created" + | "Requested" + | "Found" + | "Cloning" + | "Cloned" + | "Cancelled" + | "Failed" + | "TimedOut"; + peers: { + [peerId: string]: + | "available" + | "inProgress" + | { failed: { reason: string } }; + }; + }; +} + +export const roomStateSchema: zod.Schema = zod.record( + zod.object({ + state: zod.union([ + zod.literal("Created"), + zod.literal("Requested"), + zod.literal("Found"), + zod.literal("Cloning"), + zod.literal("Cloned"), + zod.literal("Cancelled"), + zod.literal("Failed"), + zod.literal("TimedOut"), + ]), + peers: zod.record( + zod.union([ + zod.literal("available"), + zod.literal("inProgress"), + zod.object({ + failed: zod.object({ + reason: zod.string(), + }), + }), + ]) + ), + }) +); diff --git a/webpack.config.ts b/webpack.config.ts index 0bde61a905..7b8c834fcf 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -91,6 +91,14 @@ function ui(_env: unknown, argv: Argv): webpack.Configuration { extensions: [".svelte", ".ts", ".js"], }), ], + // This is neccessary to prevent multiple versions of the svelte runtime + // being bundled when depending on libraries containing svelte + // components. + // See https://github.com/sveltejs/svelte-loader#resolvealias + alias: { + svelte: path.resolve("node_modules", "svelte"), + }, + mainFields: ["svelte", "browser", "module", "main"], }, output: { filename: "bundle.js", diff --git a/yarn.lock b/yarn.lock index 10ba056d97..d24fd9dc10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10532,6 +10532,7 @@ __metadata: stream-browserify: ^3.0.0 svelte: ^3.38.2 svelte-check: ^1.5.2 + svelte-json-tree: ^0.1.0 svelte-loader: ^3.1.1 svelte-persistent-store: ^0.1.6 svelte-preprocess: ^4.7.3 @@ -12195,6 +12196,13 @@ __metadata: languageName: node linkType: hard +"svelte-json-tree@npm:^0.1.0": + version: 0.1.0 + resolution: "svelte-json-tree@npm:0.1.0" + checksum: 7bdf3f097be4f68285deea0dc62cef71ac42ff48afe9e1623289b91402686c6d1e20e30af2f31721752c58105ebfbcf769246262c1d00d8841ae0398cef11def + languageName: node + linkType: hard + "svelte-loader@npm:^3.1.1": version: 3.1.1 resolution: "svelte-loader@npm:3.1.1"