1import {
2 CompatibleOperationOrTombstone,
3 defs,
4 IndexedEntry,
5 IndexedEntryLog,
6 processIndexedEntryLog,
7} from "@atcute/did-plc";
8import { createEffect, createResource, createSignal, For, Show } from "solid-js";
9import { localDateFromTimestamp } from "../utils/date.js";
10import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js";
11
12type PlcEvent = "handle" | "rotation_key" | "service" | "verification_method";
13
14export const PlcLogView = (props: { did: string }) => {
15 const [activePlcEvent, setActivePlcEvent] = createSignal<PlcEvent | undefined>();
16 const [validLog, setValidLog] = createSignal<boolean | undefined>(undefined);
17 const [rawLogs, setRawLogs] = createSignal<IndexedEntryLog | undefined>(undefined);
18
19 const shouldShowDiff = (diff: DiffEntry) =>
20 !activePlcEvent() || diff.type.startsWith(activePlcEvent()!);
21
22 const shouldShowEntry = (diffs: DiffEntry[]) =>
23 !activePlcEvent() || diffs.some((d) => d.type.startsWith(activePlcEvent()!));
24
25 const fetchPlcLogs = async () => {
26 const res = await fetch(
27 `${localStorage.plcDirectory ?? "https://plc.directory"}/${props.did}/log/audit`,
28 );
29 const json = await res.json();
30 const logs = defs.indexedEntryLog.parse(json);
31 setRawLogs(logs);
32 const opHistory = createOperationHistory(logs).reverse();
33 return Array.from(groupBy(opHistory, (item) => item.orig));
34 };
35
36 const validateLog = async (logs: IndexedEntryLog) => {
37 try {
38 await processIndexedEntryLog(props.did as any, logs);
39 setValidLog(true);
40 } catch (e) {
41 console.error(e);
42 setValidLog(false);
43 }
44 };
45
46 const [plcOps] =
47 createResource<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>(fetchPlcLogs);
48
49 createEffect(() => {
50 const logs = rawLogs();
51 if (logs) {
52 setValidLog(undefined);
53 // Defer validation to next tick to avoid blocking rendering
54 setTimeout(() => validateLog(logs), 0);
55 }
56 });
57
58 const FilterButton = (props: { icon: string; event: PlcEvent; label: string }) => {
59 const isActive = () => activePlcEvent() === props.event;
60 const toggleFilter = () => setActivePlcEvent(isActive() ? undefined : props.event);
61
62 return (
63 <button
64 classList={{
65 "flex items-center gap-1 sm:gap-1.5 rounded-lg px-3 py-2 sm:px-2 sm:py-1.5 text-base sm:text-sm transition-colors": true,
66 "bg-neutral-700 text-white dark:bg-neutral-200 dark:text-neutral-900": isActive(),
67 "bg-neutral-200 text-neutral-700 hover:bg-neutral-300 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600":
68 !isActive(),
69 }}
70 onclick={toggleFilter}
71 >
72 <span class={props.icon}></span>
73 <span class="hidden font-medium sm:inline">{props.label}</span>
74 </button>
75 );
76 };
77
78 const DiffItem = (props: { diff: DiffEntry }) => {
79 const diff = props.diff;
80
81 const getDiffConfig = () => {
82 switch (diff.type) {
83 case "identity_created":
84 return { icon: "lucide--bell", title: "Identity created" };
85 case "identity_tombstoned":
86 return { icon: "lucide--skull", title: "Identity tombstoned" };
87 case "handle_added":
88 return {
89 icon: "lucide--at-sign",
90 title: "Alias added",
91 value: diff.handle,
92 isAddition: true,
93 };
94 case "handle_removed":
95 return {
96 icon: "lucide--at-sign",
97 title: "Alias removed",
98 value: diff.handle,
99 isRemoval: true,
100 };
101 case "handle_changed":
102 return {
103 icon: "lucide--at-sign",
104 title: "Alias updated",
105 oldValue: diff.prev_handle,
106 newValue: diff.next_handle,
107 };
108 case "rotation_key_added":
109 return {
110 icon: "lucide--key-round",
111 title: "Rotation key added",
112 value: diff.rotation_key,
113 isAddition: true,
114 };
115 case "rotation_key_removed":
116 return {
117 icon: "lucide--key-round",
118 title: "Rotation key removed",
119 value: diff.rotation_key,
120 isRemoval: true,
121 };
122 case "service_added":
123 return {
124 icon: "lucide--hard-drive",
125 title: "Service added",
126 badge: diff.service_id,
127 value: diff.service_endpoint,
128 isAddition: true,
129 };
130 case "service_removed":
131 return {
132 icon: "lucide--hard-drive",
133 title: "Service removed",
134 badge: diff.service_id,
135 value: diff.service_endpoint,
136 isRemoval: true,
137 };
138 case "service_changed":
139 return {
140 icon: "lucide--hard-drive",
141 title: "Service updated",
142 badge: diff.service_id,
143 oldValue: diff.prev_service_endpoint,
144 newValue: diff.next_service_endpoint,
145 };
146 case "verification_method_added":
147 return {
148 icon: "lucide--shield-check",
149 title: "Verification method added",
150 badge: diff.method_id,
151 value: diff.method_key,
152 isAddition: true,
153 };
154 case "verification_method_removed":
155 return {
156 icon: "lucide--shield-check",
157 title: "Verification method removed",
158 badge: diff.method_id,
159 value: diff.method_key,
160 isRemoval: true,
161 };
162 case "verification_method_changed":
163 return {
164 icon: "lucide--shield-check",
165 title: "Verification method updated",
166 badge: diff.method_id,
167 oldValue: diff.prev_method_key,
168 newValue: diff.next_method_key,
169 };
170 default:
171 return { icon: "lucide--circle-help", title: "Unknown log entry" };
172 }
173 };
174
175 const config = getDiffConfig();
176 const {
177 icon,
178 title,
179 value = "",
180 oldValue = "",
181 newValue = "",
182 badge = "",
183 isAddition = false,
184 isRemoval = false,
185 } = config;
186
187 return (
188 <div
189 classList={{
190 "grid grid-cols-[auto_1fr] gap-y-0.5 gap-x-2": true,
191 "opacity-70": diff.orig.nullified,
192 }}
193 >
194 <div class={`${icon} iconify shrink-0 self-center`} />
195 <div class="flex min-w-0 items-center gap-1.5">
196 <p
197 classList={{
198 "font-medium text-sm": true,
199 "line-through": diff.orig.nullified,
200 }}
201 >
202 {title}
203 </p>
204 <Show when={badge}>
205 <span class="shrink-0 rounded bg-neutral-200 px-1.5 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300">
206 #{badge}
207 </span>
208 </Show>
209 <Show when={diff.orig.nullified}>
210 <span class="ml-auto rounded bg-neutral-200 px-2 py-0.5 text-xs font-medium dark:bg-neutral-700">
211 Nullified
212 </span>
213 </Show>
214 </div>
215 <Show when={value}>
216 <div></div>
217 <div
218 classList={{
219 "text-sm break-all flex items-start gap-2 min-w-0": true,
220 "text-green-700 dark:text-green-300": isAddition,
221 "text-red-700 dark:text-red-300": isRemoval,
222 "text-neutral-600 dark:text-neutral-400": !isAddition && !isRemoval,
223 }}
224 >
225 <Show when={isAddition}>
226 <span class="shrink-0">+</span>
227 </Show>
228 <Show when={isRemoval}>
229 <span class="shrink-0">−</span>
230 </Show>
231 <span class="break-all">{value}</span>
232 </div>
233 </Show>
234 <Show when={oldValue && newValue}>
235 <div></div>
236 <div class="flex min-w-0 flex-col text-sm">
237 <div class="flex items-start gap-2 text-red-700 dark:text-red-300">
238 <span class="shrink-0">−</span>
239 <span class="break-all">{oldValue}</span>
240 </div>
241 <div class="flex items-start gap-2 text-green-700 dark:text-green-300">
242 <span class="shrink-0">+</span>
243 <span class="break-all">{newValue}</span>
244 </div>
245 </div>
246 </Show>
247 </div>
248 );
249 };
250
251 return (
252 <div class="flex w-full flex-col gap-3 wrap-anywhere">
253 <div class="flex flex-col gap-2">
254 <div class="flex items-center gap-1.5 text-sm">
255 <div class="iconify lucide--filter" />
256 <p class="font-medium">Filter by type</p>
257 </div>
258 <div class="flex flex-wrap gap-1 sm:gap-2">
259 <FilterButton icon="iconify lucide--at-sign" event="handle" label="Alias" />
260 <FilterButton icon="iconify lucide--hard-drive" event="service" label="Service" />
261 <FilterButton
262 icon="iconify lucide--shield-check"
263 event="verification_method"
264 label="Verification"
265 />
266 <FilterButton
267 icon="iconify lucide--key-round"
268 event="rotation_key"
269 label="Rotation Key"
270 />
271 </div>
272 </div>
273 <div class="flex items-center gap-1.5 text-sm font-medium">
274 <Show when={validLog() === true}>
275 <span class="iconify lucide--check-circle-2 text-green-500 dark:text-green-400"></span>
276 <span>Valid log</span>
277 </Show>
278 <Show when={validLog() === false}>
279 <span class="iconify lucide--x-circle text-red-500 dark:text-red-400"></span>
280 <span>Log validation failed</span>
281 </Show>
282 <Show when={validLog() === undefined}>
283 <span class="iconify lucide--loader-circle animate-spin"></span>
284 <span>Validating log...</span>
285 </Show>
286 </div>
287 <div class="flex flex-col gap-3">
288 <For each={plcOps()}>
289 {([entry, diffs]) => (
290 <Show when={shouldShowEntry(diffs)}>
291 <div class="flex flex-col gap-1">
292 <span class="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
293 {localDateFromTimestamp(new Date(entry.createdAt).getTime())}
294 </span>
295 <div class="flex flex-col gap-2 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-3 text-sm dark:border-neutral-700 dark:bg-neutral-800">
296 <For each={diffs.filter(shouldShowDiff)}>
297 {(diff) => <DiffItem diff={diff} />}
298 </For>
299 </div>
300 </div>
301 </Show>
302 )}
303 </For>
304 </div>
305 </div>
306 );
307};