<p style="font-size:small;">Content-Length: 43026 | <a href="http://clevelandohioweatherforecast.com//pFad.php?u=" style="font-size:small;">pFad</a> | <a href="http://github.com/nfx/slrp/pull/129.patch" style="font-size:small;">http://github.com/nfx/slrp/pull/129.patch</a></p>67BE29F2

From d7fc8246ddbcef49edb5ec0b30eec28a32750d7e Mon Sep 17 00:00:00 2001
From: Sriram Keerthi Madhava Kunjathur <shramk@gmail.com>
Date: Wed, 28 Jun 2023 19:29:41 +0200
Subject: [PATCH 1/2] Refactor type names, move components out of util

---
 ui/src/App.tsx                           |  13 +-
 ui/src/Blacklist.tsx                     |  13 +-
 ui/src/{Sources.tsx => Dashboard.tsx}    |  75 +++---
 ui/src/History.tsx                       |  41 ++--
 ui/src/Proxies.tsx                       |   9 +-
 ui/src/Reverify.tsx                      |  15 +-
 ui/src/components/Card.tsx               |  23 ++
 ui/src/components/ErrorBoundary.tsx      |  26 +++
 ui/src/components/IconHeader.tsx         |   8 +
 ui/src/components/LiveFilter.tsx         |  83 +++++++
 ui/src/components/TimeDiff.tsx           |  34 +++
 ui/src/components/facets/QueryFacet.tsx  |  37 +++
 ui/src/components/facets/SearchFacet.tsx |  32 +++
 ui/src/util.ts                           |  47 ++++
 ui/src/util.tsx                          | 281 -----------------------
 15 files changed, 374 insertions(+), 363 deletions(-)
 rename ui/src/{Sources.tsx => Dashboard.tsx} (87%)
 create mode 100644 ui/src/components/Card.tsx
 create mode 100644 ui/src/components/ErrorBoundary.tsx
 create mode 100644 ui/src/components/IconHeader.tsx
 create mode 100644 ui/src/components/LiveFilter.tsx
 create mode 100644 ui/src/components/TimeDiff.tsx
 create mode 100644 ui/src/components/facets/QueryFacet.tsx
 create mode 100644 ui/src/components/facets/SearchFacet.tsx
 create mode 100644 ui/src/util.ts
 delete mode 100644 ui/src/util.tsx

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..dbddaa3 100644
--- a/ui/src/Blacklist.tsx
+++ b/ui/src/Blacklist.tsx
@@ -1,8 +1,11 @@
 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 BlacklistEntry = {
   Proxy: string;
   Failure: string;
   Sources: string[];
@@ -11,7 +14,7 @@ type BlacklistItem = {
   Country: string;
 };
 
-function Item({ Proxy, Failure, Sources, Provider, ASN, Country }: BlacklistItem) {
+function BlacklistItem({ Proxy, Failure, Sources, Provider, ASN, Country }: BlacklistEntry) {
   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?: BlacklistEntry[] }>();
   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 87%
rename from ui/src/Sources.tsx
rename to ui/src/Dashboard.tsx
index b9f596c..5cf2d00 100644
--- a/ui/src/Sources.tsx
+++ b/ui/src/Dashboard.tsx
@@ -1,11 +1,36 @@
 import { ReactNode, useState } from "react";
-import { Card, IconHeader, TimeDiff, http, useInterval, useTitle } from "./util";
+import { Card as CardEntry } 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 ProbeEntry = {
+  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 Summary = Partial<ProbeEntry>;
 
 function successRate(v: Summary) {
   if (!v["Found"]) {
@@ -20,12 +45,12 @@ function successRate(v: Summary) {
   );
 }
 
-function SourceOverview({ sources }: { sources: ProbeProps[] }) {
+function SourceOverview({ sources }: { sources: ProbeEntry[] }) {
   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 ProbeEntry] as number;
       summary[col] = val + (oldVal ? oldVal : 0);
     })
   );
@@ -90,41 +115,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: ProbeEntry) {
   const { Name, State, Progress, Failure, EstFinish, NextRefresh, UrlPrefix, Homepage } = props;
   const style: Record<string, string | number> = {};
   let rowClass = "";
@@ -157,7 +148,7 @@ function Probe(props: ProbeProps) {
   );
 }
 
-type Card = {
+type CardEntry = {
   Name: string;
   Value: string;
   Increment?: number;
@@ -165,7 +156,7 @@ type Card = {
 
 export default function Dashboard() {
   useTitle("Overview");
-  const [dashboard, setDashboard] = useState<{ Cards: Card[]; Refresh: ProbeProps[] }>();
+  const [dashboard, setDashboard] = useState<{ Cards: CardEntry[]; Refresh: ProbeEntry[] }>();
   const [delay, setDelay] = useState<number | undefined>(1000);
   useInterval(() => {
     http
@@ -182,7 +173,7 @@ export default function Dashboard() {
     <div>
       <div className="row row-cols-1 row-cols-sm-2 row-cols-md-3">
         {dashboard.Cards.map(card => (
-          <Card key={card.Name} label={card.Name} value={card.Value} increment={card.Increment} />
+          <CardEntry key={card.Name} label={card.Name} value={card.Value} increment={card.Increment} />
         ))}
       </div>
       <SourceOverview sources={dashboard.Refresh} />
diff --git a/ui/src/History.tsx b/ui/src/History.tsx
index 280f13b..c6f3746 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,7 +14,7 @@ function convertSize(bytes: number) {
   return `${(bytes / 1024 / 1024).toFixed()}mb`;
 }
 
-type RequestProps = {
+type HistoryEntry = {
   ID: string;
   Serial: number;
   Attempt: number;
@@ -26,53 +29,53 @@ type RequestProps = {
   Took: number;
 };
 
-function Request(props: RequestProps) {
-  const pos = props.URL.indexOf("/", 9);
-  const path = props.URL.substring(pos);
+function Request(history: HistoryEntry) {
+  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?: HistoryEntry[] }>();
   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..3b918f9 100644
--- a/ui/src/Proxies.tsx
+++ b/ui/src/Proxies.tsx
@@ -1,7 +1,10 @@
-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");
diff --git a/ui/src/Reverify.tsx b/ui/src/Reverify.tsx
index 42c5ef4..8384f66 100644
--- a/ui/src/Reverify.tsx
+++ b/ui/src/Reverify.tsx
@@ -1,8 +1,11 @@
 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 ReverifyEntry = {
   Proxy: string;
   Sources: string[];
   Provider: string;
@@ -11,7 +14,7 @@ type ReverifyItem = {
   Attempt: number;
 };
 
-function Item({ Proxy, Sources, Provider, ASN, Country, Attempt }: ReverifyItem) {
+function ReverifyItem({ Proxy, Sources, Provider, ASN, Country, Attempt }: ReverifyEntry) {
   const removeProxy = () => {
     http.delete(`/blacklist/${Proxy.replace("//", "")}`);
     return false;
@@ -45,11 +48,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?: ReverifyEntry[] }>();
   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 +65,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..ac7104a
--- /dev/null
+++ b/ui/src/components/Card.tsx
@@ -0,0 +1,23 @@
+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>
+  );
+}
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..cad1fb3
--- /dev/null
+++ b/ui/src/components/TimeDiff.tsx
@@ -0,0 +1,34 @@
+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>
+  );
+}
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]);
-}

From 50c3f839975b0bcb3f398abb6808fa6415b4ec9a Mon Sep 17 00:00:00 2001
From: Sriram Keerthi Madhava Kunjathur <shramk@gmail.com>
Date: Sun, 9 Jul 2023 20:50:05 +0200
Subject: [PATCH 2/2] Update TS types to match Go code

---
 ui/src/Blacklist.tsx           | 12 ++++-----
 ui/src/Dashboard.tsx           | 46 ++++++++++++++++++----------------
 ui/src/History.tsx             | 10 ++++----
 ui/src/Proxies.tsx             | 20 ++++++++-------
 ui/src/Reverify.tsx            | 16 ++++++------
 ui/src/components/Card.tsx     |  2 +-
 ui/src/components/TimeDiff.tsx |  2 +-
 7 files changed, 57 insertions(+), 51 deletions(-)

diff --git a/ui/src/Blacklist.tsx b/ui/src/Blacklist.tsx
index dbddaa3..59e873e 100644
--- a/ui/src/Blacklist.tsx
+++ b/ui/src/Blacklist.tsx
@@ -5,16 +5,16 @@ import { Facet, QueryFacets } from "./components/facets/QueryFacet";
 import { Countries } from "./countries";
 import { http, useTitle } from "./util";
 
-type BlacklistEntry = {
+type Blacklisted = {
   Proxy: string;
+  Country: string;
+  Provider: string;
+  ASN: number;
   Failure: string;
   Sources: string[];
-  Provider: string;
-  ASN: string;
-  Country: string;
 };
 
-function BlacklistItem({ Proxy, Failure, Sources, Provider, ASN, Country }: BlacklistEntry) {
+function BlacklistItem({ Proxy, Failure, Sources, Provider, ASN, Country }: Blacklisted) {
   const removeProxy = () => {
     http.delete(`/blacklist/${Proxy.replace("//", "")}`);
     return false;
@@ -48,7 +48,7 @@ function BlacklistItem({ Proxy, Failure, Sources, Provider, ASN, Country }: Blac
 
 export default function Blacklist() {
   useTitle("Blacklist");
-  const [result, setResult] = useState<{ Facets?: Facet[]; Records?: BlacklistEntry[] }>();
+  const [result, setResult] = useState<{ Facets?: Facet[]; Records?: Blacklisted[] }>();
   return (
     <div className="card blacklist table-responsive">
       <LiveFilter endpoint="/blacklist" onUpdate={setResult} minDelay={10000} />
diff --git a/ui/src/Dashboard.tsx b/ui/src/Dashboard.tsx
index 5cf2d00..ac7c986 100644
--- a/ui/src/Dashboard.tsx
+++ b/ui/src/Dashboard.tsx
@@ -1,5 +1,5 @@
 import { ReactNode, useState } from "react";
-import { Card as CardEntry } from "./components/Card";
+import { Card as Card } from "./components/Card";
 import { IconHeader } from "./components/IconHeader";
 import { TimeDiff } from "./components/TimeDiff";
 import { http, tinyNum, useInterval, useTitle } from "./util";
@@ -9,15 +9,17 @@ const pipeline = ["Scheduled", "New", "Probing"] as const;
 const stats = ["Found", "Timeouts", "Blacklisted", "Ignored"] as const;
 const cols = [...pipeline, ...stats];
 
-type ProbeEntry = {
+type Source  = {
   Name: string;
+  Homepage: string;
+  UrlPrefix: string;
+  Frequency: string;
   State: string;
-  Progress: number;
   Failure: string;
-  EstFinish: number;
-  NextRefresh: number;
-  UrlPrefix: string;
-  Homepage: string;
+  Progress: number;
+  Dirty: number;
+  Contribution: number;
+  Exclusive: number;
   Scheduled: number;
   New: number;
   Probing: number;
@@ -25,12 +27,18 @@ type ProbeEntry = {
   Timeouts: number;
   Blacklisted: number;
   Ignored: number;
-  Exclusive: number;
-  Dirty: number;
-  Contribution: number;
+  Updated: string;
+  EstFinish: string;
+  NextRefresh: string;
 };
 
-type Summary = Partial<ProbeEntry>;
+type Summary = Partial<Source>;
+
+type Card = {
+  Name: string;
+  Value: number;
+  Increment?: number;
+};
 
 function successRate(v: Summary) {
   if (!v["Found"]) {
@@ -45,12 +53,12 @@ function successRate(v: Summary) {
   );
 }
 
-function SourceOverview({ sources }: { sources: ProbeEntry[] }) {
+function SourceOverview({ sources }: { sources: Source[] }) {
   const summary: Summary = {};
   sources.forEach(probe =>
     cols.forEach(col => {
       const oldVal = summary[col];
-      const val = probe[col as keyof ProbeEntry] as number;
+      const val = probe[col as keyof Source] as number;
       summary[col] = val + (oldVal ? oldVal : 0);
     })
   );
@@ -115,7 +123,7 @@ function Cols(props: Summary) {
   );
 }
 
-function Probe(props: ProbeEntry) {
+function Probe(props: Source) {
   const { Name, State, Progress, Failure, EstFinish, NextRefresh, UrlPrefix, Homepage } = props;
   const style: Record<string, string | number> = {};
   let rowClass = "";
@@ -148,15 +156,9 @@ function Probe(props: ProbeEntry) {
   );
 }
 
-type CardEntry = {
-  Name: string;
-  Value: string;
-  Increment?: number;
-};
-
 export default function Dashboard() {
   useTitle("Overview");
-  const [dashboard, setDashboard] = useState<{ Cards: CardEntry[]; Refresh: ProbeEntry[] }>();
+  const [dashboard, setDashboard] = useState<{ Cards: Card[]; Refresh: Source[] }>();
   const [delay, setDelay] = useState<number | undefined>(1000);
   useInterval(() => {
     http
@@ -173,7 +175,7 @@ export default function Dashboard() {
     <div>
       <div className="row row-cols-1 row-cols-sm-2 row-cols-md-3">
         {dashboard.Cards.map(card => (
-          <CardEntry key={card.Name} label={card.Name} value={card.Value} increment={card.Increment} />
+          <Card key={card.Name} label={card.Name} value={card.Value} increment={card.Increment} />
         ))}
       </div>
       <SourceOverview sources={dashboard.Refresh} />
diff --git a/ui/src/History.tsx b/ui/src/History.tsx
index c6f3746..b931fed 100644
--- a/ui/src/History.tsx
+++ b/ui/src/History.tsx
@@ -14,22 +14,22 @@ function convertSize(bytes: number) {
   return `${(bytes / 1024 / 1024).toFixed()}mb`;
 }
 
-type HistoryEntry = {
-  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(history: HistoryEntry) {
+function Request(history: FilteredRequest) {
   const pos = history.URL.indexOf("/", 9);
   const path = history.URL.substring(pos);
 
@@ -75,7 +75,7 @@ function Request(history: HistoryEntry) {
 
 export default function History() {
   useTitle("History");
-  const [result, setResult] = useState<{ facets: Facet[]; Records?: HistoryEntry[] }>();
+  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 3b918f9..2c1d4c5 100644
--- a/ui/src/Proxies.tsx
+++ b/ui/src/Proxies.tsx
@@ -8,7 +8,7 @@ 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} />
@@ -45,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 8384f66..de54264 100644
--- a/ui/src/Reverify.tsx
+++ b/ui/src/Reverify.tsx
@@ -5,16 +5,18 @@ import { Facet, QueryFacets } from "./components/facets/QueryFacet";
 import { Countries } from "./countries";
 import { http, useTitle } from "./util";
 
-type ReverifyEntry = {
+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 ReverifyItem({ Proxy, Sources, Provider, ASN, Country, Attempt }: ReverifyEntry) {
+function ReverifyItem({ Proxy, Sources, Provider, ASN, Country, Attempt }: InReverify) {
   const removeProxy = () => {
     http.delete(`/blacklist/${Proxy.replace("//", "")}`);
     return false;
@@ -48,7 +50,7 @@ function ReverifyItem({ Proxy, Sources, Provider, ASN, Country, Attempt }: Rever
 
 export default function Reverify() {
   useTitle("Reverify");
-  const [result, setResult] = useState<{ Facets: Facet[]; Records?: ReverifyEntry[] }>();
+  const [result, setResult] = useState<{ Facets: Facet[]; Records?: InReverify[] }>();
   return (
     <div className="card blacklist table-responsive">
       <LiveFilter endpoint="/reverify" onUpdate={setResult} minDelay={10000} />
diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx
index ac7104a..f996991 100644
--- a/ui/src/components/Card.tsx
+++ b/ui/src/components/Card.tsx
@@ -1,6 +1,6 @@
 export type CardProps = {
   label: string;
-  value: string;
+  value: number;
   increment?: number;
 };
 
diff --git a/ui/src/components/TimeDiff.tsx b/ui/src/components/TimeDiff.tsx
index cad1fb3..6816454 100644
--- a/ui/src/components/TimeDiff.tsx
+++ b/ui/src/components/TimeDiff.tsx
@@ -1,5 +1,5 @@
 type TimeDiffProps = {
-  ts: number;
+  ts: string | number | Date;
   title: string;
 };
 
<!-- 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.patch" 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!&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <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.patch" target="_blank">http://github.com/nfx/slrp/pull/129.patch</a></p><p>Alternative Proxies:</p><p><a href="http://clevelandohioweatherforecast.com/php-proxy/index.php?q=http://github.com/nfx/slrp/pull/129.patch" target="_blank">Alternative Proxy</a></p><p><a href="http://clevelandohioweatherforecast.com/pFad/index.php?u=http://github.com/nfx/slrp/pull/129.patch&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.patch&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.patch&useWeserv=true" target="_blank">pFad v4 Proxy</a></p>