mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-13 06:09:42 +02:00
record relay response time
This commit is contained in:
parent
4bd04eb644
commit
fa98fa6fda
@ -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
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("relayInfo");
|
||||
db.createObjectStore("relayScoreboardStats", { keyPath: "relay" });
|
||||
db.createObjectStore("accounts", { keyPath: "pubkey" });
|
||||
},
|
||||
];
|
||||
|
@ -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;
|
||||
|
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,
|
||||
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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user