+416
src/utils/plc-logs.ts
+416
src/utils/plc-logs.ts
···
1
+
// courtesy of the best 🐇 mary
2
+
// https://github.com/mary-ext/boat/blob/trunk/src/views/identity/plc-oplogs.tsx
3
+
import { IndexedEntry, Service } from "@atcute/did-plc";
4
+
5
+
export type DiffEntry =
6
+
| {
7
+
type: "identity_created";
8
+
orig: IndexedEntry;
9
+
nullified: boolean;
10
+
at: string;
11
+
rotationKeys: string[];
12
+
verificationMethods: Record<string, string>;
13
+
alsoKnownAs: string[];
14
+
services: Record<string, { type: string; endpoint: string }>;
15
+
}
16
+
| {
17
+
type: "identity_tombstoned";
18
+
orig: IndexedEntry;
19
+
nullified: boolean;
20
+
at: string;
21
+
}
22
+
| {
23
+
type: "rotation_key_added";
24
+
orig: IndexedEntry;
25
+
nullified: boolean;
26
+
at: string;
27
+
rotation_key: string;
28
+
}
29
+
| {
30
+
type: "rotation_key_removed";
31
+
orig: IndexedEntry;
32
+
nullified: boolean;
33
+
at: string;
34
+
rotation_key: string;
35
+
}
36
+
| {
37
+
type: "verification_method_added";
38
+
orig: IndexedEntry;
39
+
nullified: boolean;
40
+
at: string;
41
+
method_id: string;
42
+
method_key: string;
43
+
}
44
+
| {
45
+
type: "verification_method_removed";
46
+
orig: IndexedEntry;
47
+
nullified: boolean;
48
+
at: string;
49
+
method_id: string;
50
+
method_key: string;
51
+
}
52
+
| {
53
+
type: "verification_method_changed";
54
+
orig: IndexedEntry;
55
+
nullified: boolean;
56
+
at: string;
57
+
method_id: string;
58
+
prev_method_key: string;
59
+
next_method_key: string;
60
+
}
61
+
| {
62
+
type: "handle_added";
63
+
orig: IndexedEntry;
64
+
nullified: boolean;
65
+
at: string;
66
+
handle: string;
67
+
}
68
+
| {
69
+
type: "handle_removed";
70
+
orig: IndexedEntry;
71
+
nullified: boolean;
72
+
at: string;
73
+
handle: string;
74
+
}
75
+
| {
76
+
type: "handle_changed";
77
+
orig: IndexedEntry;
78
+
nullified: boolean;
79
+
at: string;
80
+
prev_handle: string;
81
+
next_handle: string;
82
+
}
83
+
| {
84
+
type: "service_added";
85
+
orig: IndexedEntry;
86
+
nullified: boolean;
87
+
at: string;
88
+
service_id: string;
89
+
service_type: string;
90
+
service_endpoint: string;
91
+
}
92
+
| {
93
+
type: "service_removed";
94
+
orig: IndexedEntry;
95
+
nullified: boolean;
96
+
at: string;
97
+
service_id: string;
98
+
service_type: string;
99
+
service_endpoint: string;
100
+
}
101
+
| {
102
+
type: "service_changed";
103
+
orig: IndexedEntry;
104
+
nullified: boolean;
105
+
at: string;
106
+
service_id: string;
107
+
prev_service_type: string;
108
+
next_service_type: string;
109
+
prev_service_endpoint: string;
110
+
next_service_endpoint: string;
111
+
};
112
+
113
+
export const createOperationHistory = (entries: IndexedEntry[]): DiffEntry[] => {
114
+
const history: DiffEntry[] = [];
115
+
116
+
for (let idx = 0, len = entries.length; idx < len; idx++) {
117
+
const entry = entries[idx];
118
+
const op = entry.operation;
119
+
120
+
if (op.type === "create") {
121
+
history.push({
122
+
type: "identity_created",
123
+
orig: entry,
124
+
nullified: entry.nullified,
125
+
at: entry.createdAt,
126
+
rotationKeys: [op.recoveryKey, op.signingKey],
127
+
verificationMethods: { atproto: op.signingKey },
128
+
alsoKnownAs: [`at://${op.handle}`],
129
+
services: {
130
+
atproto_pds: {
131
+
type: "AtprotoPersonalDataServer",
132
+
endpoint: op.service,
133
+
},
134
+
},
135
+
});
136
+
} else if (op.type === "plc_operation") {
137
+
const prevOp = findLastMatching(entries, (entry) => !entry.nullified, idx - 1)?.operation;
138
+
139
+
let oldRotationKeys: string[];
140
+
let oldVerificationMethods: Record<string, string>;
141
+
let oldAlsoKnownAs: string[];
142
+
let oldServices: Record<string, Service>;
143
+
144
+
if (!prevOp) {
145
+
history.push({
146
+
type: "identity_created",
147
+
orig: entry,
148
+
nullified: entry.nullified,
149
+
at: entry.createdAt,
150
+
rotationKeys: op.rotationKeys,
151
+
verificationMethods: op.verificationMethods,
152
+
alsoKnownAs: op.alsoKnownAs,
153
+
services: op.services,
154
+
});
155
+
156
+
continue;
157
+
} else if (prevOp.type === "create") {
158
+
oldRotationKeys = [prevOp.recoveryKey, prevOp.signingKey];
159
+
oldVerificationMethods = { atproto: prevOp.signingKey };
160
+
oldAlsoKnownAs = [`at://${prevOp.handle}`];
161
+
oldServices = {
162
+
atproto_pds: {
163
+
type: "AtprotoPersonalDataServer",
164
+
endpoint: prevOp.service,
165
+
},
166
+
};
167
+
} else if (prevOp.type === "plc_operation") {
168
+
oldRotationKeys = prevOp.rotationKeys;
169
+
oldVerificationMethods = prevOp.verificationMethods;
170
+
oldAlsoKnownAs = prevOp.alsoKnownAs;
171
+
oldServices = prevOp.services;
172
+
} else {
173
+
continue;
174
+
}
175
+
176
+
// Check for rotation key changes
177
+
{
178
+
const additions = difference(op.rotationKeys, oldRotationKeys);
179
+
const removals = difference(oldRotationKeys, op.rotationKeys);
180
+
181
+
for (const key of additions) {
182
+
history.push({
183
+
type: "rotation_key_added",
184
+
orig: entry,
185
+
nullified: entry.nullified,
186
+
at: entry.createdAt,
187
+
rotation_key: key,
188
+
});
189
+
}
190
+
191
+
for (const key of removals) {
192
+
history.push({
193
+
type: "rotation_key_removed",
194
+
orig: entry,
195
+
nullified: entry.nullified,
196
+
at: entry.createdAt,
197
+
rotation_key: key,
198
+
});
199
+
}
200
+
}
201
+
202
+
// Check for verification method changes
203
+
{
204
+
for (const id in op.verificationMethods) {
205
+
if (!(id in oldVerificationMethods)) {
206
+
history.push({
207
+
type: "verification_method_added",
208
+
orig: entry,
209
+
nullified: entry.nullified,
210
+
at: entry.createdAt,
211
+
method_id: id,
212
+
method_key: op.verificationMethods[id],
213
+
});
214
+
} else if (op.verificationMethods[id] !== oldVerificationMethods[id]) {
215
+
history.push({
216
+
type: "verification_method_changed",
217
+
orig: entry,
218
+
nullified: entry.nullified,
219
+
at: entry.createdAt,
220
+
method_id: id,
221
+
prev_method_key: oldVerificationMethods[id],
222
+
next_method_key: op.verificationMethods[id],
223
+
});
224
+
}
225
+
}
226
+
227
+
for (const id in oldVerificationMethods) {
228
+
if (!(id in op.verificationMethods)) {
229
+
history.push({
230
+
type: "verification_method_removed",
231
+
orig: entry,
232
+
nullified: entry.nullified,
233
+
at: entry.createdAt,
234
+
method_id: id,
235
+
method_key: oldVerificationMethods[id],
236
+
});
237
+
}
238
+
}
239
+
}
240
+
241
+
// Check for handle changes
242
+
if (op.alsoKnownAs.length === 1 && oldAlsoKnownAs.length === 1) {
243
+
if (op.alsoKnownAs[0] !== oldAlsoKnownAs[0]) {
244
+
history.push({
245
+
type: "handle_changed",
246
+
orig: entry,
247
+
nullified: entry.nullified,
248
+
at: entry.createdAt,
249
+
prev_handle: oldAlsoKnownAs[0],
250
+
next_handle: op.alsoKnownAs[0],
251
+
});
252
+
}
253
+
} else {
254
+
const additions = difference(op.alsoKnownAs, oldAlsoKnownAs);
255
+
const removals = difference(oldAlsoKnownAs, op.alsoKnownAs);
256
+
257
+
for (const handle of additions) {
258
+
history.push({
259
+
type: "handle_added",
260
+
orig: entry,
261
+
nullified: entry.nullified,
262
+
at: entry.createdAt,
263
+
handle: handle,
264
+
});
265
+
}
266
+
267
+
for (const handle of removals) {
268
+
history.push({
269
+
type: "handle_removed",
270
+
orig: entry,
271
+
nullified: entry.nullified,
272
+
at: entry.createdAt,
273
+
handle: handle,
274
+
});
275
+
}
276
+
}
277
+
278
+
// Check for service changes
279
+
{
280
+
for (const id in op.services) {
281
+
if (!(id in oldServices)) {
282
+
history.push({
283
+
type: "service_added",
284
+
orig: entry,
285
+
nullified: entry.nullified,
286
+
at: entry.createdAt,
287
+
service_id: id,
288
+
service_type: op.services[id].type,
289
+
service_endpoint: op.services[id].endpoint,
290
+
});
291
+
} else if (!dequal(op.services[id], oldServices[id])) {
292
+
history.push({
293
+
type: "service_changed",
294
+
orig: entry,
295
+
nullified: entry.nullified,
296
+
at: entry.createdAt,
297
+
service_id: id,
298
+
prev_service_type: oldServices[id].type,
299
+
next_service_type: op.services[id].type,
300
+
prev_service_endpoint: oldServices[id].endpoint,
301
+
next_service_endpoint: op.services[id].endpoint,
302
+
});
303
+
}
304
+
}
305
+
306
+
for (const id in oldServices) {
307
+
if (!(id in op.services)) {
308
+
history.push({
309
+
type: "service_removed",
310
+
orig: entry,
311
+
nullified: entry.nullified,
312
+
at: entry.createdAt,
313
+
service_id: id,
314
+
service_type: oldServices[id].type,
315
+
service_endpoint: oldServices[id].endpoint,
316
+
});
317
+
}
318
+
}
319
+
}
320
+
} else if (op.type === "plc_tombstone") {
321
+
history.push({
322
+
type: "identity_tombstoned",
323
+
orig: entry,
324
+
nullified: entry.nullified,
325
+
at: entry.createdAt,
326
+
});
327
+
}
328
+
}
329
+
330
+
return history;
331
+
};
332
+
333
+
function findLastMatching<T, S extends T>(
334
+
arr: T[],
335
+
predicate: (item: T) => item is S,
336
+
start?: number,
337
+
): S | undefined;
338
+
function findLastMatching<T>(
339
+
arr: T[],
340
+
predicate: (item: T) => boolean,
341
+
start?: number,
342
+
): T | undefined;
343
+
function findLastMatching<T>(
344
+
arr: T[],
345
+
predicate: (item: T) => boolean,
346
+
start: number = arr.length - 1,
347
+
): T | undefined {
348
+
for (let i = start, v: any; i >= 0; i--) {
349
+
if (predicate((v = arr[i]))) {
350
+
return v;
351
+
}
352
+
}
353
+
354
+
return undefined;
355
+
}
356
+
357
+
function difference<T>(a: readonly T[], b: readonly T[]): T[] {
358
+
const set = new Set(b);
359
+
return a.filter((value) => !set.has(value));
360
+
}
361
+
362
+
const dequal = (a: any, b: any): boolean => {
363
+
let ctor: any;
364
+
let len: number;
365
+
366
+
if (a === b) {
367
+
return true;
368
+
}
369
+
370
+
if (a && b && (ctor = a.constructor) === b.constructor) {
371
+
if (ctor === Array) {
372
+
if ((len = a.length) === b.length) {
373
+
while (len--) {
374
+
if (!dequal(a[len], b[len])) {
375
+
return false;
376
+
}
377
+
}
378
+
}
379
+
380
+
return len === -1;
381
+
} else if (!ctor || ctor === Object) {
382
+
len = 0;
383
+
384
+
for (ctor in a) {
385
+
len++;
386
+
387
+
if (!(ctor in b) || !dequal(a[ctor], b[ctor])) {
388
+
return false;
389
+
}
390
+
}
391
+
392
+
return Object.keys(b).length === len;
393
+
}
394
+
}
395
+
396
+
return a !== a && b !== b;
397
+
};
398
+
399
+
export const groupBy = <K, T>(items: T[], keyFn: (item: T, index: number) => K): Map<K, T[]> => {
400
+
const map = new Map<K, T[]>();
401
+
402
+
for (let idx = 0, len = items.length; idx < len; idx++) {
403
+
const val = items[idx];
404
+
const key = keyFn(val, idx);
405
+
406
+
const list = map.get(key);
407
+
408
+
if (list !== undefined) {
409
+
list.push(val);
410
+
} else {
411
+
map.set(key, [val]);
412
+
}
413
+
}
414
+
415
+
return map;
416
+
};
+200
-75
src/views/repo.tsx
+200
-75
src/views/repo.tsx
···
16
16
import { BlobView } from "./blob.jsx";
17
17
import { TextInput } from "../components/text-input.jsx";
18
18
import Tooltip from "../components/tooltip.jsx";
19
+
import { CompatibleOperationOrTombstone, defs, IndexedEntry } from "@atcute/did-plc";
20
+
import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js";
21
+
import { localDateFromTimestamp } from "../utils/date.js";
19
22
20
23
type Tab = "collections" | "backlinks" | "doc" | "blobs";
21
24
···
28
31
const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>();
29
32
const [tab, setTab] = createSignal<Tab>("collections");
30
33
const [filter, setFilter] = createSignal<string>();
34
+
const [plcOps, setPlcOps] =
35
+
createSignal<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>();
36
+
const [showPlcLogs, setShowPlcLogs] = createSignal(false);
37
+
const [loading, setLoading] = createSignal(false);
31
38
let rpc: Client;
32
39
let pds: string;
33
40
const did = params.repo;
···
44
51
{props.label}
45
52
</button>
46
53
);
54
+
55
+
const DiffItem = (props: { diff: DiffEntry }) => {
56
+
const diff = props.diff;
57
+
let title = "Unknown log entry";
58
+
let icon = "i-lucide-circle-help";
59
+
let value = "";
60
+
61
+
if (diff.type === "identity_created") {
62
+
icon = "i-lucide-bell";
63
+
title = `Identity created`;
64
+
} else if (diff.type === "identity_tombstoned") {
65
+
icon = "i-lucide-skull";
66
+
title = `Identity tombstoned`;
67
+
} else if (diff.type === "handle_added") {
68
+
icon = "i-lucide-at-sign";
69
+
title = "Alias added";
70
+
value = diff.handle;
71
+
} else if (diff.type === "handle_changed") {
72
+
icon = "i-lucide-at-sign";
73
+
title = "Alias updated";
74
+
value = `${diff.prev_handle} → ${diff.next_handle}`;
75
+
} else if (diff.type === "handle_removed") {
76
+
icon = "i-lucide-at-sign";
77
+
title = `Alias removed`;
78
+
value = diff.handle;
79
+
} else if (diff.type === "rotation_key_added") {
80
+
icon = "i-lucide-key-round";
81
+
title = `Rotation key added`;
82
+
value = diff.rotation_key;
83
+
} else if (diff.type === "rotation_key_removed") {
84
+
icon = "i-lucide-key-round";
85
+
title = `Rotation key removed`;
86
+
value = diff.rotation_key;
87
+
} else if (diff.type === "service_added") {
88
+
icon = "i-lucide-server";
89
+
title = `Service ${diff.service_id} added`;
90
+
value = `${diff.service_endpoint}`;
91
+
} else if (diff.type === "service_changed") {
92
+
icon = "i-lucide-server";
93
+
title = `Service ${diff.service_id} updated`;
94
+
value = `${diff.prev_service_endpoint} → ${diff.next_service_endpoint}`;
95
+
} else if (diff.type === "service_removed") {
96
+
icon = "i-lucide-server";
97
+
title = `Service ${diff.service_id} removed`;
98
+
value = `${diff.service_endpoint}`;
99
+
} else if (diff.type === "verification_method_added") {
100
+
icon = "i-lucide-shield-check";
101
+
title = `Verification method ${diff.method_id} added`;
102
+
value = `${diff.method_key}`;
103
+
} else if (diff.type === "verification_method_changed") {
104
+
icon = "i-lucide-shield-check";
105
+
title = `Verification method ${diff.method_id} updated`;
106
+
value = `${diff.prev_method_key} → ${diff.next_method_key}`;
107
+
} else if (diff.type === "verification_method_removed") {
108
+
icon = "i-lucide-shield-check";
109
+
title = `Verification method ${diff.method_id} removed`;
110
+
value = `${diff.method_key}`;
111
+
}
112
+
113
+
return (
114
+
<div class="grid grid-cols-[min-content_1fr] items-center">
115
+
<div class={icon + ` mr-1 shrink-0 text-lg`} />
116
+
<p
117
+
classList={{
118
+
"font-semibold": true,
119
+
"text-gray-500 line-through dark:text-gray-400": diff.orig.nullified,
120
+
}}
121
+
>
122
+
{title}
123
+
</p>
124
+
<div></div>
125
+
{value}
126
+
</div>
127
+
);
128
+
};
47
129
48
130
const fetchRepo = async () => {
49
131
pds = await resolvePDS(did);
···
228
310
<Show when={tab() === "doc"}>
229
311
<Show when={didDoc()}>
230
312
{(didDocument) => (
231
-
<div class="break-anywhere flex flex-col gap-y-1">
232
-
<div class="flex items-center justify-between gap-2">
313
+
<div class="break-anywhere flex flex-col gap-y-2">
314
+
<div class="flex flex-col gap-y-1">
315
+
<div class="flex items-center justify-between gap-2">
316
+
<div>
317
+
<span class="font-semibold text-stone-600 dark:text-stone-400">ID </span>
318
+
<span>{didDocument().id}</span>
319
+
</div>
320
+
<Tooltip text="DID Document">
321
+
<a
322
+
href={
323
+
did.startsWith("did:plc") ?
324
+
`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}`
325
+
: `https://${did.split("did:web:")[1]}/.well-known/did.json`
326
+
}
327
+
target="_blank"
328
+
>
329
+
<div class="i-lucide-external-link text-lg" />
330
+
</a>
331
+
</Tooltip>
332
+
</div>
333
+
<div>
334
+
<p class="font-semibold text-stone-600 dark:text-stone-400">Identities</p>
335
+
<ul class="ml-2">
336
+
<For each={didDocument().alsoKnownAs}>{(alias) => <li>{alias}</li>}</For>
337
+
</ul>
338
+
</div>
233
339
<div>
234
-
<span class="font-semibold text-stone-600 dark:text-stone-400">ID </span>
235
-
<span>{didDocument().id}</span>
340
+
<p class="font-semibold text-stone-600 dark:text-stone-400">Services</p>
341
+
<ul class="ml-2">
342
+
<For each={didDocument().service}>
343
+
{(service) => (
344
+
<li class="flex flex-col">
345
+
<span>#{service.id.split("#")[1]}</span>
346
+
<a
347
+
class="w-fit text-blue-400 hover:underline"
348
+
href={service.serviceEndpoint.toString()}
349
+
target="_blank"
350
+
>
351
+
{service.serviceEndpoint.toString()}
352
+
</a>
353
+
</li>
354
+
)}
355
+
</For>
356
+
</ul>
357
+
</div>
358
+
<div>
359
+
<p class="font-semibold text-stone-600 dark:text-stone-400">
360
+
Verification methods
361
+
</p>
362
+
<ul class="ml-2">
363
+
<For each={didDocument().verificationMethod}>
364
+
{(verif) => (
365
+
<li class="flex flex-col">
366
+
<span>#{verif.id.split("#")[1]}</span>
367
+
<span>{verif.publicKeyMultibase}</span>
368
+
</li>
369
+
)}
370
+
</For>
371
+
</ul>
236
372
</div>
237
-
<Tooltip text="DID Document">
238
-
<a
239
-
href={
240
-
did.startsWith("did:plc") ?
241
-
`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}`
242
-
: `https://${did.split("did:web:")[1]}/.well-known/did.json`
243
-
}
244
-
target="_blank"
373
+
</div>
374
+
<div class="flex justify-between">
375
+
<Show when={did.startsWith("did:plc")}>
376
+
<div class="flex items-center gap-1">
377
+
<button
378
+
type="button"
379
+
onclick={async () => {
380
+
if (!plcOps()) {
381
+
setLoading(true);
382
+
const response = await fetch(`https://plc.directory/${did}/log/audit`);
383
+
const json = await response.json();
384
+
const logs = defs.indexedEntryLog.parse(json);
385
+
const opHistory = createOperationHistory(logs).reverse();
386
+
setPlcOps(Array.from(groupBy(opHistory, (item) => item.orig)));
387
+
setLoading(false);
388
+
}
389
+
390
+
setShowPlcLogs(!showPlcLogs());
391
+
}}
392
+
class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100"
393
+
>
394
+
<div class="i-lucide-logs text-sm" />
395
+
{showPlcLogs() ? "Hide" : "Show"} PLC logs
396
+
</button>
397
+
<Show when={loading()}>
398
+
<div class="i-lucide-loader-circle animate-spin text-xl" />
399
+
</Show>
400
+
</div>
401
+
</Show>
402
+
<Show when={error()?.length === 0 || error() === undefined}>
403
+
<div
404
+
classList={{
405
+
"flex items-center gap-1": true,
406
+
"flex-row-reverse": did.startsWith("did:web"),
407
+
}}
245
408
>
246
-
<div class="i-lucide-external-link text-lg" />
247
-
</a>
248
-
</Tooltip>
409
+
<Show when={downloading()}>
410
+
<div class="i-lucide-loader-circle animate-spin text-xl" />
411
+
</Show>
412
+
<button
413
+
type="button"
414
+
onclick={() => downloadRepo()}
415
+
class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100"
416
+
>
417
+
<div class="i-lucide-download text-sm" />
418
+
Export Repo
419
+
</button>
420
+
</div>
421
+
</Show>
249
422
</div>
250
-
<div>
251
-
<p class="font-semibold text-stone-600 dark:text-stone-400">Identities</p>
252
-
<ul class="ml-2">
253
-
<For each={didDocument().alsoKnownAs}>{(alias) => <li>{alias}</li>}</For>
254
-
</ul>
255
-
</div>
256
-
<div>
257
-
<p class="font-semibold text-stone-600 dark:text-stone-400">Services</p>
258
-
<ul class="ml-2">
259
-
<For each={didDocument().service}>
260
-
{(service) => (
261
-
<li class="flex flex-col">
262
-
<span>#{service.id.split("#")[1]}</span>
263
-
<a
264
-
class="w-fit text-blue-400 hover:underline"
265
-
href={service.serviceEndpoint.toString()}
266
-
target="_blank"
267
-
>
268
-
{service.serviceEndpoint.toString()}
269
-
</a>
270
-
</li>
271
-
)}
272
-
</For>
273
-
</ul>
274
-
</div>
275
-
<div>
276
-
<p class="font-semibold text-stone-600 dark:text-stone-400">
277
-
Verification methods
278
-
</p>
279
-
<ul class="ml-2">
280
-
<For each={didDocument().verificationMethod}>
281
-
{(verif) => (
282
-
<li class="flex flex-col">
283
-
<span>#{verif.id.split("#")[1]}</span>
284
-
<span>{verif.publicKeyMultibase}</span>
285
-
</li>
423
+
<Show when={showPlcLogs()}>
424
+
<div class="flex flex-col gap-1 text-sm">
425
+
<For each={plcOps()}>
426
+
{([entry, diffs]) => (
427
+
<div class="flex flex-col">
428
+
<span class="text-neutral-500 dark:text-neutral-400">
429
+
{localDateFromTimestamp(new Date(entry.createdAt).getTime())}
430
+
</span>
431
+
{diffs.map((diff) => (
432
+
<DiffItem diff={diff} />
433
+
))}
434
+
</div>
286
435
)}
287
436
</For>
288
-
</ul>
289
-
</div>
290
-
<Show when={did.startsWith("did:plc")}>
291
-
<a
292
-
class="flex w-fit items-center text-blue-400 hover:underline"
293
-
href={`https://boat.kelinci.net/plc-oplogs?q=${did}`}
294
-
target="_blank"
295
-
>
296
-
PLC operation logs <div class="i-lucide-external-link ml-0.5 text-sm" />
297
-
</a>
298
-
</Show>
299
-
<Show when={error()?.length === 0 || error() === undefined}>
300
-
<div class="flex items-center gap-1">
301
-
<button
302
-
type="button"
303
-
onclick={() => downloadRepo()}
304
-
class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100"
305
-
>
306
-
<div class="i-lucide-download text-sm" />
307
-
Export Repo
308
-
</button>
309
-
<Show when={downloading()}>
310
-
<div class="i-lucide-loader-circle animate-spin text-xl" />
311
-
</Show>
312
437
</div>
313
438
</Show>
314
439
</div>