Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/frontend/.oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"sortImports": {},
"sortTailwindcss": {}
"sortTailwindcss": {
"stylesheet": "src/app.css"
}
}
4 changes: 3 additions & 1 deletion packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
"@skeletonlabs/skeleton": "^4.12.0",
"@skeletonlabs/skeleton-svelte": "^4.12.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.53.0",
"@sveltejs/kit": "^2.59.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.0",
"@types/node": "^22.19.11",
"eventsource-parser": "^3.0.8",
"globals": "^16.5.0",
"layerchart": "2.0.0-next.63",
"oxfmt": "^0.41.0",
"oxlint": "^1.56.0",
"oxlint-tsgolint": "^0.17.1",
Expand Down
11 changes: 4 additions & 7 deletions packages/frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,11 @@
@import "@skeletonlabs/skeleton";
@import "@skeletonlabs/skeleton-svelte";

@import "$lib/styles/theme.css";
@import "$lib/styles/utilities.css";
@import "./lib/styles/theme.css";
@import "./lib/styles/utilities.css";

@import "layerchart/skeleton-4.css";

input:focus {
outline: none;
}

body {
background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 15px 15px;
}
4 changes: 2 additions & 2 deletions packages/frontend/src/lib/remotes/auth.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const register = form(registerSchema, async (data) => {
return error(409, "A user is already registered");

default:
return error(500, "An error occured");
return error(500, "An error occurred");
}
});

Expand Down Expand Up @@ -56,6 +56,6 @@ export const login = form(loginSchema, async (data, issue) => {
);

default:
return error(500, "An error occured");
return error(500, "An error occurred");
}
});
3 changes: 2 additions & 1 deletion packages/frontend/src/lib/remotes/bot.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export const createBot = form(CreateBotSchema, async (data) => {
return error(401, "Unauthorized");
case 409:
return error(409, "A bot already exists");

default:
return error(500, "An error occured");
return error(500, "An error occurred");
}
});
154 changes: 144 additions & 10 deletions packages/frontend/src/lib/remotes/clusters.remote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { command, getRequestEvent, query } from "$app/server";
import { env } from "$env/dynamic/private";
import type { GetClusterDto } from "@hallmaster/backend/dto";
import {
type GetClusterDto,
type GetClusterLogsDto,
type GetClusterStatsDto,
} from "@hallmaster/backend/dto";
import { error } from "@sveltejs/kit";
import { EventSourceParserStream } from "eventsource-parser/stream";

export const getClusters = query(async (): Promise<GetClusterDto[]> => {
const token = getRequestEvent().cookies.get("token");
Expand All @@ -14,13 +19,47 @@ export const getClusters = query(async (): Promise<GetClusterDto[]> => {
});

switch (response.status) {
case 200:
return await response.json();
case 200: {
const clusters: GetClusterDto[] = await response.json();
return clusters.sort((a, b) => a.id - b.id);
}
case 401:
return error(401, "Unauthorized");

default:
return error(500, "An error occured");
return error(500, "An error occurred");
}
});

export const getClustersLive = query.live(async function* () {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(`${env.API_URL}/clusters/stream`, {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
},
});

switch (response.status) {
case 200: {
if (!response.body) return error(500, "An error occurred");

const chunks = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
// @ts-ignore svelte-check false positive
.values();

for await (const clusters of chunks)
yield (JSON.parse(clusters.data) as GetClusterDto[]).sort((a, b) => a.id - b.id);
break;
}
case 401:
return error(401, "Unauthorized");

default:
return error(500, "An error occurred");
}
});

Expand All @@ -36,14 +75,14 @@ export const startCluster = command("unchecked", async (id: number) => {

switch (response.status) {
case 204:
return;
return await getClusters().refresh();
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Cluster not found");

default:
return error(500, "An error occured");
return error(500, "An error occurred");
}
});

Expand All @@ -59,14 +98,14 @@ export const stopCluster = command("unchecked", async (id: number) => {

switch (response.status) {
case 204:
return;
return await getClusters().refresh();
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Cluster not found");

default:
return error(500, "An error occured");
return error(500, "An error occurred");
}
});

Expand All @@ -82,13 +121,108 @@ export const restartCluster = command("unchecked", async (id: number) => {

switch (response.status) {
case 204:
return;
return await getClusters().refresh();
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Cluster not found");

default:
return error(500, "An error occured");
return error(500, "An error occurred");
}
});

export const getClusterLogs = query(
"unchecked",
async (id: GetClusterDto["id"]): Promise<GetClusterLogsDto> => {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(`${env.API_URL}/clusters/${id}/logs`, {
headers: { Authorization: `Bearer ${token}` },
});

switch (response.status) {
case 200:
return await response.json();
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Cluster not found");

default:
return error(500, "An error occurred");
}
},
);

export const getClusterLogsLive = query.live(
"unchecked",
async function* (id: GetClusterDto["id"]) {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(`${env.API_URL}/clusters/${id}/logs/stream`, {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
},
});

switch (response.status) {
case 200: {
if (!response.body) return error(500, "An error occurred");

const chunks = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
// @ts-ignore svelte-check false positive
.values();

for await (const chunk of chunks) yield JSON.parse(chunk.data) as GetClusterLogsDto[number];
break;
}
case 401:
return error(401, "Unauthorized");

default:
return error(500, "An error occurred");
}
},
);

export const getClusterStatsLive = query.live(
"unchecked",
async function* (id: GetClusterDto["id"]) {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(new URL(`/clusters/${id}/stats/stream?interval=2`, env.API_URL), {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
},
});

switch (response.status) {
case 200: {
if (!response.body) return error(500, "An error occurred");

const chunks = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
// @ts-ignore svelte-check false positive
.values();

for await (const chunk of chunks) yield JSON.parse(chunk.data) as GetClusterStatsDto;
break;
}
case 400:
return error(400, "Cluster has no container or is not running");
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Cluster not found");

default:
return error(500, "An error occurred");
}
},
);
4 changes: 4 additions & 0 deletions packages/frontend/src/lib/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,7 @@
--color-surface-contrast-900: var(--color-surface-contrast-light);
--color-surface-contrast-950: var(--color-surface-contrast-light);
}

@theme {
--text-tiny: 0.625rem;
}
6 changes: 3 additions & 3 deletions packages/frontend/src/lib/styles/utilities.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
@utility form {
@apply card;
@apply bg-surface-50-950!;
@apply border-surface-200-800 border;
@apply ring-surface-100-900 ring-6;
@apply outline-surface-200-800 outline outline-offset-6;
@apply border border-surface-200-800;
@apply ring-6 ring-surface-100-900;
@apply outline outline-offset-6 outline-surface-200-800;
@apply m-1.75;

@apply flex flex-col items-center gap-4 p-6 *:w-full;
Expand Down
5 changes: 5 additions & 0 deletions packages/frontend/src/lib/utils/formatBytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default function formatBytes(bytes: number) {
const suffixes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(1024)));
return (!bytes && "0 Bytes") || (bytes / Math.pow(1024, i)).toFixed(2) + " " + suffixes[i];
}
9 changes: 9 additions & 0 deletions packages/frontend/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
import type { LayoutProps } from "./$types";
let { children }: LayoutProps = $props();
</script>

<main class="max-w-6xl mx-auto">
{@render children()}
</main>
Loading
Loading