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