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
2 changes: 2 additions & 0 deletions packages/backend/src/bot/dto/get-recommended-shards.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export const GetRecommendedShardsSchema = z.object({
});

export class GetRecommendedShardsZodDto extends createZodDto(GetRecommendedShardsSchema) {}

export type GetRecommendedShardsDto = z.infer<typeof GetRecommendedShardsSchema>;
1 change: 1 addition & 0 deletions packages/backend/src/index.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { loginSchema } from './auth/schemas/login.schema.js';
export { CreateBotSchema, type CreateBotDto } from './bot/dto/create-bot.dto.js';
export { UpdateBotSchema, type UpdateBotDto } from './bot/dto/update-bot.dto.js';
export { GetBotSchema, type GetBotDto } from './bot/dto/get-bot.dto.js';
export { GetRecommendedShardsSchema, type GetRecommendedShardsDto } from './bot/dto/get-recommended-shards.dto.js';

export { SseIntervalQuerySchema } from './clusters/dto/sse-interval-query.dto.js';
export { ClusterIdParamSchema, type ClusterIdParamDto } from './clusters/dto/cluster-id-param.dto.js';
Expand Down
55 changes: 37 additions & 18 deletions packages/frontend/src/lib/remotes/bot.remote.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { command, form, getRequestEvent, query } from "$app/server";
import { env } from "$env/dynamic/private";
import { CreateBotSchema, UpdateBotSchema, type GetBotDto } from "@hallmaster/backend/dto";
import {
CreateBotSchema,
UpdateBotSchema,
type GetBotDto,
type GetRecommendedShardsDto,
} from "@hallmaster/backend/dto";
import { error, redirect } from "@sveltejs/kit";

import { getClusters } from "./clusters.remote";

export const createBot = form(CreateBotSchema, async (payload) => {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(`${env.API_URL}/bot`, {
const response = await fetch(new URL("/bot", env.API_URL), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${getRequestEvent().cookies.get("token")}`,
},
body: JSON.stringify(payload),
});
Expand All @@ -26,18 +29,17 @@ export const createBot = form(CreateBotSchema, async (payload) => {
return error(409, "A bot already exists");

default:
console.error(await response.text());
return error(500, "An error occurred");
}
});

export const getBot = query<GetBotDto>(async () => {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(new URL("/bot", env.API_URL), {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${getRequestEvent().cookies.get("token")}`,
},
});

Expand All @@ -50,18 +52,39 @@ export const getBot = query<GetBotDto>(async () => {
return redirect(303, "/setup");

default:
console.error(await response.text());
return error(500, "An error occurred");
}
});

export const updateBotToken = form(UpdateBotSchema.pick({ token: true }), async (payload) => {
const token = getRequestEvent().cookies.get("token");
export const getRecommendedShards = query<GetRecommendedShardsDto["shards"]>(async () => {
const response = await fetch(new URL("/bot/recommended-shards", env.API_URL), {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getRequestEvent().cookies.get("token")}`,
},
});

switch (response.status) {
case 200:
return ((await response.json()) as GetRecommendedShardsDto).shards;
case 401:
return redirect(303, "/login");
case 404:
return redirect(303, "/setup");

default:
console.error(await response.text());
return error(500, "An error occurred");
}
});

export const updateBotToken = form(UpdateBotSchema.pick({ token: true }), async (payload) => {
const response = await fetch(new URL("/bot", env.API_URL), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${getRequestEvent().cookies.get("token")}`,
},
body: JSON.stringify(payload),
});
Expand All @@ -82,20 +105,18 @@ export const updateBotToken = form(UpdateBotSchema.pick({ token: true }), async
});

export const updateBotImage = form(UpdateBotSchema.pick({ dockerImage: true }), async (payload) => {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(new URL("/bot", env.API_URL), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${getRequestEvent().cookies.get("token")}`,
},
body: JSON.stringify(payload),
});

switch (response.status) {
case 202:
getBot().set(await response.json());
getBot().set((await response.json()) as GetBotDto);
return;
case 401:
return redirect(303, "/login");
Expand All @@ -109,13 +130,11 @@ export const updateBotImage = form(UpdateBotSchema.pick({ dockerImage: true }),
});

export const updateBotLayout = command(UpdateBotSchema.pick({ layout: true }), async (payload) => {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(new URL("/bot", env.API_URL), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${getRequestEvent().cookies.get("token")}`,
},
body: JSON.stringify(payload),
});
Expand Down
150 changes: 76 additions & 74 deletions packages/frontend/src/lib/utils/LayoutManager.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,115 +1,117 @@
import type { GetClusterDto, UpdateBotDto } from "@hallmaster/backend/dto";

class Shard {
public id: GetClusterDto["shardIds"][number];
public mutation: "unchanged" | "added" | "deleted" | "moved";

constructor(id: typeof this.id, mutation: typeof this.mutation = "unchanged") {
this.id = $state(id);
this.mutation = $state(mutation);
}
}

class Cluster {
public readonly shards: Shard[];
public mutation: "unchanged" | "added" | "deleted" = $state("unchanged");
public current: Set<number>;
public readonly previous: Set<number>;
public shards = $state<Map<number, "unchanged" | "deleted" | "moved" | "added">>();
public state = $state<"unchanged" | "changed" | "deleted" | "added">("unchanged");

constructor(
private readonly manager: LayoutManager,
shards: Shard[],
public readonly id?: number,
shards: NonNullable<typeof this.shards>,
public readonly id?: GetClusterDto["id"],
) {
this.shards = $state(shards);
}
const ids = shards.keys().toArray();
this.current = new Set(ids);

private findById(id: GetClusterDto["shardIds"][number]) {
const index = this.shards.findIndex((shard) => shard.id === id && shard.mutation !== "deleted");
if (id === undefined) {
this.previous = new Set();
this.state = "added";
} else this.previous = new Set(ids);

return {
index,
shard: index !== -1 ? this.shards[index] : undefined,
};
this.shards = shards;
}

public add() {
if (this.mutation === "deleted") return;
public update() {
const unchanged = this.current.intersection(this.previous);
const deleted = this.previous.difference(this.current);
const moved = this.manager.clusters
.filter((cluster) => cluster !== this)
.map((cluster) => cluster.previous.intersection(this.current))
.flatMap((set) => Array.from(set));
const added = this.current.difference(new Set([...unchanged, ...moved]));

const entries = [
...unchanged.values().map((id) => [id, "unchanged"] as const),
...deleted.values().map((id) => [id, "deleted"] as const),
...moved.map((id) => [id, "moved"] as const),
...added.values().map((id) => [id, "added"] as const),
].sort((a, b) => a[0] - b[0]);

this.shards = new Map(entries);

if (this.current.size === 0) this.state = "deleted";
else if (this.previous.size === 0) this.state = "added";
else if (deleted.size + moved.length + added.size !== 0) this.state = "changed";
else this.state = "unchanged";
}

this.shards.push(new Shard(this.manager.maxShardId + 1, "added"));
public add() {
this.current.add(this.manager.maxShardId + 1);
this.update();
}

public remove(id: GetClusterDto["shardIds"][number]) {
const { shard, index } = this.findById(id);
if (!shard) return;
public pop(update: boolean = true) {
const id = Math.max(...this.current);
this.current.delete(id);

if (shard.mutation === "added") this.shards.splice(index, 1);
else {
shard.id = 0;
shard.mutation = "deleted";
for (const cluster of this.manager.clusters) {
cluster.decrement(id);
if (update) cluster.update();
}
}

for (const shard of this.manager.shards) if (shard.id > id) shard.id--;
public decrement(id: GetClusterDto["shardIds"][number]) {
this.current = new Set(this.current.values().map((shard) => (shard > id ? shard - 1 : shard)));
}

public move(id: GetClusterDto["shardIds"][number], cluster: number) {
const { shard, index } = this.findById(id);
if (!shard) return;
public clear() {
while (this.current.size) this.pop(false);

shard.mutation = "moved";
this.manager.clusters[cluster].shards.push(this.shards.splice(index, 1)[0]);
for (const cluster of this.manager.clusters) cluster.update();
}
}

export class LayoutManager {
public clusters: Cluster[] = $state([]);

constructor(data: Omit<GetClusterDto, "status">[]) {
this.clusters = data
.map(
(cluster) =>
new Cluster(
this,
cluster.shardIds.map((id) => new Shard(id)),
cluster.id,
),
)
.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
}

public get maxShardId(): number {
return Math.max(
...this.clusters.flatMap((cluster) => cluster.shards.map((shard) => shard.id)),
-1,
public clusters: Cluster[];

constructor(private readonly data: Omit<GetClusterDto, "status">[]) {
this.clusters = $state(
this.data.map(
({ id, shardIds }) =>
new Cluster(this, new Map(shardIds.map((id) => [id, "unchanged"])), id),
),
);
}

public get shards(): Shard[] {
return this.clusters.flatMap((cluster) => cluster.shards);
public get maxShardId(): number {
return Math.max(...this.clusters.flatMap(({ current }) => Array.from(current)), -1);
}

public add() {
const cluster = new Cluster(this, [new Shard(this.maxShardId + 1, "added")]);
cluster.mutation = "added";

this.clusters.push(cluster);
public add(length: number = 1) {
const max = this.maxShardId + 1;
this.clusters.push(
new Cluster(this, new Map(Array.from({ length }).map((_, index) => [max + index, "added"]))),
);
}

public remove(index: number) {
const cluster = this.clusters[index];
const isAdded = cluster.state === "added";

for (const { id } of cluster.shards.toReversed()) cluster.remove(id);
cluster.clear();
if (isAdded) this.clusters.splice(index, 1);
}

if (cluster.mutation === "added") this.clusters.splice(index, 1);
else cluster.mutation = "deleted";
public reset() {
this.clusters = this.data.map(
({ id, shardIds }) => new Cluster(this, new Map(shardIds.map((id) => [id, "unchanged"])), id),
);
}

public export(): NonNullable<UpdateBotDto["layout"]> {
return this.clusters
.filter((cluster) => cluster.mutation !== "deleted" && cluster.shards.length !== 0)
.map((cluster) => ({
id: cluster.id,
shardIds: cluster.shards
.filter((shard) => shard.mutation !== "deleted")
.map((shard) => shard.id),
}));
.filter((cluster) => cluster.state !== "deleted")
.map(({ id, current }) => ({ id, shardIds: Array.from(current) }));
}
}
5 changes: 4 additions & 1 deletion packages/frontend/src/routes/(app)/layout/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<script lang="ts">
import { appbar } from "../+layout.svelte";
import type { LayoutProps } from "./$types";

let { children }: LayoutProps = $props();
</script>

<main class="flex flex-col gap-4 max-w-6xl mx-auto">
{@render appbar(["Administration", "Layout"])}

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