<p style="font-size:small;">Content-Length: 32635 | <a href="http://clevelandohioweatherforecast.com//pFad.php?u=" style="font-size:small;">pFad</a> | <a href="http://github.com/nfx/slrp/pull/129.diff" style="font-size:small;">http://github.com/nfx/slrp/pull/129.diff</a></p>67BE285D diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 667b5e6..8e088df 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,14 +1,13 @@ -import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap-icons/font/bootstrap-icons.css"; +import "bootstrap/dist/css/bootstrap.min.css"; +import { NavLink, Outlet, Route, Routes } from "react-router-dom"; import "./App.css"; -import React from "react"; -import { Routes, Route, NavLink, Outlet } from "react-router-dom"; -import { ErrorBoundary } from "./util"; -import Dashboard from "./Sources"; -import Proxies from "./Proxies"; -import History from "./History"; import Blacklist from "./Blacklist"; +import Dashboard from "./Dashboard"; +import History from "./History"; +import Proxies from "./Proxies"; import Reverify from "./Reverify"; +import { ErrorBoundary } from "./components/ErrorBoundary"; function Header() { return ( diff --git a/ui/src/Blacklist.tsx b/ui/src/Blacklist.tsx index f1b6f90..59e873e 100644 --- a/ui/src/Blacklist.tsx +++ b/ui/src/Blacklist.tsx @@ -1,17 +1,20 @@ import { useState } from "react"; +import { IconHeader } from "./components/IconHeader"; +import { LiveFilter } from "./components/LiveFilter"; +import { Facet, QueryFacets } from "./components/facets/QueryFacet"; import { Countries } from "./countries"; -import { Facet, IconHeader, LiveFilter, QueryFacets, http, useTitle } from "./util"; +import { http, useTitle } from "./util"; -type BlacklistItem = { +type Blacklisted = { Proxy: string; + Country: string; + Provider: string; + ASN: number; Failure: string; Sources: string[]; - Provider: string; - ASN: string; - Country: string; }; -function Item({ Proxy, Failure, Sources, Provider, ASN, Country }: BlacklistItem) { +function BlacklistItem({ Proxy, Failure, Sources, Provider, ASN, Country }: Blacklisted) { const removeProxy = () => { http.delete(`/blacklist/${Proxy.replace("//", "")}`); return false; @@ -45,7 +48,7 @@ function Item({ Proxy, Failure, Sources, Provider, ASN, Country }: BlacklistItem export default function Blacklist() { useTitle("Blacklist"); - const [result, setResult] = useState<{ Facets?: Facet[]; Records?: BlacklistItem[] }>(); + const [result, setResult] = useState<{ Facets?: Facet[]; Records?: Blacklisted[] }>(); return ( <div className="card blacklist table-responsive"> <LiveFilter endpoint="/blacklist" onUpdate={setResult} minDelay={10000} /> @@ -62,7 +65,7 @@ export default function Blacklist() { <IconHeader icon="emoji-dizzy failure" title="Failure" /> </tr> </thead> - <tbody>{result.Records && result.Records.map(r => <Item key={r.Proxy} {...r} />)}</tbody> + <tbody>{result.Records && result.Records.map(r => <BlacklistItem key={r.Proxy} {...r} />)}</tbody> </table> </div> )} diff --git a/ui/src/Sources.tsx b/ui/src/Dashboard.tsx similarity index 89% rename from ui/src/Sources.tsx rename to ui/src/Dashboard.tsx index b9f596c..ac7c986 100644 --- a/ui/src/Sources.tsx +++ b/ui/src/Dashboard.tsx @@ -1,11 +1,44 @@ import { ReactNode, useState } from "react"; -import { Card, IconHeader, TimeDiff, http, useInterval, useTitle } from "./util"; +import { Card as Card } from "./components/Card"; +import { IconHeader } from "./components/IconHeader"; +import { TimeDiff } from "./components/TimeDiff"; +import { http, tinyNum, useInterval, useTitle } from "./util"; const overall = ["Exclusive", "Dirty", "Contribution"] as const; const pipeline = ["Scheduled", "New", "Probing"] as const; const stats = ["Found", "Timeouts", "Blacklisted", "Ignored"] as const; const cols = [...pipeline, ...stats]; -type Summary = Partial<ProbeProps>; + +type Source = { + Name: string; + Homepage: string; + UrlPrefix: string; + Frequency: string; + State: string; + Failure: string; + Progress: number; + Dirty: number; + Contribution: number; + Exclusive: number; + Scheduled: number; + New: number; + Probing: number; + Found: number; + Timeouts: number; + Blacklisted: number; + Ignored: number; + Updated: string; + EstFinish: string; + NextRefresh: string; +}; + +type Summary = Partial<Source>; + +type Card = { + Name: string; + Value: number; + Increment?: number; +}; function successRate(v: Summary) { if (!v["Found"]) { @@ -20,12 +53,12 @@ function successRate(v: Summary) { ); } -function SourceOverview({ sources }: { sources: ProbeProps[] }) { +function SourceOverview({ sources }: { sources: Source[] }) { const summary: Summary = {}; sources.forEach(probe => cols.forEach(col => { const oldVal = summary[col]; - const val = probe[col as keyof ProbeProps] as number; + const val = probe[col as keyof Source] as number; summary[col] = val + (oldVal ? oldVal : 0); }) ); @@ -90,41 +123,7 @@ function Cols(props: Summary) { ); } -function tinyNum(n?: number) { - if (!n) { - return 0; - } - if (n < 1000) { - return n; - } else if (n < 1000000) { - return (n / 1000).toFixed() + "k"; - } - return (n / 1000000).toFixed(1) + "m"; -} - -type ProbeProps = { - Name: string; - State: string; - Progress: number; - Failure: string; - EstFinish: number; - NextRefresh: number; - UrlPrefix: string; - Homepage: string; - Scheduled: number; - New: number; - Probing: number; - Found: number; - Timeouts: number; - Blacklisted: number; - Ignored: number; - Exclusive: number; - Dirty: number; - Contribution: number; -}; -// type ProbeProps = { [key: string]: number | string }; - -function Probe(props: ProbeProps) { +function Probe(props: Source) { const { Name, State, Progress, Failure, EstFinish, NextRefresh, UrlPrefix, Homepage } = props; const style: Record<string, string | number> = {}; let rowClass = ""; @@ -157,15 +156,9 @@ function Probe(props: ProbeProps) { ); } -type Card = { - Name: string; - Value: string; - Increment?: number; -}; - export default function Dashboard() { useTitle("Overview"); - const [dashboard, setDashboard] = useState<{ Cards: Card[]; Refresh: ProbeProps[] }>(); + const [dashboard, setDashboard] = useState<{ Cards: Card[]; Refresh: Source[] }>(); const [delay, setDelay] = useState<number | undefined>(1000); useInterval(() => { http diff --git a/ui/src/History.tsx b/ui/src/History.tsx index 280f13b..b931fed 100644 --- a/ui/src/History.tsx +++ b/ui/src/History.tsx @@ -1,6 +1,9 @@ import { useState } from "react"; import "./History.css"; -import { Facet, IconHeader, LiveFilter, QueryFacets, useTitle } from "./util"; +import { IconHeader } from "./components/IconHeader"; +import { LiveFilter } from "./components/LiveFilter"; +import { Facet, QueryFacets } from "./components/facets/QueryFacet"; +import { useTitle } from "./util"; function convertSize(bytes: number) { if (bytes < 1024) { @@ -11,68 +14,68 @@ function convertSize(bytes: number) { return `${(bytes / 1024 / 1024).toFixed()}mb`; } -type RequestProps = { - ID: string; +type FilteredRequest = { + ID: number; Serial: number; Attempt: number; Ts: string; Method: string; URL: string; - Status: string; StatusCode: number; + Status: string; Proxy: string; Appeared: number; Size: number; Took: number; }; -function Request(props: RequestProps) { - const pos = props.URL.indexOf("/", 9); - const path = props.URL.substring(pos); +function Request(history: FilteredRequest) { + const pos = history.URL.indexOf("/", 9); + const path = history.URL.substring(pos); let color = "text-muted"; - if (props.StatusCode < 300) { + if (history.StatusCode < 300) { color = "text-success"; - } else if (props.StatusCode < 500) { + } else if (history.StatusCode < 500) { color = "text-warning"; } // TODO: add links in backend return ( <tr className="list-group-item-action"> <td className="text-muted"> - <small>{new Date(props.Ts).toLocaleTimeString()}</small> + <small>{new Date(history.Ts).toLocaleTimeString()}</small> </td> <td> <span className="request"> - {props.Method}{" "} - <a className="app-link" href={`http://localhost:8089/api/history/${props.ID}?format=text`} rel="noreferrer" target="_blank"> - <abbr title={props.URL}>{path}</abbr> + {history.Method}{" "} + <a className="app-link" href={`http://localhost:8089/api/history/${history.ID}?format=text`} rel="noreferrer" target="_blank"> + <abbr title={history.URL}>{path}</abbr> </a> <sup> - <a className="text-muted" href={`/history?filter=Serial:${props.Serial}`}> - {props.Serial} + <a className="text-muted" href={`/history?filter=Serial:${history.Serial}`}> + {history.Serial} </a> </sup> </span> </td> <td className={color}> - {props.StatusCode === 200 ? 200 : <abbr title={props.Status}>{props.StatusCode}</abbr>} <sup>{props.Attempt}</sup> + {history.StatusCode === 200 ? 200 : <abbr title={history.Status}>{history.StatusCode}</abbr>} <sup>{history.Attempt}</sup> </td> <td className="text-muted proxy"> <a className="link-primary app-link" href={`/history?filter=Proxy:"${Proxy}"`}> - {props.Proxy} + {history.Proxy} </a>{" "} - <sup>{props.Appeared}</sup> + <sup>{history.Appeared}</sup> </td> - <td className="size">{convertSize(props.Size)}</td> - <td className="took">{props.Took}s</td> + <td className="size">{convertSize(history.Size)}</td> + <td className="took">{history.Took}s</td> </tr> ); } export default function History() { useTitle("History"); - const [result, setResult] = useState<{ facets: Facet[]; Records?: RequestProps[] }>(); + const [result, setResult] = useState<{ facets: Facet[]; Records?: FilteredRequest[] }>(); return ( <div id="history-table" className="card history table-responsive"> <LiveFilter endpoint="/history" onUpdate={setResult} minDelay={2000} /> diff --git a/ui/src/Proxies.tsx b/ui/src/Proxies.tsx index e25802e..2c1d4c5 100644 --- a/ui/src/Proxies.tsx +++ b/ui/src/Proxies.tsx @@ -1,11 +1,14 @@ -import { LiveFilter, QueryFacets, TimeDiff, http, IconHeader, useTitle, Facet } from "./util"; -import { Countries } from "./countries"; import { useState } from "react"; -import React from "react"; +import { IconHeader } from "./components/IconHeader"; +import { LiveFilter } from "./components/LiveFilter"; +import { TimeDiff } from "./components/TimeDiff"; +import { Facet, QueryFacets } from "./components/facets/QueryFacet"; +import { Countries } from "./countries"; +import { http, useTitle } from "./util"; export default function Proxies() { useTitle("Proxies"); - const [result, setResult] = useState<{ Facets: Facet[]; Records?: ProxyEntry[] }>(); + const [result, setResult] = useState<{ Facets: Facet[]; Records?: ApiEntry[] }>(); return ( <div> <LiveFilter endpoint="/pool" onUpdate={setResult} minDelay={2000} /> @@ -42,24 +45,26 @@ function timeTook(duration: number) { return `${(ms / 1000).toFixed(2)}s`; } -export type ProxyEntry = { +export type ApiEntry = { Proxy: string; FirstSeen: number; LastSeen: number; - Timeouts: number; + ReanimateAfter: string; Ok: boolean; - ReanimateAfter: number; Speed: number; - Country: string; - Provider: string; - ASN: string; + Seen: number; + Timeouts: number; Offered: number; + Reanimated: number; Succeed: number; - HourSucceed: number[]; HourOffered: number[]; + HourSucceed: number[]; + Country: string; + Provider: string; + ASN: number; }; -function Entry(props: ProxyEntry) { +function Entry(props: ApiEntry) { const proxy = props.Proxy; const { FirstSeen, LastSeen, Timeouts, Ok, ReanimateAfter, Speed, Country, Provider, ASN } = props; const removeProxy = () => { diff --git a/ui/src/Reverify.tsx b/ui/src/Reverify.tsx index 42c5ef4..de54264 100644 --- a/ui/src/Reverify.tsx +++ b/ui/src/Reverify.tsx @@ -1,17 +1,22 @@ import { useState } from "react"; +import { IconHeader } from "./components/IconHeader"; +import { LiveFilter } from "./components/LiveFilter"; +import { Facet, QueryFacets } from "./components/facets/QueryFacet"; import { Countries } from "./countries"; -import { Facet, IconHeader, LiveFilter, QueryFacets, http, useTitle } from "./util"; +import { http, useTitle } from "./util"; -type ReverifyItem = { +type InReverify = { Proxy: string; - Sources: string[]; - Provider: string; - ASN: string; - Country: string; Attempt: number; + After: string; + Country: string; + Provider: string; + ASN: number; + Failure: string; + Sources: string[]; }; -function Item({ Proxy, Sources, Provider, ASN, Country, Attempt }: ReverifyItem) { +function ReverifyItem({ Proxy, Sources, Provider, ASN, Country, Attempt }: InReverify) { const removeProxy = () => { http.delete(`/blacklist/${Proxy.replace("//", "")}`); return false; @@ -45,11 +50,11 @@ function Item({ Proxy, Sources, Provider, ASN, Country, Attempt }: ReverifyItem) export default function Reverify() { useTitle("Reverify"); - const [result, setResult] = useState<{ Facets: Facet[]; Records?: ReverifyItem[] }>(); + const [result, setResult] = useState<{ Facets: Facet[]; Records?: InReverify[] }>(); return ( <div className="card blacklist table-responsive"> <LiveFilter endpoint="/reverify" onUpdate={setResult} minDelay={10000} /> - {result != null && ( + {result && ( <div> <QueryFacets endpoint="/reverify" {...result} /> <table className="table text-start table-sm"> @@ -62,7 +67,7 @@ export default function Reverify() { <IconHeader icon="hdd-network attempt" title="Attempt" /> </tr> </thead> - <tbody>{result.Records && result.Records.map(r => <Item key={r.Proxy} {...r} />)}</tbody> + <tbody>{result.Records && result.Records.map(r => <ReverifyItem key={r.Proxy} {...r} />)}</tbody> </table> </div> )} diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx new file mode 100644 index 0000000..f996991 --- /dev/null +++ b/ui/src/components/Card.tsx @@ -0,0 +1,23 @@ +export type CardProps = { + label: string; + value: number; + increment?: number; +}; + +export function Card({ label, value, increment = 0 }: CardProps) { + return ( + <div className="col"> + <div className="card mb-3"> + <div className="card-body"> + <div className="row align-items-center gx-0"> + <div className="col"> + <h6 className="text-uppercase text-muted mb-2">{label}</h6> + <span className="h2 mb-0 ">{value}</span> + {increment > 0 && <span className="badge bg-success ms-2">+{increment}</span>} + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/ui/src/components/ErrorBoundary.tsx b/ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..2d0a10a --- /dev/null +++ b/ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Component } from "react"; + +export class ErrorBoundary extends Component<{ children: React.ReactNode }> { + state = { error: false, errorMessage: "" }; + + static getDerivedStateFromError(error: any) { + return { error: true, errorMessage: error.toString() }; + } + + componentDidCatch(error: any) { + console.error(error); + } + + render() { + if (this.state.error) { + return ( + <div className="alert alert-danger" role="alert"> + <h4 className="alert-heading">Failed</h4> + {this.state.errorMessage} + </div> + ); + } + return this.props.children; + } +} diff --git a/ui/src/components/IconHeader.tsx b/ui/src/components/IconHeader.tsx new file mode 100644 index 0000000..5640aeb --- /dev/null +++ b/ui/src/components/IconHeader.tsx @@ -0,0 +1,8 @@ +export function IconHeader({ icon, col, title }: { icon: string; col?: string; title: string }) { + const cl = `bi bi-${icon}`; + return ( + <th className={`col-${col}`}> + <i className={cl} title={title} /> + </th> + ); +} diff --git a/ui/src/components/LiveFilter.tsx b/ui/src/components/LiveFilter.tsx new file mode 100644 index 0000000..138929e --- /dev/null +++ b/ui/src/components/LiveFilter.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; +import React from "react"; +import { http } from "../util"; + +type LiveFilterProps = { + endpoint: string; + onUpdate: (data: any) => void; + minDelay?: number; +}; + +export function LiveFilter({ endpoint, onUpdate, minDelay = 5000 }: LiveFilterProps) { + const savedCallback = useRef<number>(); + const [pause, setPause] = useState(false); + const [total, setTotal] = useState(); + const [failure, setFailure] = useState<number | undefined>(); + const [searchParams, setSearchParams] = useSearchParams(); + + let doFilter = useCallback(() => { + clearTimeout(savedCallback.current); + savedCallback.current = setTimeout(() => { + http + .get(endpoint, { params: searchParams }) + .then(response => { + if (pause) { + clearTimeout(savedCallback.current); + return; + } + setTotal(response.data.Total); + onUpdate(response.data); + setFailure(undefined); + savedCallback.current = setTimeout(doFilter, minDelay); + }) + .catch(err => { + if (err.isAxiosError) { + setFailure(err.response.data.Message); + } + clearTimeout(savedCallback.current); + return false; + }); + }, 500); + }, [pause, savedCallback, searchParams, endpoint, minDelay, onUpdate]); + + useEffect(() => { + doFilter(); + return () => clearTimeout(savedCallback.current); + }, [savedCallback, doFilter]); + + let filter = searchParams.get("filter") || ""; + let change = (event: React.ChangeEvent<HTMLInputElement>) => { + filter = event.target.value; + setSearchParams(filter === "" ? {} : { filter }); + doFilter(); + }; + + let togglePause = () => { + setPause(!pause); + if (pause) { + doFilter(); + } else { + clearTimeout(savedCallback.current); + } + }; + + return ( + <div className="search-filter"> + <div className="input-group"> + <div> + {total !== undefined && <span className="total">{total} total</span>} + <input className="form-control form-control-dark w-100 border-secondary" type="text" value={filter} onChange={change} placeholder="Search" aria-label="Search" /> + </div> + <button className="btn btn-outline-secondary border-secondary" type="button" onClick={togglePause} title={!pause ? "Pause live update" : "Resume live update"}> + <i className={`bi ${pause ? "bi-play" : "bi-pause"}`} /> + </button> + </div> + {failure !== undefined && ( + <div className="alert-danger" role="alert"> + {failure} + </div> + )} + </div> + ); +} diff --git a/ui/src/components/TimeDiff.tsx b/ui/src/components/TimeDiff.tsx new file mode 100644 index 0000000..6816454 --- /dev/null +++ b/ui/src/components/TimeDiff.tsx @@ -0,0 +1,34 @@ +type TimeDiffProps = { + ts: string | number | Date; + title: string; +}; + +export function TimeDiff({ ts, title }: TimeDiffProps) { + const now = new Date().getTime(); + const it = new Date(ts).getTime(); + let elapsed = Math.abs((now - it) / 1000); + let value = "?"; + if (elapsed < 60) { + value = `${elapsed.toFixed()}s`; + } else if (elapsed < 60 * 60) { + value = `${(elapsed / 60).toFixed()}m`; + } else if (elapsed < 60 * 60 * 24) { + value = `${(elapsed / 60 / 60).toFixed()}h`; + } else if (elapsed < 60 * 60 * 24 * 7) { + value = `${(elapsed / 60 / 60 / 24).toFixed()}d`; + } else if (elapsed < 60 * 60 * 24 * 30) { + value = `${(elapsed / 60 / 60 / 24 / 7).toFixed()}w`; + } + if (it > now) { + return ( + <sup className="text-muted" title={title}> + in {value} + </sup> + ); + } + return ( + <sup className="text-muted" title={title}> + {value} ago + </sup> + ); +} diff --git a/ui/src/components/facets/QueryFacet.tsx b/ui/src/components/facets/QueryFacet.tsx new file mode 100644 index 0000000..6e492f8 --- /dev/null +++ b/ui/src/components/facets/QueryFacet.tsx @@ -0,0 +1,37 @@ +function QueryFacetFilter({ Name, Value, Filter, endpoint }: { Name: string; Value: string; Filter: string; endpoint: string }) { + const short = Name.length > 32 ? `${Name.substring(0, 32)}...` : Name; + const link = Name !== "n/a" ? `${endpoint}?filter=${Filter}` : undefined; + return ( + <li> + {link ? ( + short + ) : ( + <a className="link-primary app-link" href={link}> + {short} + </a> + )}{" "} + <sup>{Value}</sup> + </li> + ); +} + +function QueryFacet({ Name, Top, endpoint }: { Name: string; Top: { Name: string; Value: string; Filter: string }[]; endpoint: string }) { + return Top.length == 0 ? ( + <></> + ) : ( + <div key={Name} className="search-facet"> + <strong>{Name}</strong> + <ul> + {Top.map(f => ( + <QueryFacetFilter key={f.Name} endpoint={endpoint} {...f} /> + ))} + </ul> + </div> + ); +} + +export type Facet = { Name: string; Top: { Name: string; Value: string; Filter: string }[] }; + +export function QueryFacets({ Facets, endpoint }: { Facets?: Facet[]; endpoint: string }) { + return <>{Facets && Facets.map(f => <QueryFacet key={f.Name} endpoint={endpoint} {...f} />)}</>; +} diff --git a/ui/src/components/facets/SearchFacet.tsx b/ui/src/components/facets/SearchFacet.tsx new file mode 100644 index 0000000..fe8dbc1 --- /dev/null +++ b/ui/src/components/facets/SearchFacet.tsx @@ -0,0 +1,32 @@ +function FilterableFacet({ Name, Value, link }: { Name: string; Value: string; link?: string }) { + const short = Name.length > 32 ? `${Name.substring(0, 32)}...` : Name; + return ( + <li> + {link ? ( + <a className="link-primary app-link" href={link.replace("$", Name)}> + {short} + </a> + ) : ( + short + )}{" "} + <sup>{Value}</sup> + </li> + ); +} + +export function SearchFacet({ name, items, link }: { name: string; items: { Name: string; Value: string }[]; link?: string }) { + let result: any[] = []; + if (items.length > 1) { + result.push( + <div key={name} className="search-facet"> + <strong>{name}</strong> + <ul> + {items.map(f => ( + <FilterableFacet key={f.Name} link={link} {...f} /> + ))} + </ul> + </div> + ); + } + return result; +} diff --git a/ui/src/util.ts b/ui/src/util.ts new file mode 100644 index 0000000..987d808 --- /dev/null +++ b/ui/src/util.ts @@ -0,0 +1,47 @@ +import axios from "axios"; +import { useEffect, useRef } from "react"; + +export const http = axios.create({ + baseURL: "/api" +}); + +export function useInterval(callback: () => any, delay?: number) { + // https://overreacted.io/making-setinterval-declarative-with-react-hooks/ + const savedCallback = useRef<() => number>(); + useEffect(() => { + savedCallback.current = callback; + }); + useEffect(() => { + function tick() { + if (savedCallback.current) { + savedCallback.current(); + } + } + if (delay !== undefined) { + let id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); +} + +export function useTitle(title: string) { + useEffect(() => { + const prev = document.title; + document.title = `${title} - slrp`; + return () => { + document.title = prev; + }; + }, [title]); +} + +export function tinyNum(n?: number) { + if (!n) { + return 0; + } + if (n < 1000) { + return n; + } else if (n < 1000000) { + return (n / 1000).toFixed() + "k"; + } + return (n / 1000000).toFixed(1) + "m"; +} diff --git a/ui/src/util.tsx b/ui/src/util.tsx deleted file mode 100644 index 6ef6f73..0000000 --- a/ui/src/util.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import axios from "axios"; -import { useEffect, useState, useRef, Component, useCallback } from "react"; -import { useSearchParams } from "react-router-dom"; -import React from "react"; - -export const http = axios.create({ - baseURL: "/api" -}); - -export class ErrorBoundary extends Component<{ children: React.ReactNode }> { - state = { error: false, errorMessage: "" }; - - static getDerivedStateFromError(error: any) { - return { error: true, errorMessage: error.toString() }; - } - - componentDidCatch(error: any) { - console.error(error); - } - - render() { - if (this.state.error) { - return ( - <div className="alert alert-danger" role="alert"> - <h4 className="alert-heading">Failed</h4> - {this.state.errorMessage} - </div> - ); - } - return this.props.children; - } -} - -type TimeDiffProps = { - ts: number; - title: string; -}; - -export function TimeDiff({ ts, title }: TimeDiffProps) { - const now = new Date().getTime(); - const it = new Date(ts).getTime(); - let elapsed = Math.abs((now - it) / 1000); - let value = "?"; - if (elapsed < 60) { - value = `${elapsed.toFixed()}s`; - } else if (elapsed < 60 * 60) { - value = `${(elapsed / 60).toFixed()}m`; - } else if (elapsed < 60 * 60 * 24) { - value = `${(elapsed / 60 / 60).toFixed()}h`; - } else if (elapsed < 60 * 60 * 24 * 7) { - value = `${(elapsed / 60 / 60 / 24).toFixed()}d`; - } else if (elapsed < 60 * 60 * 24 * 30) { - value = `${(elapsed / 60 / 60 / 24 / 7).toFixed()}w`; - } - if (it > now) { - return ( - <sup className="text-muted" title={title}> - in {value} - </sup> - ); - } - return ( - <sup className="text-muted" title={title}> - {value} ago - </sup> - ); -} - -export function IconHeader({ icon, col, title }: { icon: string; col?: string; title: string }) { - const cl = `bi bi-${icon}`; - return ( - <th className={`col-${col}`}> - <i className={cl} title={title} /> - </th> - ); -} - -export type CardProps = { - label: string; - value: string; - increment?: number; -}; - -export function Card({ label, value, increment = 0 }: CardProps) { - return ( - <div className="col"> - <div className="card mb-3"> - <div className="card-body"> - <div className="row align-items-center gx-0"> - <div className="col"> - <h6 className="text-uppercase text-muted mb-2">{label}</h6> - <span className="h2 mb-0 ">{value}</span> - {increment > 0 && <span className="badge bg-success ms-2">+{increment}</span>} - </div> - </div> - </div> - </div> - </div> - ); -} - -type LiveFilterProps = { - endpoint: string; - onUpdate: (data: any) => void; - minDelay?: number; -}; - -export function LiveFilter({ endpoint, onUpdate, minDelay = 5000 }: LiveFilterProps) { - const savedCallback = useRef<number>(); - const [pause, setPause] = useState(false); - const [total, setTotal] = useState(); - const [failure, setFailure] = useState<number | undefined>(); - const [searchParams, setSearchParams] = useSearchParams(); - - let doFilter = useCallback(() => { - clearTimeout(savedCallback.current); - savedCallback.current = setTimeout(() => { - http - .get(endpoint, { params: searchParams }) - .then(response => { - if (pause) { - clearTimeout(savedCallback.current); - return; - } - setTotal(response.data.Total); - onUpdate(response.data); - setFailure(undefined); - savedCallback.current = setTimeout(doFilter, minDelay); - }) - .catch(err => { - if (err.isAxiosError) { - setFailure(err.response.data.Message); - } - clearTimeout(savedCallback.current); - return false; - }); - }, 500); - }, [pause, savedCallback, searchParams, endpoint, minDelay, onUpdate]); - - useEffect(() => { - doFilter(); - return () => clearTimeout(savedCallback.current); - }, [savedCallback, doFilter]); - - let filter = searchParams.get("filter") || ""; - let change = (event: React.ChangeEvent<HTMLInputElement>) => { - filter = event.target.value; - setSearchParams(filter === "" ? {} : { filter }); - doFilter(); - }; - - let togglePause = () => { - setPause(!pause); - if (pause) { - doFilter(); - } else { - clearTimeout(savedCallback.current); - } - }; - - return ( - <div className="search-filter"> - <div className="input-group"> - <div> - {total !== undefined && <span className="total">{total} total</span>} - <input className="form-control form-control-dark w-100 border-secondary" type="text" value={filter} onChange={change} placeholder="Search" aria-label="Search" /> - </div> - <button className="btn btn-outline-secondary border-secondary" type="button" onClick={togglePause} title={!pause ? "Pause live update" : "Resume live update"}> - <i className={`bi ${pause ? "bi-play" : "bi-pause"}`} /> - </button> - </div> - {failure !== undefined && ( - <div className="alert-danger" role="alert"> - {failure} - </div> - )} - </div> - ); -} - -function FilterableFacet({ Name, Value, link }: { Name: string; Value: string; link?: string }) { - const short = Name.length > 32 ? `${Name.substring(0, 32)}...` : Name; - return ( - <li> - {link ? ( - <a className="link-primary app-link" href={link.replace("$", Name)}> - {short} - </a> - ) : ( - short - )}{" "} - <sup>{Value}</sup> - </li> - ); -} - -export function SearchFacet({ name, items, link }: { name: string; items: { Name: string; Value: string }[]; link?: string }) { - let result: any[] = []; - if (items.length > 1) { - result.push( - <div key={name} className="search-facet"> - <strong>{name}</strong> - <ul> - {items.map(f => ( - <FilterableFacet key={f.Name} link={link} {...f} /> - ))} - </ul> - </div> - ); - } - return result; -} - -function QueryFacetFilter({ Name, Value, Filter, endpoint }: { Name: string; Value: string; Filter: string; endpoint: string }) { - const short = Name.length > 32 ? `${Name.substring(0, 32)}...` : Name; - const link = Name !== "n/a" ? `${endpoint}?filter=${Filter}` : undefined; - return ( - <li> - {link ? ( - short - ) : ( - <a className="link-primary app-link" href={link}> - {short} - </a> - )}{" "} - <sup>{Value}</sup> - </li> - ); -} - -function QueryFacet({ Name, Top, endpoint }: { Name: string; Top: { Name: string; Value: string; Filter: string }[]; endpoint: string }) { - return Top.length == 0 ? ( - <></> - ) : ( - <div key={Name} className="search-facet"> - <strong>{Name}</strong> - <ul> - {Top.map(f => ( - <QueryFacetFilter key={f.Name} endpoint={endpoint} {...f} /> - ))} - </ul> - </div> - ); -} - -export type Top = { Name: string; Value: string; Filter: string }[]; - -export type Facet = { Name: string; Top: Top }; - -export function QueryFacets({ Facets, endpoint }: { Facets?: Facet[]; endpoint: string }) { - return <>{Facets && Facets.map(f => <QueryFacet key={f.Name} endpoint={endpoint} {...f} />)}</>; -} - -export function useInterval(callback: () => any, delay?: number) { - // https://overreacted.io/making-setinterval-declarative-with-react-hooks/ - const savedCallback = useRef<() => number>(); - useEffect(() => { - savedCallback.current = callback; - }); - useEffect(() => { - function tick() { - if (savedCallback.current) { - savedCallback.current(); - } - } - if (delay !== undefined) { - let id = setInterval(tick, delay); - return () => clearInterval(id); - } - }, [delay]); -} - -export function useTitle(title: string) { - useEffect(() => { - const prev = document.title; - document.title = `${title} - slrp`; - return () => { - document.title = prev; - }; - }, [title]); -} <!-- URL input box at the bottom --> <form method="GET" action=""> <label for="targeturl-bottom"><b>Enter URL:</b></label> <input type="text" id="targeturl-bottom" name="u" value="http://github.com/nfx/slrp/pull/129.diff" required><br><small> <label for="useWeserv-bottom">Disable Weserv Image Reduction:</label> <input type="checkbox" id="useWeserv-bottom" name="useWeserv" value="false"><br> <label for="stripJS-bottom">Strip JavaScript:</label> <input type="checkbox" id="stripJS-bottom" name="stripJS" value="true"><br> <label for="stripImages-bottom">Strip Images:</label> <input type="checkbox" id="stripImages-bottom" name="stripImages" value="true"><br> <label for="stripFnts-bottom">Stripout Font Forcing:</label> <input type="checkbox" id="stripFnts-bottom" name="stripFnts" value="true"><br> <label for="stripCSS-bottom">Strip CSS:</label> <input type="checkbox" id="stripCSS-bottom" name="stripCSS" value="true"><br> <label for="stripVideos-bottom">Strip Videos:</label> <input type="checkbox" id="stripVideos-bottom" name="stripVideos" value="true"><br> <label for="removeMenus-bottom">Remove Headers and Menus:</label> <input type="checkbox" id="removeMenus-bottom" name="removeMenus" value="true"><br></small> <!-- New form elements Sandwich Strip --> <label for="start"><small>Remove from after:</label> <input type="text" id="start" name="start" value="<body>"> <label for="end"><small>to before:</label> <input type="text" id="end" name="end"> <input type="checkbox" id="applySandwichStrip" name="applySandwichStrip" value="1" onclick="submitForm()"> ApplySandwichStrip<br></small> <button type="submit">Fetch</button> </form><!-- Header banner at the bottom --> <p><h1><a href="http://clevelandohioweatherforecast.com//pFad.php?u=" title="pFad">pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier! <i>Saves Data!</i></a></h1><br><em>--- a PPN by Garber Painting Akron. <b> With Image Size Reduction </b>included!</em></p><p>Fetched URL: <a href="http://github.com/nfx/slrp/pull/129.diff" target="_blank">http://github.com/nfx/slrp/pull/129.diff</a></p><p>Alternative Proxies:</p><p><a href="http://clevelandohioweatherforecast.com/php-proxy/index.php?q=http://github.com/nfx/slrp/pull/129.diff" target="_blank">Alternative Proxy</a></p><p><a href="http://clevelandohioweatherforecast.com/pFad/index.php?u=http://github.com/nfx/slrp/pull/129.diff&useWeserv=true" target="_blank">pFad Proxy</a></p><p><a href="http://clevelandohioweatherforecast.com/pFad/v3index.php?u=http://github.com/nfx/slrp/pull/129.diff&useWeserv=true" target="_blank">pFad v3 Proxy</a></p><p><a href="http://clevelandohioweatherforecast.com/pFad/v4index.php?u=http://github.com/nfx/slrp/pull/129.diff&useWeserv=true" target="_blank">pFad v4 Proxy</a></p>