@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +143 -2
- package/bin/snowglobe.mjs +69 -0
- package/lib/apps.mjs +97 -0
- package/lib/args.mjs +29 -0
- package/lib/certs.mjs +211 -0
- package/lib/commands/account.mjs +85 -0
- package/lib/commands/app.mjs +86 -0
- package/lib/commands/certs.mjs +36 -0
- package/lib/commands/check.mjs +37 -0
- package/lib/commands/down.mjs +37 -0
- package/lib/commands/env.mjs +145 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/render.mjs +63 -0
- package/lib/commands/run.mjs +109 -0
- package/lib/commands/trust.mjs +45 -0
- package/lib/commands/up.mjs +257 -0
- package/lib/config.mjs +485 -0
- package/lib/env.mjs +364 -0
- package/lib/hosts.mjs +36 -0
- package/lib/render.mjs +570 -0
- package/lib/settings.mjs +146 -0
- package/lib/state.mjs +75 -0
- package/lib/trust.mjs +288 -0
- package/lib/up.mjs +54 -0
- package/lib/xrpc.mjs +49 -0
- package/package.json +55 -4
- package/snowglobe.default.yml +54 -0
- package/stack/docker-compose.relay.yml +105 -0
- package/stack/docker-compose.test.yml +41 -0
- package/stack/docker-compose.yml +178 -0
- package/stack/https/app-names.cjs +31 -0
- package/stack/https/dns/Dockerfile +10 -0
- package/stack/https/dns/dns.sh +40 -0
- package/stack/https/nginx.conf.template +104 -0
- package/stack/https/proxy/Dockerfile +8 -0
- package/stack/https/proxy/server.mjs +213 -0
- package/stack/init/init.sh +118 -0
- package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
- package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
- package/stack/page/index.html +409 -0
- package/stack/page/snowglobe.conf +59 -0
- package/stack/pdsls/Dockerfile +27 -0
- package/stack/pdsls/network.patch +294 -0
- package/stack/pdsls/server.mjs +192 -0
- package/stack/relay/Dockerfile +30 -0
- package/stack/relay/allow-private-hosts.patch +55 -0
- package/stack/slingshot/Dockerfile +36 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Snowglobe network</title>
|
|
7
|
+
<link rel="preload" href="/fonts/schibsted-grotesk-latin.woff2" as="font" type="font/woff2" crossorigin>
|
|
8
|
+
<style>
|
|
9
|
+
/* The Snowglobe look, winter blue and gold: the colors and type snowglobe.at shares with this page.
|
|
10
|
+
Light by default, dark when the viewer's system asks for it. */
|
|
11
|
+
/* The fonts are served with this page, not from a font service, so opening it asks nothing of
|
|
12
|
+
another site. The same files as snowglobe.at's (page/fonts/, with their licenses). */
|
|
13
|
+
@font-face { font-family: "Schibsted Grotesk"; font-weight: 400 700; font-display: swap; src: url(/fonts/schibsted-grotesk-latin.woff2) format("woff2"); }
|
|
14
|
+
@font-face { font-family: "JetBrains Mono"; font-weight: 400 500; font-display: swap; src: url(/fonts/jetbrains-mono-latin.woff2) format("woff2"); }
|
|
15
|
+
:root {
|
|
16
|
+
--font: "Schibsted Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
+
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
18
|
+
--bg: #F1F5FA; --panel: #FFFFFF; --ink: #12213A; --muted: #44546B; --line: #D5DFEC; --line2: #E4EBF4;
|
|
19
|
+
--accent: #1F4F9A; --accent-hover: #163A73; --btn: #1F4F9A; --btn-hover: #163A73; --btn-ink: #FFFFFF;
|
|
20
|
+
--tint: #FFF4DC; --tint-line: #EFD597; --focus: #D99A1E; --mark: #D99A1E;
|
|
21
|
+
--warn-bg: #FFF1C7; --warn-ink: #5C4300; --warn-line: #F0D98A;
|
|
22
|
+
--up: #1F8A5B; --down: #B42318; --down-ring: #FBD5D1;
|
|
23
|
+
color-scheme: light dark;
|
|
24
|
+
}
|
|
25
|
+
@media (prefers-color-scheme: dark) {
|
|
26
|
+
:root {
|
|
27
|
+
--bg: #0E1A2E; --panel: #15243C; --ink: #E6EDF7; --muted: #A5B4CA; --line: #263A58; --line2: #20324D;
|
|
28
|
+
--accent: #F2C46B; --accent-hover: #F7D894; --btn: #E3A92E; --btn-hover: #F0BC4E; --btn-ink: #12213A;
|
|
29
|
+
--tint: #2A2414; --tint-line: #5A4720; --focus: #F2C46B; --mark: #F2C46B;
|
|
30
|
+
--warn-bg: #FFD98A; --warn-ink: #3A2A00; --warn-line: #FFD98A;
|
|
31
|
+
--up: #5FD39A; --down: #FF8A7A; --down-ring: #4A1F1A;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
* { box-sizing: border-box; }
|
|
35
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 var(--font); }
|
|
36
|
+
a { color: var(--accent); }
|
|
37
|
+
a:hover { color: var(--accent-hover); }
|
|
38
|
+
a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
|
39
|
+
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 32px; }
|
|
40
|
+
.mono { font-family: var(--font-mono); font-size: 0.84em; overflow-wrap: anywhere; }
|
|
41
|
+
|
|
42
|
+
.demo { background: var(--warn-bg); color: var(--warn-ink); border-bottom: 1px solid var(--warn-line); }
|
|
43
|
+
.demo .wrap { display: flex; gap: 10px; align-items: center; padding-top: 12px; padding-bottom: 12px; font-weight: 500; }
|
|
44
|
+
.demo svg { width: 20px; height: 20px; flex: none; fill: none; stroke: var(--warn-ink); stroke-width: 2; }
|
|
45
|
+
|
|
46
|
+
.nav { display: flex; align-items: center; justify-content: space-between; gap: 12px 16px; flex-wrap: wrap; padding: 20px 0; }
|
|
47
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; color: var(--ink); text-decoration: none; }
|
|
48
|
+
.brand:hover { color: var(--ink); }
|
|
49
|
+
.mk { width: 28px; height: 28px; flex: none; }
|
|
50
|
+
.mk .dome { fill: none; stroke: currentColor; stroke-width: 2; }
|
|
51
|
+
.mk .node { fill: currentColor; }
|
|
52
|
+
.mk .base { fill: var(--mark); }
|
|
53
|
+
|
|
54
|
+
.head { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 48px; align-items: end; padding: 40px 0 48px; }
|
|
55
|
+
.head h1 { font-size: clamp(48px, 7vw, 88px); line-height: 1; letter-spacing: -0.03em; font-weight: 650; margin: 0 0 12px; overflow-wrap: anywhere; }
|
|
56
|
+
.sub { color: var(--muted); font-size: 18px; margin: 0; }
|
|
57
|
+
.state { display: flex; flex-direction: column; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 20px 24px; }
|
|
58
|
+
.state b { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
|
59
|
+
.state > span { color: var(--muted); }
|
|
60
|
+
|
|
61
|
+
.dot { width: 12px; height: 12px; border-radius: 50%; flex: none; display: inline-block; background: var(--line); }
|
|
62
|
+
.dot.up { background: var(--up); }
|
|
63
|
+
.dot.down { background: var(--down); box-shadow: 0 0 0 3px var(--down-ring); }
|
|
64
|
+
|
|
65
|
+
.grid2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; }
|
|
66
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 28px; }
|
|
67
|
+
.panel h2 { font-size: 22px; font-weight: 600; margin: 0 0 12px; letter-spacing: -0.01em; }
|
|
68
|
+
.panel p { margin: 0 0 12px; color: var(--muted); }
|
|
69
|
+
.panel p:last-child { margin-bottom: 0; }
|
|
70
|
+
.blk { margin-top: 24px; }
|
|
71
|
+
|
|
72
|
+
.apps { display: flex; flex-direction: column; gap: 12px; }
|
|
73
|
+
.app { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; border: 1px solid var(--tint-line); background: var(--tint); border-radius: 16px; padding: 18px 20px; }
|
|
74
|
+
.app b { display: block; font-size: 19px; }
|
|
75
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 20px; border-radius: 999px; background: var(--btn); color: var(--btn-ink); font-weight: 600; text-decoration: none; }
|
|
76
|
+
.btn:hover { background: var(--btn-hover); color: var(--btn-ink); }
|
|
77
|
+
|
|
78
|
+
.row { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.5fr) minmax(0, 1.3fr) 150px; gap: 16px; align-items: center; padding: 14px 0; border-top: 1px solid var(--line2); }
|
|
79
|
+
.row:first-child { border-top: 0; }
|
|
80
|
+
.row b { font-weight: 600; }
|
|
81
|
+
.row .what { color: var(--muted); }
|
|
82
|
+
.st { display: flex; align-items: center; gap: 8px; font-weight: 500; }
|
|
83
|
+
.st.down { color: var(--down); }
|
|
84
|
+
.checked { color: var(--muted); font-size: 15px; margin: 12px 0 0; }
|
|
85
|
+
|
|
86
|
+
.acct h3 { font-size: 17px; margin: 20px 0 4px; font-weight: 600; }
|
|
87
|
+
.list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 24px; }
|
|
88
|
+
.who { padding: 9px 0; border-top: 1px solid var(--line2); min-width: 0; }
|
|
89
|
+
.who b { font-weight: 600; display: block; overflow-wrap: anywhere; }
|
|
90
|
+
.who .mono { color: var(--muted); font-size: 12.5px; display: block; }
|
|
91
|
+
|
|
92
|
+
.feedhead { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
93
|
+
.live { display: inline-flex; align-items: center; gap: 8px; background: var(--tint); color: var(--accent); border-radius: 999px; padding: 4px 12px; font-weight: 600; font-size: 14px; }
|
|
94
|
+
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
|
95
|
+
.live.on .pulse { background: var(--up); animation: pl 2s ease-in-out infinite; }
|
|
96
|
+
@keyframes pl { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
|
97
|
+
@media (prefers-reduced-motion: reduce) { .live.on .pulse { animation: none; } }
|
|
98
|
+
.feed { max-height: 620px; overflow-y: auto; }
|
|
99
|
+
.ev { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 16px; padding: 12px 0; border-top: 1px solid var(--line2); }
|
|
100
|
+
.ev time { color: var(--muted); font-size: 14px; }
|
|
101
|
+
.ev b { font-weight: 600; overflow-wrap: anywhere; }
|
|
102
|
+
.ev .mono { color: var(--muted); font-size: 12.5px; display: block; }
|
|
103
|
+
.empty { color: var(--muted); padding: 9px 0; }
|
|
104
|
+
|
|
105
|
+
.foot { padding: 40px 0 56px; color: var(--muted); font-size: 15px; }
|
|
106
|
+
|
|
107
|
+
@media (max-width: 820px) {
|
|
108
|
+
.wrap { padding: 0 16px; }
|
|
109
|
+
.head, .grid2 { grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
|
110
|
+
.head { padding: 16px 0 24px; }
|
|
111
|
+
.head h1 { font-size: 52px; }
|
|
112
|
+
.sub { font-size: 16px; }
|
|
113
|
+
.blk { margin-top: 16px; }
|
|
114
|
+
.row { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 12px 0; }
|
|
115
|
+
.row .what { display: none; }
|
|
116
|
+
.row .host { grid-column: 1 / -1; font-size: 12.5px; }
|
|
117
|
+
.row .st { grid-row: 1 / 2; grid-column: 2 / 3; }
|
|
118
|
+
.panel { padding: 20px; border-radius: 16px; }
|
|
119
|
+
.list { grid-template-columns: minmax(0, 1fr); }
|
|
120
|
+
.ev { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
|
121
|
+
.feed { max-height: 480px; }
|
|
122
|
+
.app .btn { width: 100%; }
|
|
123
|
+
.demo .wrap { align-items: flex-start; font-size: 15px; }
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
126
|
+
</head>
|
|
127
|
+
<body>
|
|
128
|
+
<div class="demo" role="note">
|
|
129
|
+
<div class="wrap">
|
|
130
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5v5.5M12 16.5v.5"></path></svg>
|
|
131
|
+
<span>Demo only. Everything on this network is test data and may be lost at any time.</span>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="wrap">
|
|
135
|
+
<nav class="nav" aria-label="Snowglobe">
|
|
136
|
+
<a class="brand" href="https://snowglobe.at/">
|
|
137
|
+
<svg class="mk" viewBox="0 0 32 32" aria-hidden="true"><circle class="dome" cx="16" cy="14" r="11"></circle><path class="node" d="M9.5 22.5v-6.1l3.6-3.1 3.6 3.1v6.1z"></path><path class="node" d="M21.5 10.2l-2.7 4.3h1.4l-2.4 4.2h7.4l-2.4-4.2h1.4z"></path><rect class="node" x="20.9" y="18.6" width="1.2" height="3.9"></rect><circle class="node" cx="11.5" cy="9" r="1"></circle><circle class="node" cx="16.5" cy="7.4" r="0.9"></circle><circle class="node" cx="24.6" cy="9.6" r="0.9"></circle><path class="base" d="M9 22.5h14l2.2 5.5H6.8z"></path></svg>
|
|
138
|
+
Snowglobe
|
|
139
|
+
</a>
|
|
140
|
+
<a href="https://snowglobe.at/#local">Run a globe of your own</a>
|
|
141
|
+
</nav>
|
|
142
|
+
|
|
143
|
+
<header class="head">
|
|
144
|
+
<div>
|
|
145
|
+
<h1 id="title">Snowglobe network</h1>
|
|
146
|
+
<p class="sub" id="subtitle"></p>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="state" role="status">
|
|
149
|
+
<b><span class="dot" id="overall-dot"></span><span id="overall">Checking services</span></b>
|
|
150
|
+
<span id="tally"></span>
|
|
151
|
+
</div>
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
<div class="grid2">
|
|
155
|
+
<section class="panel">
|
|
156
|
+
<h2>What this is</h2>
|
|
157
|
+
<p id="about">An isolated atproto network: its own PDS, PLC directory, relay and Jetstream. Its accounts live in its
|
|
158
|
+
own directory, not the public one, so nothing here reaches Bluesky or the rest of the public atproto
|
|
159
|
+
network, and they cannot find anything here.</p>
|
|
160
|
+
<p>Apps point at it to test against real protocol services without touching the shared network that
|
|
161
|
+
Bluesky and everyone else use.</p>
|
|
162
|
+
</section>
|
|
163
|
+
<section class="panel">
|
|
164
|
+
<h2>Apps on this network</h2>
|
|
165
|
+
<div class="apps" id="apps"><p>None attached.</p></div>
|
|
166
|
+
</section>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<section class="panel blk" aria-labelledby="h-svc">
|
|
170
|
+
<h2 id="h-svc">Services and health</h2>
|
|
171
|
+
<div id="services"></div>
|
|
172
|
+
<p class="checked" id="checked"></p>
|
|
173
|
+
</section>
|
|
174
|
+
|
|
175
|
+
<section class="panel acct blk" aria-labelledby="h-acct">
|
|
176
|
+
<h2 id="h-acct">Accounts</h2>
|
|
177
|
+
<p><span id="accounts-note">Every account on the network: people on the PDS, groups on the group host.</span>
|
|
178
|
+
Logins for the seeded test people are kept by the network's operator, not on this page.</p>
|
|
179
|
+
<div id="accounts"><p class="empty">Loading…</p></div>
|
|
180
|
+
</section>
|
|
181
|
+
|
|
182
|
+
<section class="panel blk" aria-labelledby="h-act">
|
|
183
|
+
<div class="feedhead">
|
|
184
|
+
<h2 id="h-act" style="margin:0">Activity</h2>
|
|
185
|
+
<span class="live" id="stream"><span class="pulse"></span><span id="stream-text">Connecting</span></span>
|
|
186
|
+
</div>
|
|
187
|
+
<p><span id="count">0</span> writes in the last 24 hours, newest first, then live as they happen. Jetstream keeps a day of events, so older writes are not listed.</p>
|
|
188
|
+
<div class="feed" id="events"><p class="empty">Waiting for the next write on the network…</p></div>
|
|
189
|
+
</section>
|
|
190
|
+
|
|
191
|
+
<footer class="foot">Run a network like this on your own machine with
|
|
192
|
+
<a href="https://snowglobe.at/">Snowglobe</a>.</footer>
|
|
193
|
+
</div>
|
|
194
|
+
<script>
|
|
195
|
+
(function () {
|
|
196
|
+
// A globe's services. A network that differs (the local snowglobe) lists its own in snowglobe.json as
|
|
197
|
+
// "services": [label, name, health key, what it does] each, where a name with a dot is a whole host
|
|
198
|
+
// name, one without hangs from the network's domain, and an empty one gets no link.
|
|
199
|
+
var SERVICES = [
|
|
200
|
+
["PDS", "alpha", "pds", "Hosts accounts and their repos"],
|
|
201
|
+
["PLC directory", "plc", "plc", "Resolves each did:plc to its keys and PDS"],
|
|
202
|
+
["Relay", "relay", "relay", "Crawls the PDS and merges its changes into one firehose"],
|
|
203
|
+
["Jetstream", "jetstream", "jetstream", "The relay's firehose as JSON, for apps (and this page)"],
|
|
204
|
+
["Slingshot", "slingshot", "slingshot", "Resolves handles, DIDs and records from this network only"],
|
|
205
|
+
["Group host", "grouphost", "grouphost", "Hosts groups, which are accounts of their own"],
|
|
206
|
+
["Image CDN", "cdn", "cdn", "Serves account images in Bluesky's CDN shape"],
|
|
207
|
+
["Tap", "tap", "tap", "Syncs and backfills repos; locked by its admin password"],
|
|
208
|
+
["PDSls", "pdsls", "pdsls", "Browses this network's repos and records"]
|
|
209
|
+
];
|
|
210
|
+
// Where each kind of account is listed: [relay path, kind]. snowglobe.json may give its own as "accounts",
|
|
211
|
+
// where a kind is "person" or "group", followed by ", <server>" on a network with several.
|
|
212
|
+
var ACCOUNTS = [["/people", "person"], ["/groups", "group"]];
|
|
213
|
+
var handles = {};
|
|
214
|
+
var tally = { people: null, groups: null, writes: 0 };
|
|
215
|
+
|
|
216
|
+
function el(tag, attrs, text) {
|
|
217
|
+
var e = document.createElement(tag);
|
|
218
|
+
for (var k in attrs || {}) e.setAttribute(k, attrs[k]);
|
|
219
|
+
if (text != null) e.textContent = text;
|
|
220
|
+
return e;
|
|
221
|
+
}
|
|
222
|
+
// Appends each child that is there; returns the parent.
|
|
223
|
+
function add(parent) {
|
|
224
|
+
for (var i = 1; i < arguments.length; i++) if (arguments[i]) parent.appendChild(arguments[i]);
|
|
225
|
+
return parent;
|
|
226
|
+
}
|
|
227
|
+
function getJSON(path) {
|
|
228
|
+
return fetch(path, { cache: "no-store" }).then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); });
|
|
229
|
+
}
|
|
230
|
+
function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }
|
|
231
|
+
function showTally() {
|
|
232
|
+
var parts = [];
|
|
233
|
+
if (tally.people != null) parts.push(plural(tally.people, "person", "people"));
|
|
234
|
+
if (tally.groups) parts.push(plural(tally.groups, "group", "groups"));
|
|
235
|
+
var line = parts.length ? parts.join(" and ") + ". " : "";
|
|
236
|
+
document.getElementById("tally").textContent = line + plural(tally.writes, "write", "writes") + " in the last 24 hours.";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function health(domain, services) {
|
|
240
|
+
var body = document.getElementById("services");
|
|
241
|
+
body.textContent = "";
|
|
242
|
+
var marks = services.map(function (s) {
|
|
243
|
+
var dot = el("span", { "class": "dot" });
|
|
244
|
+
var status = el("span", {}, "checking");
|
|
245
|
+
var st = add(el("span", { "class": "st" }), dot, status);
|
|
246
|
+
var host;
|
|
247
|
+
if (s[1]) {
|
|
248
|
+
var name = s[1].indexOf(".") >= 0 ? s[1] : s[1] + "." + domain;
|
|
249
|
+
host = el("a", { "class": "mono host", href: "https://" + name + "/" }, name);
|
|
250
|
+
} else host = el("span", { "class": "mono host" });
|
|
251
|
+
add(body, add(el("div", { "class": "row" }), el("b", {}, s[0]), el("span", { "class": "what" }, s[3]), host, st));
|
|
252
|
+
return { key: s[2], dot: dot, st: st, status: status, up: null };
|
|
253
|
+
});
|
|
254
|
+
// The headline waits until every service has answered once.
|
|
255
|
+
function overall() {
|
|
256
|
+
if (marks.some(function (m) { return m.up === null; })) return;
|
|
257
|
+
var up = marks.filter(function (m) { return m.up; }).length;
|
|
258
|
+
document.getElementById("overall-dot").className = "dot " + (up === marks.length ? "up" : "down");
|
|
259
|
+
document.getElementById("overall").textContent =
|
|
260
|
+
up === marks.length ? "All " + marks.length + " services up" : up + " of " + marks.length + " services up";
|
|
261
|
+
}
|
|
262
|
+
function mark(m, ok, text) {
|
|
263
|
+
m.up = ok;
|
|
264
|
+
m.dot.className = "dot " + (ok ? "up" : "down");
|
|
265
|
+
m.st.className = "st " + (ok ? "up" : "down");
|
|
266
|
+
m.status.textContent = text;
|
|
267
|
+
overall();
|
|
268
|
+
}
|
|
269
|
+
function check() {
|
|
270
|
+
marks.forEach(function (m) {
|
|
271
|
+
fetch("/health/" + m.key, { cache: "no-store" }).then(function (r) {
|
|
272
|
+
mark(m, r.ok, r.ok ? (m.key === "tap" ? "up (locked)" : "up") : "down (" + r.status + ")");
|
|
273
|
+
}).catch(function () { mark(m, false, "no answer"); });
|
|
274
|
+
});
|
|
275
|
+
document.getElementById("checked").textContent = "Checked " + new Date().toLocaleTimeString() + "; again every 30 seconds.";
|
|
276
|
+
}
|
|
277
|
+
check();
|
|
278
|
+
setInterval(check, 30000);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function listAll(base) {
|
|
282
|
+
var out = [];
|
|
283
|
+
function page(cursor) {
|
|
284
|
+
var q = base + "/listRepos?limit=100" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : "");
|
|
285
|
+
return getJSON(q).then(function (d) {
|
|
286
|
+
var repos = d.repos || [];
|
|
287
|
+
out = out.concat(repos);
|
|
288
|
+
return repos.length === 100 && d.cursor ? page(d.cursor) : out;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return page("");
|
|
292
|
+
}
|
|
293
|
+
function accounts(kinds) {
|
|
294
|
+
var body = document.getElementById("accounts");
|
|
295
|
+
var isGroup = function (kind) { return kind.indexOf("group") === 0; };
|
|
296
|
+
// Name each account's server only when there is more than one of its kind.
|
|
297
|
+
var several = {
|
|
298
|
+
person: kinds.filter(function (k) { return !isGroup(k[1]); }).length > 1,
|
|
299
|
+
group: kinds.filter(function (k) { return isGroup(k[1]); }).length > 1
|
|
300
|
+
};
|
|
301
|
+
return Promise.all(kinds.map(function (k) {
|
|
302
|
+
return listAll(k[0]).then(function (repos) {
|
|
303
|
+
return Promise.all(repos.map(function (r) {
|
|
304
|
+
return getJSON(k[0] + "/describeRepo?repo=" + encodeURIComponent(r.did))
|
|
305
|
+
.then(function (d) { return { did: r.did, handle: d.handle, kind: k[1], active: r.active }; })
|
|
306
|
+
.catch(function () { return { did: r.did, handle: "", kind: k[1], active: r.active }; });
|
|
307
|
+
}));
|
|
308
|
+
}).catch(function () { return [{ error: k[1] }]; });
|
|
309
|
+
})).then(function (lists) {
|
|
310
|
+
body.textContent = "";
|
|
311
|
+
var all = [].concat.apply([], lists);
|
|
312
|
+
var errors = all.filter(function (a) { return a.error; });
|
|
313
|
+
all = all.filter(function (a) { return !a.error; });
|
|
314
|
+
all.sort(function (a, b) { return (a.handle || a.did).localeCompare(b.handle || b.did); });
|
|
315
|
+
var people = all.filter(function (a) { return !isGroup(a.kind); });
|
|
316
|
+
var groups = all.filter(function (a) { return isGroup(a.kind); });
|
|
317
|
+
tally.people = people.length; tally.groups = groups.length; showTally();
|
|
318
|
+
[["People", people, several.person], ["Groups", groups, several.group]].forEach(function (g) {
|
|
319
|
+
if (g[0] === "Groups" && !g[1].length) return;
|
|
320
|
+
add(body, el("h3", {}, g[0] + " (" + g[1].length + ")"));
|
|
321
|
+
var list = add(body, el("div", { "class": "list" }));
|
|
322
|
+
g[1].forEach(function (a) {
|
|
323
|
+
if (a.handle) handles[a.did] = a.handle;
|
|
324
|
+
var where = g[2] && a.kind.indexOf(", ") > 0 ? " · " + a.kind.split(", ")[1] : "";
|
|
325
|
+
add(list, add(el("div", { "class": "who" }),
|
|
326
|
+
el("b", {}, (a.handle || "(no handle)") + (a.active === false ? " (inactive)" : "")),
|
|
327
|
+
el("span", { "class": "mono" }, a.did + where)));
|
|
328
|
+
});
|
|
329
|
+
if (!g[1].length && !errors.length) add(list, el("div", { "class": "empty" }, "No accounts yet"));
|
|
330
|
+
});
|
|
331
|
+
errors.forEach(function (e) {
|
|
332
|
+
var server = e.error.split(", ")[1];
|
|
333
|
+
add(body, el("p", {}, "Could not list the " + (isGroup(e.error) ? "groups" : "people") + (server ? " on " + server : "") + "."));
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// feed: the Jetstream subscribe URL; a path is taken on this page's own host.
|
|
339
|
+
function activity(feed) {
|
|
340
|
+
var body = document.getElementById("events");
|
|
341
|
+
// Start a day back, which is as much as Jetstream keeps (its event TTL defaults to 24 hours), so a
|
|
342
|
+
// quiet network still shows what it did lately; a reconnect resumes from the last event seen. Older
|
|
343
|
+
// events are dropped here.
|
|
344
|
+
var since = (Date.now() - 86400 * 1000) * 1000;
|
|
345
|
+
var first = true, wait = 1000, cursor = since;
|
|
346
|
+
var stream = document.getElementById("stream"), streamText = document.getElementById("stream-text");
|
|
347
|
+
var verbs = { create: "created", update: "updated", "delete": "deleted" };
|
|
348
|
+
function connect() {
|
|
349
|
+
var ws = new WebSocket(feed + "?cursor=" + cursor);
|
|
350
|
+
ws.onopen = function () { stream.className = "live on"; streamText.textContent = "Live"; wait = 1000; };
|
|
351
|
+
ws.onclose = function () {
|
|
352
|
+
stream.className = "live";
|
|
353
|
+
streamText.textContent = "Reconnecting";
|
|
354
|
+
setTimeout(connect, wait);
|
|
355
|
+
wait = Math.min(wait * 2, 30000);
|
|
356
|
+
};
|
|
357
|
+
ws.onmessage = function (m) {
|
|
358
|
+
var e; try { e = JSON.parse(m.data); } catch (x) { return; }
|
|
359
|
+
if (e.time_us) { if (e.time_us < since) return; cursor = e.time_us; }
|
|
360
|
+
var what, detail = null;
|
|
361
|
+
if (e.kind === "commit" && e.commit) {
|
|
362
|
+
what = (verbs[e.commit.operation] || e.commit.operation) + " a record";
|
|
363
|
+
detail = e.commit.collection;
|
|
364
|
+
} else if (e.kind === "identity") what = "updated its identity";
|
|
365
|
+
else if (e.kind === "account") what = e.account && e.account.active === false ? "was deactivated" : "is active";
|
|
366
|
+
else return;
|
|
367
|
+
if (first) { body.textContent = ""; first = false; }
|
|
368
|
+
tally.writes += 1;
|
|
369
|
+
document.getElementById("count").textContent = String(tally.writes);
|
|
370
|
+
showTally();
|
|
371
|
+
var t = e.time_us ? new Date(e.time_us / 1000) : new Date();
|
|
372
|
+
var who = handles[e.did] || (e.identity && e.identity.handle) || e.did;
|
|
373
|
+
var when = t.toDateString() === new Date().toDateString() ? t.toLocaleTimeString() : t.toLocaleString();
|
|
374
|
+
var text = add(el("div"), el("b", {}, who), document.createTextNode(" " + what),
|
|
375
|
+
detail && el("span", { "class": "mono" }, detail));
|
|
376
|
+
body.insertBefore(add(el("div", { "class": "ev" }), el("time", { datetime: t.toISOString() }, when), text), body.firstChild);
|
|
377
|
+
while (body.children.length > 50) body.removeChild(body.lastChild);
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
connect();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
getJSON("/snowglobe.json").then(function (g) {
|
|
384
|
+
document.title = g.globe + " · Snowglobe network";
|
|
385
|
+
document.getElementById("title").textContent = g.globe;
|
|
386
|
+
document.getElementById("subtitle").textContent = "A Snowglobe network at " + g.domain;
|
|
387
|
+
var apps = document.getElementById("apps");
|
|
388
|
+
if (g.apps && g.apps.length) {
|
|
389
|
+
apps.textContent = "";
|
|
390
|
+
g.apps.forEach(function (a) {
|
|
391
|
+
var host = a.url.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
392
|
+
add(apps, add(el("div", { "class": "app" }),
|
|
393
|
+
add(el("div"), el("b", {}, a.name), el("span", { "class": "mono" }, host)),
|
|
394
|
+
el("a", { "class": "btn", href: a.url }, "Open " + a.name)));
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (g.about) document.getElementById("about").textContent = g.about;
|
|
398
|
+
if (g.accountsNote) document.getElementById("accounts-note").textContent = g.accountsNote;
|
|
399
|
+
var feed = g.feed || "wss://jetstream." + g.domain + "/subscribe";
|
|
400
|
+
if (feed.charAt(0) === "/") feed = "wss://" + location.host + feed;
|
|
401
|
+
showTally();
|
|
402
|
+
health(g.domain, g.services || SERVICES);
|
|
403
|
+
// The feed names accounts by handle, so it waits for the account list.
|
|
404
|
+
accounts(g.accounts || ACCOUNTS).catch(function () {}).then(function () { activity(feed); });
|
|
405
|
+
});
|
|
406
|
+
})();
|
|
407
|
+
</script>
|
|
408
|
+
</body>
|
|
409
|
+
</html>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# The local snowglobe's page relays: included in the server for the bare
|
|
2
|
+
# SG_DOMAIN in https/nginx.conf.template, where root and snowglobe.json are set. Each relay is a fixed
|
|
3
|
+
# path to one service in the compose network, GET only. A variable upstream is looked up through
|
|
4
|
+
# Docker's DNS on each request, so a service that starts after nginx, or is recreated, is still found.
|
|
5
|
+
# The relays to each account server are rendered from snowglobe.yml (bin/snowglobe.mjs render) into
|
|
6
|
+
# the globe's nginx/page.inc, included below.
|
|
7
|
+
|
|
8
|
+
proxy_connect_timeout 3s;
|
|
9
|
+
proxy_read_timeout 5s;
|
|
10
|
+
# These replace the http block's headers here, so the feed's websocket ones are repeated.
|
|
11
|
+
proxy_set_header Host $proxy_host;
|
|
12
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
13
|
+
proxy_set_header Connection $connection_upgrade;
|
|
14
|
+
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
|
15
|
+
|
|
16
|
+
set $plc http://plc:2582;
|
|
17
|
+
set $jetstream http://jetstream:6008;
|
|
18
|
+
set $slingshot http://slingshot-dns:8080;
|
|
19
|
+
set $maildev http://maildev:1080;
|
|
20
|
+
set $pdsls http://pdsls:8080;
|
|
21
|
+
set $relay http://relay:2470;
|
|
22
|
+
set $tap http://tap:2480;
|
|
23
|
+
|
|
24
|
+
location = / {
|
|
25
|
+
try_files /index.html =404;
|
|
26
|
+
add_header Cache-Control "no-cache";
|
|
27
|
+
}
|
|
28
|
+
location = /snowglobe.json {
|
|
29
|
+
default_type application/json;
|
|
30
|
+
add_header Cache-Control "no-cache";
|
|
31
|
+
alias /etc/nginx/snowglobe/snowglobe.json;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Health: each service's own check, or its front page where it has none.
|
|
35
|
+
location = /health/plc { proxy_pass $plc/_health; }
|
|
36
|
+
location = /health/relay { proxy_pass $relay/xrpc/_health; }
|
|
37
|
+
location = /health/jetstream { proxy_pass $jetstream/; }
|
|
38
|
+
location = /health/slingshot { proxy_pass $slingshot/; }
|
|
39
|
+
location = /health/sync { proxy_pass $tap/health; }
|
|
40
|
+
location = /health/mail { proxy_pass $maildev/healthz; }
|
|
41
|
+
location = /health/pdsls { proxy_pass $pdsls/_health; }
|
|
42
|
+
|
|
43
|
+
# Health and accounts for each account server (/health/<name>, /people/<name>/listRepos and
|
|
44
|
+
# describeRepo; /health/pds and /people/ are the PDS with bare handles, else the first). The query
|
|
45
|
+
# string passes through.
|
|
46
|
+
include /etc/nginx/globe/page.inc;
|
|
47
|
+
|
|
48
|
+
# The live feed: Jetstream's websocket, open as long as the page is.
|
|
49
|
+
location = /subscribe {
|
|
50
|
+
proxy_read_timeout 1h;
|
|
51
|
+
proxy_pass $jetstream/subscribe$is_args$args;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# The page's fonts (stack/page/fonts/), served with it so the page loads nothing from another site.
|
|
55
|
+
location ^~ /fonts/ {
|
|
56
|
+
try_files $uri =404;
|
|
57
|
+
add_header Cache-Control "public, max-age=86400";
|
|
58
|
+
}
|
|
59
|
+
location / { return 404; }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# PDSls (pds.ls), the record browser, for this network: built from a pinned upstream commit with
|
|
2
|
+
# network.patch, which lets a host page give it the network's addresses, and served by server.mjs,
|
|
3
|
+
# which gives it this network's and answers its lookups from inside the network. Upstream publishes
|
|
4
|
+
# no image.
|
|
5
|
+
|
|
6
|
+
ARG PDSLS_COMMIT=b9814fd14e6dd95945d04e71e3e12294b5f69530
|
|
7
|
+
|
|
8
|
+
FROM oven/bun:1.4.3 AS build
|
|
9
|
+
ARG PDSLS_COMMIT
|
|
10
|
+
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
|
11
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
12
|
+
WORKDIR /src
|
|
13
|
+
RUN git init -q . && git remote add origin https://github.com/notjuliet/pdsls.git \
|
|
14
|
+
&& git fetch -q --depth 1 origin "${PDSLS_COMMIT}" && git checkout -q FETCH_HEAD
|
|
15
|
+
COPY network.patch /tmp/network.patch
|
|
16
|
+
RUN git apply /tmp/network.patch
|
|
17
|
+
RUN bun install --frozen-lockfile
|
|
18
|
+
# The OAuth client is the one server.mjs serves at the page's origin, so the build's own is unused.
|
|
19
|
+
RUN bun scripts/generate-metadata.js && bun node_modules/vite/bin/vite.js build
|
|
20
|
+
|
|
21
|
+
FROM node:22-alpine
|
|
22
|
+
WORKDIR /app
|
|
23
|
+
COPY --from=build /src/dist ./dist
|
|
24
|
+
COPY server.mjs ./
|
|
25
|
+
USER node
|
|
26
|
+
EXPOSE 8080
|
|
27
|
+
CMD ["node", "server.mjs"]
|