mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-05 17:55:01 +02:00
record relay response time
This commit is contained in:
@@ -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
17
src/helpers/url.ts
Normal 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;
|
||||||
|
}
|
@@ -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" });
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@@ -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;
|
||||||
|
115
src/services/relay-scoreboard.ts
Normal file
115
src/services/relay-scoreboard.ts
Normal 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;
|
@@ -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>
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user