Create logs route

This commit is contained in:
thiloho
2024-09-13 17:04:04 +02:00
parent e661368b89
commit 2b97a28488
9 changed files with 326 additions and 43 deletions

View File

@@ -8,6 +8,7 @@
"name": "web-app", "name": "web-app",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"fast-diff": "1.3.0",
"github-slugger": "2.0.0", "github-slugger": "2.0.0",
"highlight.js": "11.10.0", "highlight.js": "11.10.0",
"isomorphic-dompurify": "2.14.0", "isomorphic-dompurify": "2.14.0",
@@ -2549,6 +2550,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",

View File

@@ -38,6 +38,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"fast-diff": "1.3.0",
"github-slugger": "2.0.0", "github-slugger": "2.0.0",
"highlight.js": "11.10.0", "highlight.js": "11.10.0",
"isomorphic-dompurify": "2.14.0", "isomorphic-dompurify": "2.14.0",

View File

@@ -1,14 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
const { children, id, text }: { children: Snippet; id: string; text: string } = $props(); const {
children,
id,
text,
isWider = false
}: { children: Snippet; id: string; text: string; isWider?: boolean } = $props();
const modalId = `${id}-modal`; const modalId = `${id}-modal`;
</script> </script>
<a href={`#${modalId}`} role="button">{text}</a> <a href={`#${modalId}`} role="button">{text}</a>
<div id={modalId} class="modal"> <div id={modalId} class="modal" style="--modal-width: {isWider ? 600 : 300}px">
<div class="modal__content"> <div class="modal__content">
{@render children()} {@render children()}
<a href="#!" role="button">Close</a> <a href="#!" role="button">Close</a>
@@ -46,7 +51,7 @@
background-color: var(--bg-primary); background-color: var(--bg-primary);
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: var(--border-primary); border: var(--border-primary);
inline-size: 300px; inline-size: var(--modal-width);
max-inline-size: 100%; max-inline-size: 100%;
max-block-size: calc(100vh - var(--space-m)); max-block-size: calc(100vh - var(--space-m));
overflow-y: auto; overflow-y: auto;

View File

@@ -55,6 +55,9 @@
<li> <li>
<a href="/website/{id}/publish">Publish</a> <a href="/website/{id}/publish">Publish</a>
</li> </li>
<li>
<a href="/website/{id}/logs">Logs</a>
</li>
</ul> </ul>
</nav> </nav>
@@ -120,13 +123,13 @@
} }
.operations { .operations {
border-inline-end: var(--border-primary);
padding-block-start: var(--space-s); padding-block-start: var(--space-s);
} }
.preview { .preview {
display: flex; display: flex;
padding-block-start: var(--space-s); padding-block-start: var(--space-s);
border-inline-start: var(--border-primary);
} }
} }
</style> </style>

View File

@@ -22,12 +22,12 @@ export interface Article {
cover_image: string | null; cover_image: string | null;
publication_date: Date | null; publication_date: Date | null;
main_content: string | null; main_content: string | null;
category: string | null;
article_weight: number | null;
created_at: Date; created_at: Date;
last_modified_at: Date; last_modified_at: Date;
last_modified_by: string | null; last_modified_by: string | null;
title_description_search: any | null; title_description_search: any | null;
category: string | null;
article_weight: number | null;
} }
export interface ArticleInput { export interface ArticleInput {
id?: string; id?: string;
@@ -39,12 +39,12 @@ export interface ArticleInput {
cover_image?: string | null; cover_image?: string | null;
publication_date?: Date | null; publication_date?: Date | null;
main_content?: string | null; main_content?: string | null;
category?: string | null;
article_weight?: number | null;
created_at?: Date; created_at?: Date;
last_modified_at?: Date; last_modified_at?: Date;
last_modified_by?: string | null; last_modified_by?: string | null;
title_description_search?: any | null; title_description_search?: any | null;
category?: string | null;
article_weight?: number | null;
} }
const article = { const article = {
tableName: "article", tableName: "article",
@@ -58,12 +58,12 @@ const article = {
"cover_image", "cover_image",
"publication_date", "publication_date",
"main_content", "main_content",
"category",
"article_weight",
"created_at", "created_at",
"last_modified_at", "last_modified_at",
"last_modified_by", "last_modified_by",
"title_description_search", "title_description_search"
"category",
"article_weight"
], ],
requiredForInsert: ["website_id", "title"], requiredForInsert: ["website_id", "title"],
primaryKey: "id", primaryKey: "id",
@@ -71,8 +71,8 @@ const article = {
website_id: { table: "website", column: "id", $type: null as unknown as Website }, website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User }, user_id: { table: "user", column: "id", $type: null as unknown as User },
cover_image: { table: "media", column: "id", $type: null as unknown as Media }, cover_image: { table: "media", column: "id", $type: null as unknown as Media },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }, category: { table: "docs_category", column: "id", $type: null as unknown as DocsCategory },
category: { table: "docs_category", column: "id", $type: null as unknown as DocsCategory } last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
}, },
$type: null as unknown as Article, $type: null as unknown as Article,
$input: null as unknown as ArticleInput $input: null as unknown as ArticleInput
@@ -80,26 +80,39 @@ const article = {
// Table change_log // Table change_log
export interface ChangeLog { export interface ChangeLog {
website_id: string; id: string;
user_id: string; website_id: string | null;
change_summary: string; user_id: string | null;
previous_value: Json | null; tstamp: Date;
new_value: Json | null; table_name: string;
timestamp: Date; operation: string;
old_value: any | null;
new_value: any | null;
} }
export interface ChangeLogInput { export interface ChangeLogInput {
website_id: string; id?: string;
user_id?: string; website_id?: string | null;
change_summary: string; user_id?: string | null;
previous_value?: Json | null; tstamp?: Date;
new_value?: Json | null; table_name: string;
timestamp?: Date; operation: string;
old_value?: any | null;
new_value?: any | null;
} }
const change_log = { const change_log = {
tableName: "change_log", tableName: "change_log",
columns: ["website_id", "user_id", "change_summary", "previous_value", "new_value", "timestamp"], columns: [
requiredForInsert: ["website_id", "change_summary"], "id",
primaryKey: "website_id", "website_id",
"user_id",
"tstamp",
"table_name",
"operation",
"old_value",
"new_value"
],
requiredForInsert: ["table_name", "operation"],
primaryKey: "id",
foreignKeys: { foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website }, website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User } user_id: { table: "user", column: "id", $type: null as unknown as User }
@@ -153,6 +166,8 @@ export interface DocsCategory {
user_id: string | null; user_id: string | null;
category_name: string; category_name: string;
category_weight: number; category_weight: number;
last_modified_at: Date;
last_modified_by: string | null;
} }
export interface DocsCategoryInput { export interface DocsCategoryInput {
id?: string; id?: string;
@@ -160,15 +175,26 @@ export interface DocsCategoryInput {
user_id?: string | null; user_id?: string | null;
category_name: string; category_name: string;
category_weight: number; category_weight: number;
last_modified_at?: Date;
last_modified_by?: string | null;
} }
const docs_category = { const docs_category = {
tableName: "docs_category", tableName: "docs_category",
columns: ["id", "website_id", "user_id", "category_name", "category_weight"], columns: [
"id",
"website_id",
"user_id",
"category_name",
"category_weight",
"last_modified_at",
"last_modified_by"
],
requiredForInsert: ["website_id", "category_name", "category_weight"], requiredForInsert: ["website_id", "category_name", "category_weight"],
primaryKey: "id", primaryKey: "id",
foreignKeys: { foreignKeys: {
website_id: { table: "website", column: "id", $type: null as unknown as Website }, website_id: { table: "website", column: "id", $type: null as unknown as Website },
user_id: { table: "user", column: "id", $type: null as unknown as User } user_id: { table: "user", column: "id", $type: null as unknown as User },
last_modified_by: { table: "user", column: "id", $type: null as unknown as User }
}, },
$type: null as unknown as DocsCategory, $type: null as unknown as DocsCategory,
$input: null as unknown as DocsCategoryInput $input: null as unknown as DocsCategoryInput
@@ -390,10 +416,10 @@ export interface Website {
content_type: string; content_type: string;
title: string; title: string;
created_at: Date; created_at: Date;
is_published: boolean;
last_modified_at: Date; last_modified_at: Date;
last_modified_by: string | null; last_modified_by: string | null;
title_search: any | null; title_search: any | null;
is_published: boolean;
} }
export interface WebsiteInput { export interface WebsiteInput {
id?: string; id?: string;
@@ -401,10 +427,10 @@ export interface WebsiteInput {
content_type: string; content_type: string;
title: string; title: string;
created_at?: Date; created_at?: Date;
is_published?: boolean;
last_modified_at?: Date; last_modified_at?: Date;
last_modified_by?: string | null; last_modified_by?: string | null;
title_search?: any | null; title_search?: any | null;
is_published?: boolean;
} }
const website = { const website = {
tableName: "website", tableName: "website",
@@ -414,10 +440,10 @@ const website = {
"content_type", "content_type",
"title", "title",
"created_at", "created_at",
"is_published",
"last_modified_at", "last_modified_at",
"last_modified_by", "last_modified_by",
"title_search", "title_search"
"is_published"
], ],
requiredForInsert: ["content_type", "title"], requiredForInsert: ["content_type", "title"],
primaryKey: "id", primaryKey: "id",

View File

@@ -1,17 +1,20 @@
import type { LayoutServerLoad } from "./$types"; import type { LayoutServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils"; import { API_BASE_PREFIX } from "$lib/server/utils";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import type { Website, Home } from "$lib/db-schema"; import type { Website, Home, User } from "$lib/db-schema";
export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => { export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
const websiteData = await fetch(`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`, { const websiteData = await fetch(
`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}&select=*,user!user_id(username)`,
{
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`, Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json" Accept: "application/vnd.pgrst.object+json"
} }
}); }
);
if (!websiteData.ok) { if (!websiteData.ok) {
throw error(404, "Website not found"); throw error(404, "Website not found");
@@ -26,7 +29,7 @@ export const load: LayoutServerLoad = async ({ params, fetch, cookies }) => {
} }
}); });
const website: Website = await websiteData.json(); const website: Website & { user: { username: User["username"] } } = await websiteData.json();
const home: Home = await homeData.json(); const home: Home = await homeData.json();
return { return {

View File

@@ -0,0 +1,72 @@
import type { PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
import type { ChangeLog, User, Collab } from "$lib/db-schema";
export const load: PageServerLoad = async ({ parent, fetch, params, cookies, url }) => {
const userFilter = url.searchParams.get("logs_filter_user");
const resourceFilter = url.searchParams.get("logs_filter_resource");
const operationFilter = url.searchParams.get("logs_filter_operation");
const searchParams = new URLSearchParams();
const baseFetchUrl = `${API_BASE_PREFIX}/change_log?website_id=eq.${params.websiteId}&select=id,table_name,operation,tstamp,old_value,new_value,user!inner(username)&order=tstamp.desc`;
if (userFilter && userFilter !== "all") {
searchParams.append("user.username", `eq.${userFilter}`);
}
if (resourceFilter && resourceFilter !== "all") {
searchParams.append("table_name", `eq.${resourceFilter}`);
}
if (operationFilter && operationFilter !== "all") {
searchParams.append("operation", `eq.${operationFilter.toUpperCase()}`);
}
const constructedFetchUrl = `${baseFetchUrl}&${searchParams.toString()}`;
const changeLogData = await fetch(constructedFetchUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
});
const resultChangeLogData = await fetch(constructedFetchUrl, {
method: "HEAD",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Prefer: "count=exact"
}
});
const resultChangeLogCount = Number(
resultChangeLogData.headers.get("content-range")?.split("/").at(-1)
);
const collabData = await fetch(
`${API_BASE_PREFIX}/collab?website_id=eq.${params.websiteId}&select=*,user!user_id(*)&order=last_modified_at.desc,added_at.desc`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
}
);
const changeLog: (ChangeLog & { user: { username: User["username"] } })[] =
await changeLogData.json();
const collaborators: (Collab & { user: User })[] = await collabData.json();
const { website, home } = await parent();
return {
changeLog,
resultChangeLogCount,
website,
home,
collaborators
};
};

View File

@@ -0,0 +1,156 @@
<script lang="ts">
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
import DateTime from "$lib/components/DateTime.svelte";
import Modal from "$lib/components/Modal.svelte";
import type { PageServerData } from "./$types";
import diff from "fast-diff";
import { page } from "$app/stores";
import { tables } from "$lib/db-schema";
const { data }: { data: PageServerData } = $props();
const htmlDiff = (oldValue: string, newValue: string) => {
return diff(oldValue, newValue)
.map(([type, value]) => {
let newString = "";
switch (type) {
case 1:
newString += `<ins>${value}</ins>`;
break;
case 0:
newString += `${value}`;
break;
case -1:
newString += `<del>${value}</del>`;
break;
}
return newString;
})
.join("");
};
let resources = $state({});
if (data.website.content_type === "Blog") {
const { user, change_log, media, docs_category, ...restTables } = tables;
resources = restTables;
}
if (data.website.content_type === "Docs") {
const { user, change_log, media, ...restTables } = tables;
resources = restTables;
}
</script>
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>
<section id="logs">
<hgroup>
<h2>
<a href="#logs">Logs</a>
</h2>
<p>
<strong>{data.resultChangeLogCount}</strong>
<small>results</small>
</p>
</hgroup>
<details>
<summary>Filter</summary>
<form method="GET">
<label>
Username:
<input
list="users-{data.website.id}"
name="logs_filter_user"
value={$page.url.searchParams.get("logs_filter_user")}
/>
<datalist id="users-{data.website.id}">
<option value={data.website.user.username}></option>
{#each data.collaborators as { user: { username } }}
<option value={username}></option>
{/each}
</datalist>
</label>
<label>
Resource:
<select name="logs_filter_resource">
<option value="all">Show all</option>
{#each Object.keys(resources) as resource}
<option
value={resource}
selected={resource === $page.url.searchParams.get("logs_filter_resource")}
>{resource}</option
>
{/each}
</select>
</label>
<label>
Operation:
<select name="logs_filter_operation">
<option value="all">Show all</option>
<option
value="insert"
selected={"insert" === $page.url.searchParams.get("logs_filter_operation")}
>Insert</option
>
<option
value="update"
selected={"update" === $page.url.searchParams.get("logs_filter_operation")}
>Update</option
>
<option
value="delete"
selected={"delete" === $page.url.searchParams.get("logs_filter_operation")}
>Delete</option
>
</select>
</label>
<button type="submit">Submit</button>
</form>
</details>
<div class="scroll-container">
<table>
<thead>
<tr>
<th>User</th>
<th>Resource</th>
<th>Operation</th>
<th>Date and time</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
{#each data.changeLog as { id, table_name, operation, tstamp, old_value, new_value, user }}
<tr>
<td>{user.username}</td>
<td>{table_name}</td>
<td>{operation}</td>
<td>
<DateTime date={tstamp} />
</td>
<td>
<Modal id="log-{id}" text="Show" isWider={true}>
{@const oldValue = JSON.stringify(old_value, null, 2)}
{@const newValue = JSON.stringify(new_value, null, 2)}
<hgroup>
<h3>Log changes</h3>
<p>{table_name} &mdash; {operation}</p>
</hgroup>
<pre style="white-space: pre-wrap">{@html htmlDiff(oldValue, newValue)}</pre>
</Modal>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
</WebsiteEditor>

View File

@@ -331,3 +331,13 @@ td {
padding: var(--space-2xs); padding: var(--space-2xs);
border: var(--border-primary); border: var(--border-primary);
} }
ins {
background-color: var(--color-success);
color: var(--color-text-invert);
}
del {
background-color: var(--color-error);
color: var(--color-text-invert);
}