<template>
<div
v-if="auditLogs && auditLogs.length > 0"
ref="scrollContainerRef"
class="p-xs overflow-x-hidden max-h-full"
@scrollend="handleScrollEnd"
>
<div class="flex flex-row justify-between items-center gap-xs pb-xs">
<NewButton class="grow" label="Expand All" @click="handleAll('expand')" />
<NewButton
class="grow"
label="Collapse All"
@click="handleAll('collapse')"
/>
</div>
<div ref="wrapperRef" class="grid gap-sm relative">
<!-- Single continuous timeline line behind all items -->
<div
v-if="auditLogs.length > 0"
:class="
clsx(
'absolute left-[5px] w-[2px] z-0 top-sm translate-x-[-50%]',
themeClasses('bg-neutral-400', 'bg-neutral-600'),
)
"
:style="timelineStyle"
/>
<div
v-for="auditLog in auditLogs"
ref="logRefs"
:key="identifier(auditLog)"
class="grid grid-cols-[10px_1fr] gap-2xs items-stretch"
>
<div
class="relative flex flex-row justify-center items-start h-full pt-sm self-stretch"
>
<div class="w-2.5 h-2.5 rounded-full bg-neutral-500 flex-shrink-0" />
</div>
<div
:class="
clsx(
'group/historylog',
'p-xs border rounded-sm min-h-[2.5rem] min-w-0 break-words cursor-pointer',
themeClasses(
'border-neutral-400 hover:bg-neutral-100',
'border-neutral-600 hover:bg-neutral-700',
),
)
"
@click="toggleExpand(auditLog)"
>
<div class="flex flex-col gap-2xs">
<div
class="flex flex-row gap-xs items-center justify-between text-sm"
>
<TruncateWithTooltip
class="py-2xs"
:class="
clsx(
'py-2xs',
themeClasses('text-neutral-800', 'text-neutral-100'),
)
"
>
{{ auditLog.title }}
</TruncateWithTooltip>
<!-- Put the timestamp the end. -->
<div class="flex flex-row gap-xs items-center">
<Timestamp
class="text-neutral-400"
:date="auditLog.inner.timestamp"
relative="shorthand"
enableDetailTooltip
refresh
/>
</div>
</div>
<div
v-if="auditLog.beforeValue && auditLog.afterValue"
class="flex flex-col text-xs"
>
<div class="flex flex-row items-center gap-2xs">
<span class="text-neutral-500 font-bold w-14">Previous:</span>
<TruncateWithTooltip
:class="
clsx(
'py-2xs line-through',
themeClasses('text-neutral-500', 'text-neutral-400'),
)
"
>
{{ auditLog.beforeValue }}
</TruncateWithTooltip>
</div>
<div class="flex flex-row items-center gap-2xs">
<span class="text-neutral-500 font-bold w-14">Now:</span>
<TruncateWithTooltip
:class="
clsx(
'py-2xs',
themeClasses('text-neutral-800', 'text-neutral-100'),
)
"
>
{{ auditLog.afterValue }}
</TruncateWithTooltip>
</div>
</div>
</div>
<Transition
enterActiveClass="transition duration-100"
enterFromClass="opacity-0 scale-95"
enterToClass="opacity-100 scale-100"
leaveActiveClass="transition duration-100"
leaveFromClass="opacity-100 scale-100"
leaveToClass="opacity-0 scale-95"
@beforeEnter="startUpdateTimeline"
@beforeLeave="startUpdateTimeline"
@afterEnter="finishUpdateTimeline"
@afterLeave="finishUpdateTimeline"
>
<div v-if="shouldExpand(auditLog)" class="mt-xs transition-all">
<CodeViewer
:code="JSON.stringify(auditLog.inner, null, 2)"
@click.stop
/>
</div>
</Transition>
</div>
</div>
</div>
<!-- Loading indicator and marker for when all entries are loaded. -->
<div
v-if="isFetchingNextPage || !hasNextPage"
class="flex flex-row items-center justify-center mt-md text-sm text-neutral-500"
>
<Icon v-if="isFetchingNextPage" name="loader" size="sm" />
<span v-if="isFetchingNextPage"> Loading More Logs... </span>
<span v-else-if="!hasNextPage"> All Entries Loaded </span>
</div>
</div>
<LoadingMessage v-else-if="!auditLogs" message="Loading History" />
<EmptyState
v-else
class="p-lg"
icon="component"
text="No History Found"
secondaryText="No history found for this component in this change set"
/>
</template>
<script setup lang="ts">
import { useInfiniteQuery } from "@tanstack/vue-query";
import { computed, reactive, ref } from "vue";
import {
TruncateWithTooltip,
Timestamp,
Icon,
themeClasses,
LoadingMessage,
NewButton,
} from "@si/vue-lib/design-system";
import clsx from "clsx";
import { ComponentId } from "@/api/sdf/dal/component";
import { AuditLog, EntityKind } from "@/workers/types/entity_kind_types";
import CodeViewer from "@/components/CodeViewer.vue";
import { useMakeKey } from "@/store/realtime/heimdall";
import { routes, useApi } from "./api_composables";
import EmptyState from "./EmptyState.vue";
import { useContext } from "./logic_composables/context";
const props = defineProps<{
componentId: ComponentId;
enabled?: boolean;
}>();
const componentId = computed(() => props.componentId);
const scrollContainerRef = ref<HTMLElement | null>(null);
const wrapperRef = ref<HTMLDivElement>();
const logRefs = ref<HTMLDivElement[]>();
const ctx = useContext();
const key = useMakeKey();
const pageSize = 100;
const increaseSize = 50;
// Identifies the specific audit log we are working with.
const identifier = (auditLog: ProcessedAuditLog) =>
`${props.componentId}-${auditLog.inner.kind}-${auditLog.inner.timestamp}`;
// Keep track of which audit logs should be expanded.
const expand = reactive<Record<string, boolean>>({});
const toggleExpand = (auditLog: ProcessedAuditLog) => {
const key = identifier(auditLog);
if (expand[key] === undefined) {
expand[key] = true;
} else {
expand[key] = !expand[key];
}
};
const shouldExpand = (auditLog: ProcessedAuditLog): boolean => {
return expand[identifier(auditLog)] ?? false;
};
// // Ability to expand and collapse everything.
const handleAll = (option: "expand" | "collapse") => {
if (auditLogs.value) {
for (const auditLog of auditLogs.value) {
expand[identifier(auditLog)] = option === "expand";
}
}
};
interface ProcessedAuditLog {
inner: AuditLog;
title: string;
beforeValue?: string;
afterValue?: string;
}
type AuditLogsForComponentResponse = {
logs: AuditLog[];
canLoadMore: boolean;
};
const auditLogsApi = useApi(ctx);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: key(EntityKind.AuditLogsForComponent, componentId),
enabled: computed(() => props.enabled ?? true),
queryFn: async ({ pageParam = pageSize }) => {
const call = auditLogsApi.endpoint<AuditLogsForComponentResponse>(
routes.AuditLogsForComponent,
{ componentId: componentId.value },
);
const response = await call.get(
new URLSearchParams({
size: `${pageParam}`,
sort_ascending: "false",
}),
);
if (auditLogsApi.ok(response)) {
return response.data;
}
return { logs: [], canLoadMore: false };
},
staleTime: 60 * 2 * 1000,
initialPageParam: pageSize,
getNextPageParam: (lastPage: AuditLogsForComponentResponse) => {
if (!lastPage.canLoadMore) return undefined;
return lastPage.logs.length + increaseSize;
},
maxPages: 1,
});
// Flatten all pages and filter out socket-related audit logs
const auditLogs = computed((): ProcessedAuditLog[] | undefined => {
if (!data.value) return undefined;
// There should only be one page!
const allLogs = data.value.pages.flatMap(
(page: AuditLogsForComponentResponse) => page.logs,
);
return allLogs
.filter((auditLog: AuditLog) => {
// NOTE(nick,paul,brit): this is intentionally omega hacked. We expect this to change over time.
if (auditLog.kind === "UpdateDependentProperty") {
if (
["codeItem", "qualificationItem", "resource_value"].includes(
auditLog.entityName,
)
)
return false;
// End my suffering.
const beforeValue = (auditLog.metadata.beforeValue as string) ?? "null";
const afterValue = (auditLog.metadata.afterValue as string) ?? "null";
if (beforeValue === afterValue) return false;
}
// Filter out sockets.
if (
auditLog.kind === "UpdateDependentOutputSocket" ||
auditLog.kind === "UpdateDependentInputSocket"
)
return false;
// We made it!
return true;
})
.map((filteredAuditLog: AuditLog): ProcessedAuditLog => {
// Now that we have filtered the audit logs to only those that are relevant to the user, we
// can change how they are displayed based on the kind.
if (
["UpdateDependentProperty", "SetAttribute", "UnsetAttribute"].includes(
filteredAuditLog.kind,
)
) {
if (filteredAuditLog.kind === "UpdateDependentProperty") {
return {
inner: filteredAuditLog,
title: `${filteredAuditLog.entityName} changed`,
beforeValue:
(filteredAuditLog.metadata.beforeValue as string) ?? "<empty>",
afterValue:
(filteredAuditLog.metadata.afterValue as string) ?? "<empty>",
};
} else {
const beforeValue = filteredAuditLog.metadata.beforeValue as Record<
string,
unknown
> | null;
const afterValue = filteredAuditLog.metadata.afterValue as Record<
string,
unknown
> | null;
const getDisplayValue = (
value: Record<string, unknown> | null,
): string => {
if (!value) return "<empty>";
if (value.Subscription) {
const subscription = value.Subscription as Record<
string,
unknown
>;
const compId = subscription.source_component_id as string;
const componentName =
ctx.componentDetails.value[compId]?.name || "UnknownComponent";
const subscriptionValue = subscription.value;
const path =
subscriptionValue !== null && subscriptionValue !== undefined
? (subscriptionValue as string)
: `<${subscription.source_path}>`;
return `${componentName}/${path}`;
}
if (value.Value) {
return value.Value as string;
}
return "<empty>";
};
return {
inner: filteredAuditLog,
title: `${filteredAuditLog.entityName} changed`,
beforeValue: getDisplayValue(beforeValue),
afterValue: getDisplayValue(afterValue),
};
}
}
// By default, display the audit log how we do in the audit trail screen.
return {
inner: filteredAuditLog,
title: `${filteredAuditLog.title} ${filteredAuditLog.entityType}`,
};
});
});
const handleScrollEnd = () => {
if (!scrollContainerRef.value) return;
if (hasNextPage.value && !isFetchingNextPage.value) {
fetchNextPage();
}
};
const timelineForceUpdate = ref(false);
const timelineStyle = computed(() => {
if (!logRefs.value) return "";
const lastLog = logRefs.value[logRefs.value.length - 1];
if (!lastLog) return "";
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
timelineForceUpdate.value;
return `bottom: ${lastLog.clientHeight - 22}px`;
});
const startUpdateTimeline = () => {
timelineForceUpdate.value = true;
};
const finishUpdateTimeline = () => {
timelineForceUpdate.value = false;
};
</script>