Implement database administration
Add routes and admin panel elements for creating a database backup, restoring from a backup, deleting the existing schedule, and replacing the database with the demo schedule. These server as crude ways to manage the data stored in the system.
This commit is contained in:
parent
b2f48e98e0
commit
e5e923bc8d
6 changed files with 190 additions and 5 deletions
|
@ -1,12 +1,102 @@
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<h2>Users</h2>
|
<Tabs
|
||||||
<TableUsers />
|
:tabs
|
||||||
|
default="users"
|
||||||
|
>
|
||||||
|
<template #users>
|
||||||
|
<TableUsers />
|
||||||
|
</template>
|
||||||
|
<template #database>
|
||||||
|
<p>
|
||||||
|
To backup the database you can create and download a
|
||||||
|
database snapshot. This will export all schedule and user
|
||||||
|
data stored on the server into a single JSON file that can
|
||||||
|
then later be used to replace the exsting content of the
|
||||||
|
database to restore the system to the state it was in when
|
||||||
|
the snapshot was created.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
action="/api/admin/database-export"
|
||||||
|
method="post"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<button type="submit">
|
||||||
|
Download snapshot
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Restore Database</legend>
|
||||||
|
<p>
|
||||||
|
To restore the system to a previous state you can load a
|
||||||
|
database snapshot obtained from previously running the
|
||||||
|
database snapshot tool above. This will delete all
|
||||||
|
changes including new events, shifts, users and user
|
||||||
|
data that has created since the snapshot was created.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
action="/api/admin/database-import"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
method="post"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
Database snapshot:
|
||||||
|
<input name="snapshot" type="file">
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button type="submit">
|
||||||
|
Restore database
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Danger Zone</legend>
|
||||||
|
<p>
|
||||||
|
If you wish to start over with the schedule, you can use
|
||||||
|
this button to delete the current schedule.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
action="/api/admin/delete-schedule"
|
||||||
|
method="post"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<button type="submit">
|
||||||
|
Delete schedule
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
<p>
|
||||||
|
If you wish to see a demo of how the system can be used
|
||||||
|
you can replace the existing schedule with a generated
|
||||||
|
demo schedule that shows off all the features. <b>This
|
||||||
|
will delete all existing data including users!</b>
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
action="/api/admin/database-import-demo"
|
||||||
|
method="post"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<button type="submit">
|
||||||
|
Replace with demo
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
const tabs = [
|
||||||
|
{ id: "users", title: "Users" },
|
||||||
|
{ id: "database", title: "Database" },
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
15
server/api/admin/database-export.post.ts
Normal file
15
server/api/admin/database-export.post.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { readNextSessionId, readNextUserId, readSchedule, readSessions, readSubscriptions, readUsers } from "~/server/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireServerSessionWithAdmin(event);
|
||||||
|
setHeader(event, "Content-Disposition", 'attachment; filename="database-dump.json"');
|
||||||
|
setHeader(event, "Content-Type", "application/json; charset=utf-8");
|
||||||
|
return {
|
||||||
|
nextUserId: await readNextUserId(),
|
||||||
|
users: await readUsers(),
|
||||||
|
nextSessionId: await readNextSessionId(),
|
||||||
|
sessions: await readSessions(),
|
||||||
|
subscriptions: await readSubscriptions(),
|
||||||
|
schedule: await readSchedule(),
|
||||||
|
};
|
||||||
|
})
|
7
server/api/admin/database-import-demo.post.ts
Normal file
7
server/api/admin/database-import-demo.post.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { writeSchedule, writeUsers } from "~/server/database";
|
||||||
|
import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule";
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireServerSessionWithAdmin(event);
|
||||||
|
await writeUsers(generateDemoAccounts());
|
||||||
|
await writeSchedule(generateDemoSchedule());
|
||||||
|
})
|
44
server/api/admin/database-import.post.ts
Normal file
44
server/api/admin/database-import.post.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { readNextSessionId, readNextUserId, readSessions, type ServerSession, type ServerUser, writeNextSessionId, writeNextUserId, writeSchedule, writeSessions, writeSubscriptions, writeUsers } from "~/server/database";
|
||||||
|
import type { ApiSchedule, ApiSubscription } from "~/shared/types/api";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireServerSessionWithAdmin(event);
|
||||||
|
const formData = await readMultipartFormData(event);
|
||||||
|
let snapshot: undefined | {
|
||||||
|
nextUserId: number,
|
||||||
|
users: ServerUser[],
|
||||||
|
nextSessionId: number,
|
||||||
|
sessions: ServerSession[],
|
||||||
|
subscriptions: ApiSubscription[],
|
||||||
|
schedule: ApiSchedule,
|
||||||
|
};
|
||||||
|
for (const part of formData ?? []) {
|
||||||
|
if (part.name === "snapshot") {
|
||||||
|
snapshot = JSON.parse(part.data.toString("utf-8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!snapshot) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Bad Request",
|
||||||
|
message: "snapshot missing."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNextUserId = await readNextUserId();
|
||||||
|
await writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId));
|
||||||
|
await writeUsers(snapshot.users);
|
||||||
|
const currentNextSessionId = await readNextSessionId();
|
||||||
|
await writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId));
|
||||||
|
const currentSessions = new Map((await readSessions()).map(session => [session.id, session]));
|
||||||
|
await writeSessions(snapshot.sessions.filter(session => {
|
||||||
|
const current = currentSessions.get(session.id);
|
||||||
|
// Only keep sessions that match the account id in both sets to avoid
|
||||||
|
// resurrecting deleted sessions. This will still cause session cross
|
||||||
|
// pollution if a snapshot from another instance is loaded here.
|
||||||
|
return current && current.account.id === session.account.id;
|
||||||
|
}));
|
||||||
|
await writeSubscriptions(snapshot.subscriptions);
|
||||||
|
await writeSchedule(snapshot.schedule);
|
||||||
|
await sendRedirect(event, "/");
|
||||||
|
})
|
11
server/api/admin/delete-schedule.post.ts
Normal file
11
server/api/admin/delete-schedule.post.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { writeSchedule } from "~/server/database";
|
||||||
|
import type { ApiSchedule } from "~/shared/types/api";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireServerSessionWithAdmin(event);
|
||||||
|
const schedule: ApiSchedule = {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await writeSchedule(schedule);
|
||||||
|
})
|
|
@ -1,6 +1,5 @@
|
||||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import type { ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
|
import type { ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api";
|
||||||
import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-demo-schedule";
|
|
||||||
import type { Id } from "~/shared/types/common";
|
import type { Id } from "~/shared/types/common";
|
||||||
|
|
||||||
export interface ServerSession {
|
export interface ServerSession {
|
||||||
|
@ -65,7 +64,10 @@ async function readJson<T>(filePath: string, fallback: T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readSchedule() {
|
export async function readSchedule() {
|
||||||
return readJson(schedulePath, generateDemoSchedule);
|
return readJson(schedulePath, (): ApiSchedule => ({
|
||||||
|
id: 111,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSchedule(schedule: ApiSchedule) {
|
export async function writeSchedule(schedule: ApiSchedule) {
|
||||||
|
@ -85,6 +87,14 @@ export async function writeSubscriptions(subscriptions: ApiSubscription[]) {
|
||||||
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
|
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readNextUserId() {
|
||||||
|
return await readJson(nextUserIdPath, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeNextUserId(nextId: number) {
|
||||||
|
await writeFile(nextUserIdPath, String(nextId), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
export async function nextUserId() {
|
export async function nextUserId() {
|
||||||
let nextId = await readJson(nextUserIdPath, 0);
|
let nextId = await readJson(nextUserIdPath, 0);
|
||||||
if (nextId === 0) {
|
if (nextId === 0) {
|
||||||
|
@ -95,13 +105,21 @@ export async function nextUserId() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readUsers() {
|
export async function readUsers() {
|
||||||
return await readJson(usersPath, generateDemoAccounts as () => ServerUser[]);
|
return await readJson(usersPath, (): ServerUser[] => []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeUsers(users: ServerUser[]) {
|
export async function writeUsers(users: ServerUser[]) {
|
||||||
await writeFile(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8");
|
await writeFile(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readNextSessionId() {
|
||||||
|
return await readJson(nextSessionIdPath, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeNextSessionId(nextId: number) {
|
||||||
|
await writeFile(nextSessionIdPath, String(nextId), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
export async function nextSessionId() {
|
export async function nextSessionId() {
|
||||||
const nextId = await readJson(nextSessionIdPath, 0);
|
const nextId = await readJson(nextSessionIdPath, 0);
|
||||||
await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8");
|
await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue