record relay response time

This commit is contained in:
hzrd149
2023-03-05 11:38:05 -06:00
parent 4bd04eb644
commit fa98fa6fda
7 changed files with 216 additions and 39 deletions

View File

@@ -1,3 +1,4 @@
import relayScoreboardService from "../services/relay-scoreboard";
import { RawIncomingNostrEvent, NostrEvent } from "../types/nostr-event"; import { RawIncomingNostrEvent, NostrEvent } from "../types/nostr-event";
import { NostrOutgoingMessage } from "../types/nostr-query"; import { NostrOutgoingMessage } from "../types/nostr-query";
import { Subject } from "./subject"; import { Subject } from "./subject";
@@ -46,7 +47,9 @@ export class Relay {
ws?: WebSocket; ws?: WebSocket;
mode: RelayMode = RelayMode.ALL; mode: RelayMode = RelayMode.ALL;
private intentionalClose = false;
private queue: NostrOutgoingMessage[] = []; private queue: NostrOutgoingMessage[] = [];
private subscriptionStartTime = new Map<string, Date>();
constructor(url: string, mode: RelayMode = RelayMode.ALL) { constructor(url: string, mode: RelayMode = RelayMode.ALL) {
this.url = url; this.url = url;
@@ -55,6 +58,7 @@ export class Relay {
open() { open() {
if (this.okay) return; if (this.okay) return;
this.intentionalClose = false;
this.ws = new WebSocket(this.url); this.ws = new WebSocket(this.url);
this.ws.onopen = () => { this.ws.onopen = () => {
@@ -69,6 +73,8 @@ export class Relay {
this.ws.onclose = () => { this.ws.onclose = () => {
this.onClose.next(this); this.onClose.next(this);
if (!this.intentionalClose) relayScoreboardService.submitDisconnect(this.url);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.info(`Relay: ${this.url} disconnected`); console.info(`Relay: ${this.url} disconnected`);
} }
@@ -79,11 +85,29 @@ export class Relay {
if (this.mode & RelayMode.WRITE) { if (this.mode & RelayMode.WRITE) {
if (this.connected) { if (this.connected) {
this.ws?.send(JSON.stringify(json)); this.ws?.send(JSON.stringify(json));
// record start time
if (json[0] === "REQ") {
this.subStartMeasure(json[1]);
}
} else this.queue.push(json); } else this.queue.push(json);
} }
} }
close() { close() {
this.ws?.close(); this.ws?.close();
this.intentionalClose = true;
this.subscriptionStartTime.clear();
}
private subStartMeasure(sub: string) {
this.subscriptionStartTime.set(sub, new Date());
}
private subEndMeasure(sub: string) {
const date = this.subscriptionStartTime.get(sub);
if (date) {
relayScoreboardService.submitResponseTime(this.url, new Date().valueOf() - date.valueOf());
this.subscriptionStartTime.delete(sub);
}
} }
private sendQueued() { private sendQueued() {
@@ -130,12 +154,14 @@ export class Relay {
switch (type) { switch (type) {
case "EVENT": case "EVENT":
this.onEvent.next({ relay: this, type, subId: data[1], body: data[2] }); this.onEvent.next({ relay: this, type, subId: data[1], body: data[2] });
this.subEndMeasure(data[1]);
break; break;
case "NOTICE": case "NOTICE":
this.onNotice.next({ relay: this, type, message: data[1] }); this.onNotice.next({ relay: this, type, message: data[1] });
break; break;
case "EOSE": case "EOSE":
this.onEOSE.next({ relay: this, type, subId: data[1] }); this.onEOSE.next({ relay: this, type, subId: data[1] });
this.subEndMeasure(data[1]);
break; break;
case "OK": case "OK":
this.onCommandResult.next({ relay: this, type, eventId: data[1], status: data[2], message: data[3] }); this.onCommandResult.next({ relay: this, type, eventId: data[1], status: data[2], message: data[3] });

17
src/helpers/url.ts Normal file
View File

@@ -0,0 +1,17 @@
import { utils } from "nostr-tools";
export function validateRelayUrl(relayUrl: string) {
const normalized = utils.normalizeURL(relayUrl);
const url = new URL(normalized);
if (url.protocol !== "wss:" && url.protocol !== "ws:") throw new Error("Incorrect protocol");
return url.toString();
}
export function safeRelayUrl(relayUrl: string) {
try {
return validateRelayUrl(relayUrl);
} catch (e) {}
return null;
}

View File

@@ -42,6 +42,7 @@ const MIGRATIONS: MigrationFunction[] = [
db.createObjectStore("settings"); db.createObjectStore("settings");
db.createObjectStore("relayInfo"); db.createObjectStore("relayInfo");
db.createObjectStore("relayScoreboardStats", { keyPath: "relay" });
db.createObjectStore("accounts", { keyPath: "pubkey" }); db.createObjectStore("accounts", { keyPath: "pubkey" });
}, },
]; ];

View File

@@ -35,6 +35,10 @@ export interface CustomSchema extends DBSchema {
value: { pubkey: string; relays: Record<string, number>; updated: number }; value: { pubkey: string; relays: Record<string, number>; updated: number };
indexes: { pubkey: string }; indexes: { pubkey: string };
}; };
relayScoreboardStats: {
key: string;
value: { relay: string; responseTimes: [number, Date][]; disconnects: Date[] };
};
settings: { settings: {
key: string; key: string;
value: any; value: any;

View File

@@ -0,0 +1,115 @@
import moment from "moment";
import { SuperMap } from "../classes/super-map";
import db from "./db";
class RelayScoreboardService {
private relays = new Set<string>();
private relayResponseTimes = new SuperMap<string, [number, Date][]>(() => []);
private relayDisconnects = new SuperMap<string, Date[]>(() => []);
submitResponseTime(relay: string, time: number) {
this.relays.add(relay);
const arr = this.relayResponseTimes.get(relay);
arr.unshift([time, new Date()]);
}
submitDisconnect(relay: string) {
this.relays.add(relay);
const arr = this.relayDisconnects.get(relay);
arr.unshift(new Date());
}
pruneResponseTimes() {
const cutOff = moment().subtract(1, "week").toDate();
for (const [relay, arr] of this.relayResponseTimes) {
while (true) {
const lastResponse = arr.pop();
if (!lastResponse) break;
if (lastResponse[1] >= cutOff) {
arr.push(lastResponse);
break;
}
}
}
}
pruneResponseDisconnects() {
const cutOff = moment().subtract(1, "week").toDate();
for (const [relay, arr] of this.relayDisconnects) {
while (true) {
const lastDisconnect = arr.pop();
if (!lastDisconnect) break;
if (lastDisconnect >= cutOff) {
arr.push(lastDisconnect);
break;
}
}
}
}
getAverageResponseTime(relay: string) {
const times = this.relayResponseTimes.get(relay);
// TODO: not sure if this is a good fix for keeping unconnected relays from the top of the list
if (times.length === 0) return 200;
const total = times.reduce((total, [time]) => total + time, 0);
return total / times.length;
}
getDisconnects(relay: string) {
return this.relayDisconnects.get(relay).length;
}
getRankedRelays(customRelays?: string[]) {
const relays = customRelays ?? Array.from(this.relays);
const relayAverageResponseTimes = new SuperMap<string, number>(() => 0);
const relayDisconnects = new SuperMap<string, number>(() => 0);
for (const relay of relays) {
const averageResponseTime = this.getAverageResponseTime(relay);
const disconnectTimes = this.relayDisconnects.get(relay).length;
relayAverageResponseTimes.set(relay, averageResponseTime);
relayDisconnects.set(relay, disconnectTimes);
}
return relays.sort((a, b) => {
let diff = 0;
diff += Math.sign(relayAverageResponseTimes.get(a) - relayAverageResponseTimes.get(b));
diff += Math.sign(relayDisconnects.get(a) - relayDisconnects.get(b));
return diff;
});
}
async loadStats() {
const stats = await db.getAll("relayScoreboardStats");
for (const relayStats of stats) {
this.relays.add(relayStats.relay);
this.relayResponseTimes.set(relayStats.relay, relayStats.responseTimes);
this.relayDisconnects.set(relayStats.relay, relayStats.disconnects);
}
}
async saveStats() {
const transaction = db.transaction("relayScoreboardStats", "readwrite");
for (const relay of this.relays) {
const responseTimes = this.relayResponseTimes.get(relay);
const disconnects = this.relayDisconnects.get(relay);
transaction.store.put({ relay, responseTimes, disconnects });
}
await transaction.done;
}
}
const relayScoreboardService = new RelayScoreboardService();
relayScoreboardService.loadStats().then(() => {
console.log("Loaded relay scoreboard stats");
});
setInterval(() => {
relayScoreboardService.saveStats();
}, 1000 * 5);
if (import.meta.env.DEV) {
// @ts-ignore
window.relayScoreboardService = relayScoreboardService;
}
export default relayScoreboardService;

View File

@@ -13,6 +13,7 @@ import {
IconButton, IconButton,
Text, Text,
Badge, Badge,
useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { SyntheticEvent, useEffect, useState } from "react"; import { SyntheticEvent, useEffect, useState } from "react";
import { TrashIcon, UndoIcon } from "../../components/icons"; import { TrashIcon, UndoIcon } from "../../components/icons";
@@ -23,9 +24,12 @@ import { useList } from "react-use";
import { RelayUrlInput } from "../../components/relay-url-input"; import { RelayUrlInput } from "../../components/relay-url-input";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import { RelayStatus } from "../../components/relay-status"; import { RelayStatus } from "../../components/relay-status";
import relayScoreboardService from "../../services/relay-scoreboard";
import { validateRelayUrl } from "../../helpers/url";
export const RelaysView = () => { export const RelaysView = () => {
const relays = useSubject(clientRelaysService.relays); const relays = useSubject(clientRelaysService.relays);
const toast = useToast();
const [pendingAdd, addActions] = useList<RelayConfig>([]); const [pendingAdd, addActions] = useList<RelayConfig>([]);
const [pendingRemove, removeActions] = useList<RelayConfig>([]); const [pendingRemove, removeActions] = useList<RelayConfig>([]);
@@ -49,11 +53,16 @@ export const RelaysView = () => {
}; };
const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => { const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setRelayInputValue(""); try {
const url = validateRelayUrl(relayInputValue);
const url = relayInputValue; if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) {
if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) { addActions.push({ url, mode: RelayMode.ALL });
addActions.push({ url, mode: RelayMode.ALL }); }
setRelayInputValue("");
} catch (e) {
if (e instanceof Error) {
toast({ status: "error", description: e.message });
}
} }
}; };
const savePending = async () => { const savePending = async () => {
@@ -72,6 +81,8 @@ export const RelaysView = () => {
<Thead> <Thead>
<Tr> <Tr>
<Th>Url</Th> <Th>Url</Th>
<Th>Avg Response</Th>
<Th>Disconnects</Th>
<Th>Status</Th> <Th>Status</Th>
<Th></Th> <Th></Th>
</Tr> </Tr>
@@ -85,6 +96,8 @@ export const RelaysView = () => {
<Text>{relay.url}</Text> <Text>{relay.url}</Text>
</Flex> </Flex>
</Td> </Td>
<Td>{relayScoreboardService.getAverageResponseTime(relay.url).toFixed(2)}ms</Td>
<Td>{relayScoreboardService.getDisconnects(relay.url)}</Td>
<Td> <Td>
<RelayStatus url={relay.url} /> <RelayStatus url={relay.url} />
</Td> </Td>

View File

@@ -1,8 +1,11 @@
import { Text, Grid, Box, IconButton, Flex, Heading } from "@chakra-ui/react"; import { Text, Grid, Box, IconButton, Flex, Heading, Badge, Alert, AlertIcon } from "@chakra-ui/react";
import { useUserContacts } from "../../hooks/use-user-contacts"; import { useUserContacts } from "../../hooks/use-user-contacts";
import { useNavigate, useOutletContext } from "react-router-dom"; import { useNavigate, useOutletContext } from "react-router-dom";
import { GlobalIcon } from "../../components/icons"; import { GlobalIcon } from "../../components/icons";
import { useUserRelays } from "../../hooks/use-user-relays"; import { useUserRelays } from "../../hooks/use-user-relays";
import { useMemo } from "react";
import relayScoreboardService from "../../services/relay-scoreboard";
import { RelayConfig, RelayMode } from "../../classes/relay";
const UserRelaysTab = () => { const UserRelaysTab = () => {
const { pubkey } = useOutletContext() as { pubkey: string }; const { pubkey } = useOutletContext() as { pubkey: string };
@@ -10,41 +13,39 @@ const UserRelaysTab = () => {
const userRelays = useUserRelays(pubkey); const userRelays = useUserRelays(pubkey);
const navigate = useNavigate(); const navigate = useNavigate();
const relays = useMemo(() => {
let arr: RelayConfig[] = userRelays?.relays ?? [];
if (arr.length === 0 && contacts)
arr = Array.from(Object.entries(contacts.relays)).map(([url, opts]) => ({
url,
mode: (opts.write ? RelayMode.WRITE : 0) | (opts.read ? RelayMode.READ : 0),
}));
const rankedUrls = relayScoreboardService.getRankedRelays(arr.map((r) => r.url));
return rankedUrls.map((u) => arr.find((r) => r.url === u) as RelayConfig);
}, [userRelays, contacts]);
return ( return (
<Flex direction="column"> <Flex direction="column" gap="2">
{userRelays && ( {!userRelays && contacts?.relays && (
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)" }} gap="2"> <Alert status="warning">
{userRelays.relays.map((relayConfig) => ( <AlertIcon />
<Box key={relayConfig.url} display="flex" gap="2" alignItems="center" pr="2" pl="2"> Cant find new relay list
<Text flex={1}>{relayConfig.url}</Text> </Alert>
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + relayConfig.url)}
size="sm"
aria-label="Global Feed"
/>
</Box>
))}
</Grid>
)}
{contacts && (
<>
<Heading size="md">Relays from contact list (old)</Heading>
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)" }} gap="2">
{Object.entries(contacts.relays).map(([url, opts]) => (
<Box key={url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1}>{url}</Text>
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + url)}
size="sm"
aria-label="Global Feed"
/>
</Box>
))}
</Grid>
</>
)} )}
{relays.map((relayConfig) => (
<Box key={relayConfig.url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1}>{relayConfig.url}</Text>
<Text>{relayScoreboardService.getAverageResponseTime(relayConfig.url).toFixed(2)}ms</Text>
{relayConfig.mode & RelayMode.WRITE ? <Badge colorScheme="green">Write</Badge> : null}
{relayConfig.mode & RelayMode.READ ? <Badge>Read</Badge> : null}
<IconButton
icon={<GlobalIcon />}
onClick={() => navigate("/global?relay=" + relayConfig.url)}
size="sm"
aria-label="Global Feed"
/>
</Box>
))}
</Flex> </Flex>
); );
}; };