Diagnostics for atproto PDS hosts, DIDs, and handles: https://debug.hose.cam
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width"/>
6 <title>atproto PDS & account debugger</title>
7 <meta name="description" content="Quick diagnostics for PDS hosts, handles, relay connections, handles, DIDs, ..." />
8
9 <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
10 <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
11 <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css"/>
12 <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
13
14 <script type="module">
15 import {
16 Client,
17 ClientResponseError,
18 ok,
19 simpleFetchHandler,
20 } from 'https://esm.sh/@atcute/client@4.1.1';
21 import {
22 DohJsonHandleResolver,
23 WellKnownHandleResolver,
24 } from 'https://esm.sh/@atcute/identity-resolver@1.2.0';
25
26 window.SimpleQuery = service => {
27 const client = new Client({ handler: simpleFetchHandler({ service }) });
28 return (...args) => ok(client.get(...args));
29 };
30 window.SimpleProc = service => {
31 const client = new Client({ handler: simpleFetchHandler({ service }) });
32 return (...args) => ok(client.post(...args));
33 };
34 window.isXrpcErr = e => e instanceof ClientResponseError;
35
36 window.dnsResolver = new DohJsonHandleResolver({
37 dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query',
38 });
39 window.httpResolver = new WellKnownHandleResolver();
40
41 window.slingshot = window.SimpleQuery('https://slingshot.microcosm.blue');
42 window.relays = [
43 {
44 name: 'Bluesky sync1.1 East',
45 hostname: 'relay1.us-east.bsky.network',
46 },
47 {
48 name: 'Bluesky sync1.1 West',
49 hostname: 'relay1.us-west.bsky.network',
50 },
51 {
52 name: 'Microcosm Montreal',
53 hostname: 'relay.fire.hose.cam',
54 },
55 {
56 name: 'Microcosm France',
57 hostname: 'relay3.fr.hose.cam',
58 },
59 {
60 name: 'Bluesky prod (old)',
61 hostname: 'bsky.network',
62 },
63 {
64 name: 'Blacksky (partial xrpc)',
65 hostname: 'atproto.africa',
66 },
67 {
68 name: 'Upcloud (no CORS)',
69 hostname: 'relay.upcloud.world',
70 },
71 ];
72 </script>
73
74 <script>
75 document.addEventListener('alpine:init', () => {
76 Alpine.data('debug', () => ({
77 // form input
78 identifier: '',
79
80 // state
81 identifierLoading: false,
82 identifierError: null,
83
84 // stuff to check
85 pds: null,
86 did: null,
87 handle: null,
88
89 async goto(identifier) {
90 this.identifier = identifier;
91 await this.diagnose();
92 },
93
94 async diagnose() {
95 this.identifierLoading = true;
96 this.identifierError = null;
97 this.pds = null;
98 this.did = null;
99 this.handle = null;
100 this.identifier = this.identifier.trim();
101 if (this.identifier === '') {
102 // do nothing
103 } else if (this.identifier.startsWith('https://')) {
104 this.pds = this.identifier;
105 } else {
106 if (this.identifier.startsWith('at://')) {
107 this.identifier = this.identifier.slice('at://'.length);
108 }
109 if (this.identifier.startsWith('did:')) {
110 this.did = this.identifier;
111 let data;
112 try {
113 data = await window.slingshot('com.bad-example.identity.resolveMiniDoc', {
114 params: { identifier: this.identifier },
115 });
116 this.pds = data.pds;
117 this.handle = data.handle;
118 } catch (e) {
119 if (window.isXrpcErr(e)) {
120 this.identifierError = e.error;
121 if (e.message) this.description += ` ${e.description}`;
122 } else {
123 this.identifierError = 'Failed to resolve identifier, see console for error.';
124 console.error(e);
125 }
126 }
127 } else {
128 this.handle = this.identifier;
129 let data;
130 try {
131 data = await window.slingshot('com.bad-example.identity.resolveMiniDoc', {
132 params: { identifier: this.identifier },
133 });
134 this.did = data.did;
135 this.pds = data.pds;
136 } catch (e) {
137 if (window.isXrpcErr(e)) {
138 this.identifierError = e.error;
139 if (e.message) this.description += ` ${e.description}`;
140 } else {
141 this.identifierError = 'Failed to resolve identifier, see console for error.';
142 console.error(e);
143 }
144 }
145 }
146 }
147 this.identifierLoading = false;
148 },
149 }));
150
151 Alpine.data('pdsCheck', pds => ({
152 loadingDesc: false,
153 error: null,
154 description: null,
155 accounts: [],
156 accountsComplete: false,
157 version: null,
158
159 async init() {
160 await this.update(pds);
161 },
162
163 async update(pds) {
164 this.loadingDesc = true;
165 this.error = null;
166 this.description = null;
167 this.accounts = [];
168 this.accountsComplete = false;
169 this.version = null;
170
171 if (!pds) {
172 this.loadingDesc = false;
173 return;
174 }
175
176 let query = window.SimpleQuery(pds);
177 try {
178 this.description = await query('com.atproto.server.describeServer');
179 } catch (e) {
180 if (window.isXrpcErr(e)) {
181 this.error = e.error;
182 } else {
183 this.error = 'Failed to reach (see console)';
184 console.error(e);
185 }
186 }
187 let health
188 try {
189 health = await query('_health');
190 this.version = health.version;
191 } catch (e) {
192 if (window.isXrpcErr(e)) {
193 this.error = e.error;
194 } else {
195 this.error = 'Failed to reach (see console)';
196 console.error(e);
197 }
198 }
199 let accountsRes;
200 try {
201 accountsRes = await query('com.atproto.sync.listRepos', {
202 params: { limit: 100 },
203 });
204 this.accounts = accountsRes.repos;
205 this.accountsComplete == !accountsRes.cursor;
206 } catch (e) {
207 if (window.isXrpcErr(e)) {
208 this.error = e.error;
209 } else {
210 this.error = 'Failed to reach (see console)';
211 console.error(e);
212 }
213 }
214 this.loadingDesc = false;
215 },
216 }));
217
218 Alpine.data('relayCheckHost', (pds, relay) => ({
219 loading: false,
220 error: null,
221 status: null,
222 reqCrawlStatus: null,
223 reqCrawlError: null,
224
225 async init() {
226 await this.check(pds, relay);
227 },
228
229 async check(pds, relay) {
230 this.loading = true;
231 this.error = null;
232 this.status = null;
233 const query = window.SimpleQuery(`https://${relay.hostname}`);
234 const hostname = pds.split('://')[1];
235 let data;
236 try {
237 data = await query('com.atproto.sync.getHostStatus', {
238 params: { hostname },
239 });
240 this.status = data.status;
241 } catch(e) {
242 if (window.isXrpcErr(e)) {
243 this.error = e.error;
244 } else {
245 this.error = 'Failed to check (see console)';
246 console.error(e);
247 }
248 }
249 this.loading = false;
250 this.reqCrawlStatus = null;
251 this.reqCrawlError = null;
252 },
253
254 async requestCrawl(pds, relay) {
255 this.reqCrawlStatus = "loading";
256 const proc = window.SimpleProc(`https://${relay.hostname}`);
257 const hostname = pds.split('://')[1];
258 let data;
259 try {
260 data = await proc('com.atproto.sync.requestCrawl', {
261 input: { hostname },
262 });
263 } catch (e) {
264 if (window.isXrpcErr(e)) {
265 this.reqCrawlError = e.error;
266 } else {
267 this.reqCrawlError = 'failed (see console)';
268 console.error(e);
269 }
270 }
271 this.reqCrawlStatus = "done";
272 },
273 }));
274
275 Alpine.data('checkHandle', handle => ({
276 loading: false,
277 dnsDid: null,
278 dnsErr: null,
279 httpDid: null,
280 httpErr: null,
281
282 async init() {
283 await this.updateHandle(handle);
284 },
285 async updateHandle(handle) {
286 this.loading = true;
287 this.dnsDid = null;
288 this.dnsErr = null;
289 this.httpDid = null;
290 this.httpErr = null;
291 try {
292 this.dnsDid = await window.dnsResolver.resolve(handle);
293 } catch (e) {
294 this.dnsErr = e.name;
295 }
296 try {
297 this.httpDid = await window.httpResolver.resolve(handle);
298 } catch (e) {
299 this.httpErr = e.name;
300 }
301 this.loading = false;
302 },
303 }));
304
305 Alpine.data('didToHandle', did => ({
306 loading: false,
307 error: null,
308 handle: null,
309 async load() {
310 loading = true;
311 error = null;
312 handle = null;
313 let data;
314 try {
315 data = await window.slingshot('com.bad-example.identity.resolveMiniDoc', {
316 params: { identifier: did },
317 });
318 this.handle = data.handle;
319 } catch (e) {
320 if (window.isXrpcErr(e)) {
321 this.error = e.error;
322 } else {
323 this.error = 'failed (see console)';
324 console.error(e);
325 }
326 }
327 loading = false;
328 }
329 }));
330
331 Alpine.data('didRepoState', (did, pds) => ({
332 loading: false,
333 error: null,
334 state: null,
335
336 async init() {
337 await this.checkRepoState(did, pds);
338 },
339 async checkRepoState(did, pds) {
340 this.loading = true;
341 this.error = null;
342 this.state = null;
343
344 if (!did || !pds) {
345 this.loading = false;
346 return;
347 }
348 const query = window.SimpleQuery(pds);
349 try {
350 this.state = await query('com.atproto.sync.getRepoStatus', {
351 params: { did },
352 });
353 } catch (e) {
354 if (window.isXrpcErr(e)) {
355 this.error = e.error;
356 } else {
357 this.error = 'failed (see console)';
358 console.error(e);
359 }
360 }
361 this.loading = false;
362 },
363 }));
364
365 Alpine.data('relayCheckRepo', (did, relay) => ({
366 loading: false,
367 error: null,
368 status: null,
369
370 async init() {
371 await this.check(did, relay);
372 },
373
374 async check(did, relay) {
375 this.loading = true;
376 this.error = null;
377 this.status = null;
378
379 const query = window.SimpleQuery(`https://${relay.hostname}`);
380 try {
381 this.status = await query('com.atproto.sync.getRepoStatus', {
382 params: { did },
383 });
384 } catch(e) {
385 if (window.isXrpcErr(e)) {
386 this.error = e.error;
387 } else {
388 this.error = 'Failed to check (see console)';
389 console.error(e);
390 }
391 }
392
393 this.loading = false;
394 },
395 }));
396
397 Alpine.data('pdsHistory', (did, currentPds) => ({
398 loading: false,
399 error: null,
400 history: [],
401
402 async init() {
403 this.loading = true;
404 this.error = null;
405 this.history = [];
406 try {
407 const res = await fetch(`https://plc.directory/${did}/log/audit`);
408 if (res.ok) {
409 const log = await res.json();
410 let prev = null;
411 for (op of log) {
412 const opPds = op.operation.services.atproto_pds.endpoint;
413 if (opPds === prev) continue;
414 prev = opPds;
415 this.history.push({
416 pds: opPds,
417 date: op.createdAt,
418 });
419 }
420 this.history.reverse();
421 if (this.history[0]) this.history[0].current = true;
422 } else {
423 this.error = `${res.status}: ${await res.text()}`;
424 }
425 } catch (e) {
426 this.error = 'failed to get history';
427 console.error(e);
428 }
429 this.loading = false;
430 },
431 }));
432
433 Alpine.data('handleHistory', (did, currentHandle) => ({
434 loading: false,
435 error: null,
436 history: [],
437
438 async init() {
439 this.loading = true;
440 this.error = null;
441 this.history = [];
442 try {
443 const res = await fetch(`https://plc.directory/${did}/log/audit`);
444 if (res.ok) {
445 const log = await res.json();
446 let prev = null;
447 for (op of log) {
448 let opHandle = null;
449 for (aka of op.operation.alsoKnownAs) {
450 if (aka.startsWith("at://")) {
451 opHandle = aka.slice("at://".length);
452 break;
453 }
454 }
455 if (opHandle === prev) continue;
456 prev = opHandle;
457 this.history.push({
458 handle: opHandle,
459 date: op.createdAt,
460 });
461 }
462 this.history.reverse();
463 } else {
464 this.error = `${res.status}: ${await res.text()}`;
465 }
466 } catch (e) {
467 this.error = 'failed to get history';
468 console.error(e);
469 }
470 this.loading = false;
471 },
472 }));
473 })
474 </script>
475 </head>
476 <body x-data="debug">
477 <div class="hero bg-base-200">
478 <div class="hero-content flex-col">
479 <h1>PDS Debugger</h1>
480
481 <p class="text-sm">Work in progress!</p>
482 <details class="text-xs">
483 <summary>Future features</summary>
484 <ul class="list-disc pl-4">
485 <li>firehose & jetstream listeners</li>
486 </ul>
487 </details>
488 <details class="text-xs">
489 <summary>Limitations</summary>
490 <ul class="list-disc pl-4">
491 <li>The Bluesky production relay at <code>bsky.network</code> runs the old bgs implementation, and is missing many relay XRPC endpoints.</li>
492 <li>The Blacksky relay at <code>atproto.africa</code> runs an independent implementation, and is also missing many relay XRPC endpoints.</li>
493 <li>All diagnostics run in your browser, so servers that don't enable CORS (some PDS hosts, Upcloud's relay) will fail tests.</li>
494 </ul>
495 </details>
496
497 <div class="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl">
498 <div class="card-body">
499 <form @submit.prevent="await diagnose()">
500 <label>
501 Enter an atproto handle, DID, or HTTPS PDS URL
502 <input
503 class="input"
504 x-model="identifier"
505 :disabled="identifierLoading"
506 autofocus
507 />
508 </label>
509 </form>
510 </div>
511 </div>
512
513 <template x-if="identifierError">
514 <p>uh oh: <span x-text="identifierError"></span></p>
515 </template>
516
517 <template x-if="pds != null">
518 <div class="card bg-base-100 w-full max-w-lg shrink-0 shadow-2xl">
519 <div class="card-body">
520 <h2 class="card-title">
521 <span class="badge badge-secondary">PDS</span>
522 <span x-text="pds"></span>
523 </h2>
524
525 <div
526 x-data="pdsCheck(pds)"
527 x-init="$watch('pds', v => update(v))"
528 >
529 <h3 class="text-lg mt-3">
530 Server
531 <span
532 x-show="description !== null"
533 class="badge badge-sm badge-soft badge-success"
534 >online</span>
535 </h3>
536 <p x-show="loadingDesc">Loading…</p>
537 <p x-show="error" class="text-warning" x-text="error"></p>
538 <template x-if="description !== null">
539 <div>
540 <div class="overflow-x-auto">
541 <table class="table table-xs">
542 <tbody>
543 <tr>
544 <td class="text-sm">
545 Open registration:
546 <span
547 x-text="!description.inviteCodeRequired"
548 ></span>
549 </td>
550 </tr>
551 <tr>
552 <td class="text-sm">
553 Version:
554 <code
555 class="text-xs"
556 x-text="version"
557 ></code>
558 </td>
559 </tr>
560 </tbody>
561 </table>
562 </div>
563 <h4 class="font-bold">
564 Accounts
565 </h4>
566 <div class="overflow-x-auto overflow-y-auto max-h-26">
567 <table class="table table-xs">
568 <tbody>
569 <template x-for="account in accounts">
570 <tr>
571 <td>
572 <code>
573 <a
574 href="#"
575 class="link"
576 x-text="account.did"
577 @click.prevent="goto(account.did)"
578 ></a>
579 </code>
580 </td>
581 <td>
582 <span
583 x-show="account.active"
584 class="badge badge-sm badge-soft badge-success"
585 >
586 active
587 </span>
588 <span
589 x-show="!account.active"
590 x-text="account.status"
591 class="badge badge-sm badge-soft badge-warning"
592 ></span>
593 </td>
594 <td
595 x-data="didToHandle(account.did)"
596 x-intersect:enter.once="load"
597 >
598 <span x-show="loading">Loading…</span>
599 <span x-show="error !== null" x-text="error"></span>
600 <a
601 href="#"
602 class="link"
603 @click.prevent="goto(handle)"
604 x-show="handle !== null"
605 x-text="`@${handle}`"
606 ></a>
607 </td>
608 </tr>
609 </template>
610 <template x-if="!loadingDesc && !accountsComplete">
611 <tr>
612 <td colspan="2" class="text-xs text-warning-content">
613 (more accounts not shown)
614 </td>
615 </tr>
616 </template>
617 </tbody>
618 </table>
619 </div>
620 </div>
621 </template>
622 </div>
623
624 <h3 class="text-lg mt-3">Relay host status</h3>
625 <div class="overflow-x-auto overflow-y-auto max-h-33">
626 <table class="table table-xs">
627 <tbody>
628 <template x-for="relay in window.relays">
629 <tr
630 x-data="relayCheckHost(pds, relay)"
631 x-init="$watch('pds', pds => check(pds, relay))"
632 >
633 <td x-text="relay.name" class="text-sm"></td>
634 <td>
635 <template x-if="loading">
636 <em>loading…</em>
637 </template>
638 <template x-if="error">
639 <span
640 x-text="error"
641 class="text-xs text-warning"
642 ></span>
643 </template>
644 <template x-if="status">
645 <span
646 x-text="status"
647 class="badge badge-sm"
648 :class="status === 'active' && 'badge-soft badge-success'"
649 ></span>
650 </template>
651 </td>
652 <td>
653 <div x-show="status !== 'active'">
654 <button
655 x-show="reqCrawlStatus !== 'done'"
656 class="btn btn-xs btn-ghost whitespace-nowrap"
657 :disabled="reqCrawlStatus === 'loading'"
658 @click="requestCrawl(pds, relay)"
659 >
660 request crawl
661 </button>
662 <span
663 x-show="reqCrawlError !== null"
664 x-text="reqCrawlError"
665 class="text-xs text-warning"
666 ></span>
667 <button
668 x-show="reqCrawlError === null && reqCrawlStatus === 'done'"
669 class="btn btn-xs btn-soft btn-primary whitespace-nowrap"
670 @click="check"
671 >
672 refresh
673 </button>
674 </div>
675 </td>
676 </tr>
677 </template>
678 </tbody>
679 </table>
680 </div>
681 </div>
682 </div>
683 </template>
684
685 <template x-if="did != null">
686 <div class="card bg-base-100 w-full max-w-lg shrink-0 shadow-2xl">
687 <div class="card-body">
688 <h2 class="card-title">
689 <span class="badge badge-secondary">DID</span>
690 <code x-text="did"></code>
691 </h2>
692 <template x-if="pds != null">
693 <div x-data="didRepoState(did, pds)">
694 <h3 class="text-lg mt-3">
695 Repo
696 <span
697 x-show="state && state.active"
698 class="badge badge-sm badge-soft badge-success"
699 >active</span>
700 </h3>
701 <div class="overflow-x-auto">
702 <table class="table table-xs">
703 <tbody>
704 <tr>
705 <td class="text-sm">
706 Rev:
707 <code x-text="state && state.rev"></code>
708 </td>
709 </tr>
710 <tr>
711 <td class="text-sm">
712 PDS:
713 <a
714 href="#"
715 class="link"
716 @click.prevent="goto(pds)"
717 x-text="pds"
718 ></a>
719 </td>
720 </tr>
721 </tbody>
722 </table>
723 </div>
724
725 <h3 class="text-lg mt-3">
726 Relay repo status
727 </h3>
728 <div class="overflow-x-auto overflow-y-auto max-h-33">
729 <table class="table table-xs">
730 <tbody>
731 <template x-for="relay in window.relays">
732 <tr
733 x-data="relayCheckRepo(did, relay)"
734 x-init="$watch('pds', pds => check(did, relay))"
735 >
736 <td x-text="relay.name" class="text-sm"></td>
737 <template x-if="loading">
738 <td>
739 <em>loading…</em>
740 </td>
741 </template>
742 <template x-if="error">
743 <td
744 x-text="error"
745 class="text-xs text-warning"
746 ></td>
747 </template>
748 <template x-if="status">
749 <td>
750 <span
751 x-show="status.active"
752 class="badge badge-sm badge-soft badge-success"
753 >
754 active
755 </span>
756 <span
757 x-show="!status.active"
758 x-text="status.status"
759 class="badge badge-sm badge-soft badge-warning"
760 ></span>
761 </td>
762 </template>
763 <template x-if="status">
764 <td>
765 <code
766 x-text="status.rev"
767 class="badge badge-sm badge-soft"
768 :class="status && state && (status.rev >= state.rev) ? 'badge-success' : 'badge-warning'"
769 ></code>
770 </td>
771 </template>
772 </tr>
773 </template>
774 </tbody>
775 </table>
776 </div>
777
778 <template x-if="did.startsWith('did:plc:')">
779 <div x-data="pdsHistory(did, pds)">
780 <h3 class="text-lg mt-3">
781 PLC PDS history
782 </h3>
783 <div class="overflow-x-auto">
784 <table class="table table-xs">
785 <tbody>
786 <template x-if="loading">
787 <tr>
788 <td>Loading…</td>
789 </tr>
790 </template>
791 <template x-if="error">
792 <tr>
793 <td>Error: <span x-text="error"></span></td>
794 </tr>
795 </template>
796 <template x-if="!loading && !error && history.length === 0">
797 <tr>
798 <td class="text-sm">
799 <em>no previous PDS</em>
800 </td>
801 </tr>
802 </template>
803 <template x-for="event in history">
804 <tr x-data="didRepoState(did, event.pds)">
805 <td>
806 <code x-text="event.date.split('T')[0]"></code>
807 </td>
808 <td>
809 <a
810 href="#"
811 class="link"
812 @click.prevent="goto(event.pds)"
813 x-text="event.pds"
814 ></a>
815 </td>
816 <template x-if="event.current">
817 <td>
818 <span
819 x-show="state && !state.active"
820 x-text="state && state.status"
821 class="badge badge-sm badge-soft badge-warning"
822 ></span>
823 <span
824 x-show="state && state.active"
825 class="badge badge-sm badge-soft badge-success"
826 >current</span>
827 </td>
828 </template>
829 <template x-if="!event.current">
830 <td>
831 <span
832 x-show="state && !state.active"
833 x-text="state && state.status"
834 class="badge badge-sm badge-soft badge-success"
835 ></span>
836 <span
837 x-show="state && state.active"
838 class="badge badge-sm badge-soft badge-warning"
839 >active</span>
840 </td>
841 </template>
842 </tr>
843 </template>
844 </tbody>
845 </table>
846 </div>
847 </div>
848 </template>
849 </div>
850 </template>
851 </div>
852 </div>
853 </template>
854
855 <template x-if="handle !== null">
856 <div class="card bg-base-100 w-full max-w-lg shrink-0 shadow-2xl">
857 <div
858 x-data="checkHandle(handle)"
859 x-init="$watch('handle', h => updateHandle(h))"
860 class="card-body"
861 >
862 <h2 class="card-title">
863 <span class="badge badge-secondary">Handle</span>
864 <span x-text="handle"></span>
865 </h2>
866
867 <h3 class="text-lg mt-3">
868 Resolution
869 </h3>
870 <p x-show="loading" class="text-i">Loading…</p>
871 <div x-show="!loading" class="overflow-x-auto">
872 <table class="table table-xs">
873 <tbody>
874 <tr>
875 <td class="text-sm">DNS</td>
876 <td class="text-sm">
877 <code x-text="dnsDid"></code>
878 </td>
879 <td>
880 <div
881 class="badge badge-sm badge-soft badge-neutral"
882 x-show="dnsErr !== null"
883 x-text="dnsErr"
884 ></div>
885 </td>
886 </tr>
887 <tr>
888 <td class="text-sm">Http</td>
889 <td class="text-sm">
890 <code x-text="httpDid"></code>
891 </td>
892 <td>
893 <div
894 class="badge badge-sm badge-soft badge-neutral"
895 x-show="httpErr !== null"
896 x-text="httpErr"
897 ></div>
898 </td>
899 </tr>
900 </tbody>
901 </table>
902 </div>
903
904 <template x-if="did !== null && did.startsWith('did:plc:')">
905
906 <div x-data="handleHistory(did, handle)">
907 <h3 class="text-lg mt-3">
908 PLC handle history
909 </h3>
910 <div class="overflow-x-auto">
911 <table class="table table-xs">
912 <tbody>
913 <template x-if="loading">
914 <tr>
915 <td>Loading…</td>
916 </tr>
917 </template>
918 <template x-if="error">
919 <tr>
920 <td>Error: <span x-text="error"></span></td>
921 </tr>
922 </template>
923 <template x-if="!loading && !error && history.length === 0">
924 <tr>
925 <td class="text-sm">
926 <em>no previous handle</em>
927 </td>
928 </tr>
929 </template>
930 <template x-for="event in history">
931 <tr>
932 <td>
933 <code x-text="event.date.split('T')[0]"></code>
934 </td>
935 <td>
936 <a
937 href="#"
938 class="link"
939 @click.prevent="goto(event.handle)"
940 x-text="event.handle"
941 ></a>
942 </td>
943 </tr>
944 </template>
945 </tbody>
946 </table>
947 </div>
948 </div>
949
950 </template>
951 </div>
952 </div>
953 </template>
954 </div>
955 </div>
956 </body>
957</html>