diff --git a/packages/backend/src/bot/dto/get-recommended-shards.dto.ts b/packages/backend/src/bot/dto/get-recommended-shards.dto.ts index 51efa76..07a1c48 100644 --- a/packages/backend/src/bot/dto/get-recommended-shards.dto.ts +++ b/packages/backend/src/bot/dto/get-recommended-shards.dto.ts @@ -8,3 +8,5 @@ export const GetRecommendedShardsSchema = z.object({ }); export class GetRecommendedShardsZodDto extends createZodDto(GetRecommendedShardsSchema) {} + +export type GetRecommendedShardsDto = z.infer; diff --git a/packages/backend/src/index.dto.ts b/packages/backend/src/index.dto.ts index ab25549..ca28742 100644 --- a/packages/backend/src/index.dto.ts +++ b/packages/backend/src/index.dto.ts @@ -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'; diff --git a/packages/frontend/src/lib/remotes/bot.remote.ts b/packages/frontend/src/lib/remotes/bot.remote.ts index 7788f5d..0332912 100644 --- a/packages/frontend/src/lib/remotes/bot.remote.ts +++ b/packages/frontend/src/lib/remotes/bot.remote.ts @@ -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), }); @@ -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(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")}`, }, }); @@ -50,18 +52,39 @@ export const getBot = query(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(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), }); @@ -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"); @@ -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), }); diff --git a/packages/frontend/src/lib/utils/LayoutManager.svelte.ts b/packages/frontend/src/lib/utils/LayoutManager.svelte.ts index 71a4e11..17b12b6 100644 --- a/packages/frontend/src/lib/utils/LayoutManager.svelte.ts +++ b/packages/frontend/src/lib/utils/LayoutManager.svelte.ts @@ -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; + public readonly previous: Set; + public shards = $state>(); + public state = $state<"unchanged" | "changed" | "deleted" | "added">("unchanged"); constructor( private readonly manager: LayoutManager, - shards: Shard[], - public readonly id?: number, + shards: NonNullable, + 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[]) { - 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[]) { + 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 { 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) })); } } diff --git a/packages/frontend/src/routes/(app)/layout/+layout.svelte b/packages/frontend/src/routes/(app)/layout/+layout.svelte index abcf864..34afb0c 100644 --- a/packages/frontend/src/routes/(app)/layout/+layout.svelte +++ b/packages/frontend/src/routes/(app)/layout/+layout.svelte @@ -1,9 +1,12 @@ -
+{@render appbar(["Administration", "Layout"])} + +
{@render children()}
diff --git a/packages/frontend/src/routes/(app)/layout/+page.svelte b/packages/frontend/src/routes/(app)/layout/+page.svelte index 2620a21..0c5328c 100644 --- a/packages/frontend/src/routes/(app)/layout/+page.svelte +++ b/packages/frontend/src/routes/(app)/layout/+page.svelte @@ -1,126 +1,51 @@ -
- -
+{#snippet actionRow(layout?: LayoutManager)} +
+ + +
+{/snippet} -
- {#each layout.clusters as cluster, index} - {@const columns = Math.round( - Math.sqrt((cluster.shards.length + 1) * (16 / 8)), - )} - {@const rows = Math.ceil((cluster.shards.length + 1) / columns)} -
-
-
- -

- Cluster {String(index).padStart(2, "0")} - ({cluster.shards.length}) -

-
+ + {let data = $state(await getClusters())} + {let layout = $derived(new LayoutManager(data))} + + {@render actionRow(layout)} - -
+
+ {#each layout.clusters as cluster, index} + + {/each} + + +
-
- {#each cluster.shards as shard} - - {/each} + {#snippet pending()} + {@render actionRow()} - {#if cluster.mutation !== "deleted"} - - {/if} -
+
+ {#each { length: 6 }} +
+ {/each}
- {/each} + {/snippet} - -
+ {#snippet failed(error, reset)} + + {/snippet} + diff --git a/packages/frontend/src/routes/(app)/layout/components/Actions.svelte b/packages/frontend/src/routes/(app)/layout/components/Actions.svelte new file mode 100644 index 0000000..53a7311 --- /dev/null +++ b/packages/frontend/src/routes/(app)/layout/components/Actions.svelte @@ -0,0 +1,42 @@ + + +
+ { + toaster.promise(updateBotLayout({ layout: layout!.export() }), { + loading: { title: "Updating layout.." }, + success: { title: "Successfully applied layout" }, + error: (error) => ({ + title: "Error", + description: JSON.parse(error as string)?.message ?? "An error occurred", + }), + }); + }} + /> + + +
diff --git a/packages/frontend/src/routes/(app)/layout/components/Cluster.svelte b/packages/frontend/src/routes/(app)/layout/components/Cluster.svelte new file mode 100644 index 0000000..a4a59c8 --- /dev/null +++ b/packages/frontend/src/routes/(app)/layout/components/Cluster.svelte @@ -0,0 +1,84 @@ + + +{const columns = $derived(Math.round(Math.sqrt((cluster.shards!.size + 1) * (16 / 8))))} +{const rows = $derived(Math.ceil((cluster.shards!.size + 1) / columns))} + + state !== "deleted") + .toArray().length} + class={{ + "flex aspect-video flex-col gap-2 rounded-lg border bg-surface-100-900 p-2": true, + "border-surface-200-800": cluster.state === "unchanged" || cluster.state === "changed", + "border-error-600-400": cluster.state === "deleted", + "border-success-600-400": cluster.state === "added", + }} +> + {#snippet action()} + {#if cluster.state !== "deleted"} +
+ + +
+ {/if} + {/snippet} + +
+ {#each cluster.shards as [id, state]} + + {/each} + + {#if cluster.state !== "deleted"} +
+ + +
+ {/if} +
+
diff --git a/packages/frontend/src/routes/(app)/layout/components/RecommendedShards.svelte b/packages/frontend/src/routes/(app)/layout/components/RecommendedShards.svelte new file mode 100644 index 0000000..7c74b13 --- /dev/null +++ b/packages/frontend/src/routes/(app)/layout/components/RecommendedShards.svelte @@ -0,0 +1,40 @@ + + + + + + + + Recommended amount: + + {await getRecommendedShards()} + + {#snippet pending()} +
+ {/snippet} + + {#snippet failed(_, reset)} + + {/snippet} +
+
+
+
+ + + +
+ Recommended number of shards based on bot size +
+
+
+
+
diff --git a/packages/frontend/src/routes/(app)/layout/components/Shard.svelte b/packages/frontend/src/routes/(app)/layout/components/Shard.svelte new file mode 100644 index 0000000..4107e70 --- /dev/null +++ b/packages/frontend/src/routes/(app)/layout/components/Shard.svelte @@ -0,0 +1,27 @@ + + +
+

+ {String(id).padStart(2, "0")} +

+