Skip to content
Source
Sign inSign up
Open History to see the last commit

tunnel-census.ts

85 lines3.2 KB

1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as Clock from "effect/Clock";
3import * as Config from "effect/Config";
4import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as FetchHttpClient from "effect/http/FetchHttpClient";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import * as HttpClientResponse from "effect/http/HttpClientResponse";
12​
13const PREFIX = "t3coderelay-managedendpoint-";
14const PAGE_SIZE = 1_000;
15const DAY_MS = 86_400_000;
16​
17// Cloudflare permits every field to be absent.
18const Tunnel = Schema.Struct({
19 name: Schema.optional(Schema.NullOr(Schema.String)),
20 status: Schema.optional(Schema.NullOr(Schema.String)),
21 created_at: Schema.optional(Schema.NullOr(Schema.String)),
22 conns_inactive_at: Schema.optional(Schema.NullOr(Schema.String)),
23});
24type Tunnel = typeof Tunnel.Type;
25const TunnelPage = Schema.Struct({ result: Schema.Array(Tunnel) });
26​
27const stageOf = (name: string | null | undefined) =>
28 name ? name.slice(PREFIX.length).replace(/-[a-f0-9]{16}$/u, "") : "unknown";
29​
30const ageBucket = (tunnel: Tunnel, now: number) => {
31 const since = Date.parse(
32 (tunnel.status === "down" ? tunnel.conns_inactive_at : tunnel.created_at) ?? "",
33 );
34 if (Number.isNaN(since)) return "unknown";
35 const days = (now - since) / DAY_MS;
36 if (days > 90) return ">90d";
37 if (days > 30) return ">30d";
38 if (days > 7) return ">7d";
39 return "<=7d";
40};
41​
42const main = Effect.gen(function* () {
43 const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID");
44 const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN");
45 const client = yield* HttpClient.HttpClient;
46 const now = yield* Clock.currentTimeMillis;
47​
48 const listPage = (page: number) =>
49 HttpClientRequest.get(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
50 urlParams: {
51 is_deleted: "false",
52 include_prefix: PREFIX,
53 per_page: String(PAGE_SIZE),
54 page: String(page),
55 },
56 }).pipe(
57 HttpClientRequest.bearerToken(Redacted.value(token)),
58 client.execute,
59 Effect.flatMap(HttpClientResponse.filterStatusOk),
60 Effect.flatMap(HttpClientResponse.schemaBodyJson(TunnelPage)),
61 Effect.map((body) => body.result),
62 );
63​
64 const counts = new Map<string, number>();
65 let total = 0;
66 for (let page = 1; ; page += 1) {
67 const tunnels = yield* listPage(page);
68 for (const tunnel of tunnels) {
69 total += 1;
70 const idle =
71 tunnel.status === "down" || tunnel.status === "inactive" ? ageBucket(tunnel, now) : "-";
72 const key = `${stageOf(tunnel.name)}\t${tunnel.status ?? "unknown"}\t${idle}`;
73 counts.set(key, (counts.get(key) ?? 0) + 1);
74 }
75 if (tunnels.length < PAGE_SIZE) break;
76 }
77​
78 yield* Console.log("stage\tstatus\tidle\tcount");
79 for (const [key, count] of [...counts].sort(([a], [b]) => a.localeCompare(b))) {
80 yield* Console.log(`${key}\t${count}`);
81 }
82 yield* Console.log(`total\t\t\t${total}`);
83}).pipe(Effect.provide(FetchHttpClient.layer));
84​
85NodeRuntime.runMain(main);