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.1';
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 let opPds = null;
413 const services = op.operation.services;
414 if (services) {
415 const app = services.atproto_pds;
416 if (app) {
417 opPds = app.endpoint;
418 }
419 }
420 if (opPds === prev) continue;
421 prev = opPds;
422 this.history.push({
423 pds: opPds,
424 date: op.createdAt,
425 });
426 }
427 this.history.reverse();
428 if (this.history[0]) this.history[0].current = true;
429 } else {
430 this.error = `${res.status}: ${await res.text()}`;
431 }
432 } catch (e) {
433 this.error = 'failed to get history';
434 console.error(e);
435 }
436 this.loading = false;
437 },
438 }));
439
440 Alpine.data('handleHistory', (did, currentHandle) => ({
441 loading: false,
442 error: null,
443 history: [],
444
445 async init() {
446 this.loading = true;
447 this.error = null;
448 this.history = [];
449 try {
450 const res = await fetch(`https://plc.directory/${did}/log/audit`);
451 if (res.ok) {
452 const log = await res.json();
453 let prev = null;
454 for (op of log) {
455 let opHandle = null;
456 if (op.operation.alsoKnownAs) {
457 for (aka of op.operation.alsoKnownAs) {
458 if (aka.startsWith("at://")) {
459 opHandle = aka.slice("at://".length);
460 break;
461 }
462 }
463 }
464 if (opHandle === prev) continue;
465 prev = opHandle;
466 this.history.push({
467 handle: opHandle,
468 date: op.createdAt,
469 });
470 }
471 this.history.reverse();
472 } else {
473 this.error = `${res.status}: ${await res.text()}`;
474 }
475 } catch (e) {
476 this.error = 'failed to get history';
477 console.error(e);
478 }
479 this.loading = false;
480 },
481 }));
482 })
483 </script>
484 </head>
485 <body x-data="debug">
486 <div class="hero bg-base-200">
487 <div class="hero-content flex-col">
488 <h1>PDS Debugger</h1>
489
490 <p class="text-sm">Work in progress!</p>
491 <details class="text-xs">
492 <summary>Future features</summary>
493 <ul class="list-disc pl-4">
494 <li>firehose & jetstream listeners</li>
495 </ul>
496 </details>
497 <details class="text-xs">
498 <summary>Limitations</summary>
499 <ul class="list-disc pl-4">
500 <li>The Bluesky production relay at <code>bsky.network</code> runs the old bgs implementation, and is missing many relay XRPC endpoints.</li>
501 <li>The Blacksky relay at <code>atproto.africa</code> runs an independent implementation, and is also missing many relay XRPC endpoints.</li>
502 <li>All diagnostics run in your browser, so servers that don't enable CORS (some PDS hosts, Upcloud's relay) will fail tests.</li>
503 </ul>
504 </details>
505
506 <div class="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl">
507 <div class="card-body">
508 <form @submit.prevent="await diagnose()">
509 <label>
510 Enter an atproto handle, DID, or HTTPS PDS URL
511 <input
512 class="input"
513 x-model="identifier"
514 :disabled="identifierLoading"
515 autofocus
516 />
517 </label>
518 </form>
519 </div>
520 </div>
521
522 <template x-if="identifierError">
523 <p>uh oh: <span x-text="identifierError"></span></p>
524 </template>
525
526 <template x-if="pds != null">
527 <div class="card bg-base-100 w-full max-w-lg shrink-0 shadow-2xl">
528 <div class="card-body">
529 <h2 class="card-title">
530 <span class="badge badge-secondary">PDS</span>
531 <span x-text="pds"></span>
532 </h2>
533
534 <div
535 x-data="pdsCheck(pds)"
536 x-init="$watch('pds', v => update(v))"
537 >
538 <h3 class="text-lg mt-3">
539 Server
540 <span
541 x-show="description !== null"
542 class="badge badge-sm badge-soft badge-success"
543 >online</span>
544 </h3>
545 <p x-show="loadingDesc">Loading…</p>
546 <p x-show="error" class="text-warning" x-text="error"></p>
547 <template x-if="description !== null">
548 <div>
549 <div class="overflow-x-auto">
550 <table class="table table-xs">
551 <tbody>
552 <tr>
553 <td class="text-sm">
554 Open registration:
555 <span
556 x-text="!description.inviteCodeRequired"
557 ></span>
558 </td>
559 </tr>
560 <tr>
561 <td class="text-sm">
562 Version:
563 <code
564 class="text-xs"
565 x-text="version"
566 ></code>
567 </td>
568 </tr>
569 </tbody>
570 </table>
571 </div>
572 <h4 class="font-bold">
573 Accounts
574 </h4>
575 <div class="overflow-x-auto overflow-y-auto max-h-26">
576 <table class="table table-xs">
577 <tbody>
578 <template x-for="account in accounts">
579 <tr>
580 <td>
581 <code>
582 <a
583 href="#"
584 class="link"
585 x-text="account.did"
586 @click.prevent="goto(account.did)"
587 ></a>
588 </code>
589 </td>
590 <td>
591 <span
592 x-show="account.active"
593 class="badge badge-sm badge-soft badge-success"
594 >
595 active
596 </span>
597 <span
598 x-show="!account.active"
599 x-text="account.status"
600 class="badge badge-sm badge-soft badge-warning"
601 ></span>
602 </td>
603 <td
604 x-data="didToHandle(account.did)"
605 x-intersect:enter.once="load"
606 >
607 <span x-show="loading">Loading…</span>
608 <span x-show="error !== null" x-text="error"></span>
609 <a
610 href="#"
611 class="link"
612 @click.prevent="goto(handle)"
613 x-show="handle !== null"
614 x-text="`@${handle}`"
615 ></a>
616 </td>
617 </tr>
618 </template>
619 <template x-if="!loadingDesc && !accountsComplete">
620 <tr>
621 <td colspan="2" class="text-xs text-warning-content">
622 (more accounts not shown)
623 </td>
624 </tr>
625 </template>
626 </tbody>
627 </table>
628 </div>
629 </div>
630 </template>
631 </div>
632
633 <h3 class="text-lg mt-3">Relay host status</h3>
634 <div class="overflow-x-auto overflow-y-auto max-h-33">
635 <table class="table table-xs">
636 <tbody>
637 <template x-for="relay in window.relays">
638 <tr
639 x-data="relayCheckHost(pds, relay)"
640 x-init="$watch('pds', pds => check(pds, relay))"
641 >
642 <td x-text="relay.name" class="text-sm"></td>
643 <td>
644 <template x-if="loading">
645 <em>loading…</em>
646 </template>
647 <template x-if="error">
648 <span
649 x-text="error"
650 class="text-xs text-warning"
651 ></span>
652 </template>
653 <template x-if="status">
654 <span
655 x-text="status"
656 class="badge badge-sm"
657 :class="status === 'active' && 'badge-soft badge-success'"
658 ></span>
659 </template>
660 </td>
661 <td>
662 <div x-show="status !== 'active'">
663 <button
664 x-show="reqCrawlStatus !== 'done'"
665 class="btn btn-xs btn-ghost whitespace-nowrap"
666 :disabled="reqCrawlStatus === 'loading'"
667 @click="requestCrawl(pds, relay)"
668 >
669 request crawl
670 </button>
671 <span
672 x-show="reqCrawlError !== null"
673 x-text="reqCrawlError"
674 class="text-xs text-warning"
675 ></span>
676 <button
677 x-show="reqCrawlError === null && reqCrawlStatus === 'done'"
678 class="btn btn-xs btn-soft btn-primary whitespace-nowrap"
679 @click="check"
680 >
681 refresh
682 </button>
683 </div>
684 </td>
685 </tr>
686 </template>
687 </tbody>
688 </table>
689 </div>
690 </div>
691 </div>
692 </template>
693
694 <template x-if="did != null">
695 <div class="card bg-base-100 w-full max-w-lg shrink-0 shadow-2xl">
696 <div class="card-body">
697 <h2 class="card-title">
698 <span class="badge badge-secondary">DID</span>
699 <code x-text="did"></code>
700 </h2>
701 <template x-if="pds != null">
702 <div x-data="didRepoState(did, pds)">
703 <h3 class="text-lg mt-3">
704 Repo
705 <span
706 x-show="state && state.active"
707 class="badge badge-sm badge-soft badge-success"
708 >active</span>
709 </h3>
710 <div class="overflow-x-auto">
711 <table class="table table-xs">
712 <tbody>
713 <tr>
714 <td class="text-sm">
715 Rev:
716 <code x-text="state && state.rev"></code>
717 </td>
718 </tr>
719 <tr>
720 <td class="text-sm">
721 PDS:
722 <a
723 href="#"
724 class="link"
725 @click.prevent="goto(pds)"
726 x-text="pds"
727 ></a>
728 </td>
729 </tr>
730 </tbody>
731 </table>
732 </div>
733
734 <h3 class="text-lg mt-3">
735 Relay repo status
736 </h3>
737 <div class="overflow-x-auto overflow-y-auto max-h-33">
738 <table class="table table-xs">
739 <tbody>
740 <template x-for="relay in window.relays">
741 <tr
742 x-data="relayCheckRepo(did, relay)"
743 x-init="$watch('pds', pds => check(did, relay))"
744 >
745 <td x-text="relay.name" class="text-sm"></td>
746 <template x-if="loading">
747 <td>
748 <em>loading…</em>
749 </td>
750 </template>
751 <template x-if="error">
752 <td
753 x-text="error"
754 class="text-xs text-warning"
755 ></td>
756 </template>
757 <template x-if="status">
758 <td>
759 <span
760 x-show="status.active"
761 class="badge badge-sm badge-soft badge-success"
762 >
763 active
764 </span>
765 <span
766 x-show="!status.active"
767 x-text="status.status"
768 class="badge badge-sm badge-soft badge-warning"
769 ></span>
770 </td>
771 </template>
772 <template x-if="status">
773 <td>
774 <code
775 x-text="status.rev"
776 class="badge badge-sm badge-soft"
777 :class="status && state && (status.rev >= state.rev) ? 'badge-success' : 'badge-warning'"
778 ></code>
779 </td>
780 </template>
781 </tr>
782 </template>
783 </tbody>
784 </table>
785 </div>
786
787 <template x-if="did.startsWith('did:plc:')">
788 <div x-data="pdsHistory(did, pds)">
789 <h3 class="text-lg mt-3">
790 PLC PDS history
791 </h3>
792 <div class="overflow-x-auto">
793 <table class="table table-xs">
794 <tbody>
795 <template x-if="loading">
796 <tr>
797 <td>Loading…</td>
798 </tr>
799 </template>
800 <template x-if="error">
801 <tr>
802 <td>Error: <span x-text="error"></span></td>
803 </tr>
804 </template>
805 <template x-if="!loading && !error && history.length === 0">
806 <tr>
807 <td class="text-sm">
808 <em>no previous PDS</em>
809 </td>
810 </tr>
811 </template>
812 <template x-for="event in history">
813 <tr x-data="didRepoState(did, event.pds)">
814 <td>
815 <code x-text="event.date.split('T')[0]"></code>
816 </td>
817 <td>
818 <a
819 href="#"
820 class="link"
821 @click.prevent="goto(event.pds)"
822 x-text="event.pds"
823 ></a>
824 </td>
825 <template x-if="event.current">
826 <td>
827 <span
828 x-show="state && !state.active"
829 x-text="state && state.status"
830 class="badge badge-sm badge-soft badge-warning"
831 ></span>
832 <span
833 x-show="state && state.active"
834 class="badge badge-sm badge-soft badge-success"
835 >current</span>
836 </td>
837 </template>
838 <template x-if="!event.current">
839 <td>
840 <span
841 x-show="state && !state.active"
842 x-text="state && state.status"
843 class="badge badge-sm badge-soft badge-success"
844 ></span>
845 <span
846 x-show="state && state.active"
847 class="badge badge-sm badge-soft badge-warning"
848 >active</span>
849 </td>
850 </template>
851 </tr>
852 </template>
853 </tbody>
854 </table>
855 </div>
856 </div>
857 </template>
858 </div>
859 </template>
860 </div>
861 </div>
862 </template>
863
864 <template x-if="handle !== null">
865 <div class="card bg-base-100 w-full max-w-lg shrink-0 shadow-2xl">
866 <div
867 x-data="checkHandle(handle)"
868 x-init="$watch('handle', h => updateHandle(h))"
869 class="card-body"
870 >
871 <h2 class="card-title">
872 <span class="badge badge-secondary">Handle</span>
873 <span x-text="handle"></span>
874 </h2>
875
876 <h3 class="text-lg mt-3">
877 Resolution
878 </h3>
879 <p x-show="loading" class="text-i">Loading…</p>
880 <div x-show="!loading" class="overflow-x-auto">
881 <table class="table table-xs">
882 <tbody>
883 <tr>
884 <td class="text-sm">DNS</td>
885 <td class="text-sm">
886 <code x-text="dnsDid"></code>
887 </td>
888 <td>
889 <div
890 class="badge badge-sm badge-soft badge-neutral"
891 x-show="dnsErr !== null"
892 x-text="dnsErr"
893 ></div>
894 </td>
895 </tr>
896 <tr>
897 <td class="text-sm">Http</td>
898 <td class="text-sm">
899 <code x-text="httpDid"></code>
900 </td>
901 <td>
902 <div
903 class="badge badge-sm badge-soft badge-neutral"
904 x-show="httpErr !== null"
905 x-text="httpErr"
906 ></div>
907 </td>
908 </tr>
909 </tbody>
910 </table>
911 </div>
912
913 <template x-if="did !== null && did.startsWith('did:plc:')">
914
915 <div x-data="handleHistory(did, handle)">
916 <h3 class="text-lg mt-3">
917 PLC handle history
918 </h3>
919 <div class="overflow-x-auto">
920 <table class="table table-xs">
921 <tbody>
922 <template x-if="loading">
923 <tr>
924 <td>Loading…</td>
925 </tr>
926 </template>
927 <template x-if="error">
928 <tr>
929 <td>Error: <span x-text="error"></span></td>
930 </tr>
931 </template>
932 <template x-if="!loading && !error && history.length === 0">
933 <tr>
934 <td class="text-sm">
935 <em>no previous handle</em>
936 </td>
937 </tr>
938 </template>
939 <template x-for="event in history">
940 <tr>
941 <td>
942 <code x-text="event.date.split('T')[0]"></code>
943 </td>
944 <td>
945 <a
946 href="#"
947 class="link"
948 @click.prevent="goto(event.handle)"
949 x-text="event.handle"
950 ></a>
951 </td>
952 </tr>
953 </template>
954 </tbody>
955 </table>
956 </div>
957 </div>
958
959 </template>
960 </div>
961 </div>
962 </template>
963 </div>
964 </div>
965 </body>
966</html>