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 { NostrOutgoingMessage } from "../types/nostr-query";
import { Subject } from "./subject";
@ -46,7 +47,9 @@ export class Relay {
ws?: WebSocket;
mode: RelayMode = RelayMode.ALL;
private intentionalClose = false;
private queue: NostrOutgoingMessage[] = [];
private subscriptionStartTime = new Map<string, Date>();
constructor(url: string, mode: RelayMode = RelayMode.ALL) {
this.url = url;
@ -55,6 +58,7 @@ export class Relay {
open() {
if (this.okay) return;
this.intentionalClose = false;
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
@ -69,6 +73,8 @@ export class Relay {
this.ws.onclose = () => {
this.onClose.next(this);
if (!this.intentionalClose) relayScoreboardService.submitDisconnect(this.url);
if (import.meta.env.DEV) {
console.info(`Relay: ${this.url} disconnected`);
}
@ -79,11 +85,29 @@ export class Relay {
if (this.mode & RelayMode.WRITE) {
if (this.connected) {
this.ws?.send(JSON.stringify(json));
// record start time
if (json[0] === "REQ") {
this.subStartMeasure(json[1]);
}
} else this.queue.push(json);
}
}
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() {
@ -130,12 +154,14 @@ export class Relay {
switch (type) {
case "EVENT":
this.onEvent.next({ relay: this, type, subId: data[1], body: data[2] });
this.subEndMeasure(data[1]);
break;
case "NOTICE":
this.onNotice.next({ relay: this, type, message: data[1] });
break;
case "EOSE":
this.onEOSE.next({ relay: this, type, subId: data[1] });
this.subEndMeasure(data[1]);
break;
case "OK":
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("relayInfo");
db.createObjectStore("relayScoreboardStats", { keyPath: "relay" });
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 };
indexes: { pubkey: string };
};
relayScoreboardStats: {
key: string;
value: { relay: string; responseTimes: [number, Date][]; disconnects: Date[] };
};
settings: {
key: string;
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,
Text,
Badge,
useToast,
} from "@chakra-ui/react";
import { SyntheticEvent, useEffect, useState } from "react";
import { TrashIcon, UndoIcon } from "../../components/icons";
@ -23,9 +24,12 @@ import { useList } from "react-use";
import { RelayUrlInput } from "../../components/relay-url-input";
import useSubject from "../../hooks/use-subject";
import { RelayStatus } from "../../components/relay-status";
import relayScoreboardService from "../../services/relay-scoreboard";
import { validateRelayUrl } from "../../helpers/url";
export const RelaysView = () => {
const relays = useSubject(clientRelaysService.relays);
const toast = useToast();
const [pendingAdd, addActions] = useList<RelayConfig>([]);
const [pendingRemove, removeActions] = useList<RelayConfig>([]);
@ -49,11 +53,16 @@ export const RelaysView = () => {
};
const handleAddRelay = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
setRelayInputValue("");
const url = relayInputValue;
if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) {
addActions.push({ url, mode: RelayMode.ALL });
try {
const url = validateRelayUrl(relayInputValue);
if (!relays.some((r) => r.url === url) && !pendingAdd.some((r) => r.url === url)) {
addActions.push({ url, mode: RelayMode.ALL });
}
setRelayInputValue("");
} catch (e) {
if (e instanceof Error) {
toast({ status: "error", description: e.message });
}
}
};
const savePending = async () => {
@ -72,6 +81,8 @@ export const RelaysView = () => {
<Thead>
<Tr>
<Th>Url</Th>
<Th>Avg Response</Th>
<Th>Disconnects</Th>
<Th>Status</Th>
<Th></Th>
</Tr>
@ -85,6 +96,8 @@ export const RelaysView = () => {
<Text>{relay.url}</Text>
</Flex>
</Td>
<Td>{relayScoreboardService.getAverageResponseTime(relay.url).toFixed(2)}ms</Td>
<Td>{relayScoreboardService.getDisconnects(relay.url)}</Td>
<Td>
<RelayStatus url={relay.url} />
</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 { useNavigate, useOutletContext } from "react-router-dom";
import { GlobalIcon } from "../../components/icons";
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 { pubkey } = useOutletContext() as { pubkey: string };
@ -10,41 +13,39 @@ const UserRelaysTab = () => {
const userRelays = useUserRelays(pubkey);
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 (
<Flex direction="column">
{userRelays && (
<Grid templateColumns={{ base: "1fr", xl: "repeat(2, 1fr)" }} gap="2">
{userRelays.relays.map((relayConfig) => (
<Box key={relayConfig.url} display="flex" gap="2" alignItems="center" pr="2" pl="2">
<Text flex={1}>{relayConfig.url}</Text>
<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>
</>
<Flex direction="column" gap="2">
{!userRelays && contacts?.relays && (
<Alert status="warning">
<AlertIcon />
Cant find new relay list
</Alert>
)}
{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>
);
};