feat: managing admins in webui

This commit is contained in:
pinks 2023-10-23 02:39:01 +02:00
parent f7b29dd150
commit a35f07b036
22 changed files with 946 additions and 451 deletions

View File

@ -13,17 +13,17 @@ You can put these in `.env` file or pass them as environment variables.
- `TG_BOT_TOKEN` - Telegram bot token. Get yours from [@BotFather](https://t.me/BotFather). - `TG_BOT_TOKEN` - Telegram bot token. Get yours from [@BotFather](https://t.me/BotFather).
Required. Required.
- `TG_ADMIN_USERNAMES` - Comma separated list of usernames of users that can use admin commands.
- `DENO_KV_PATH` - [Deno KV](https://deno.land/api?s=Deno.openKv&unstable) database file path. A - `DENO_KV_PATH` - [Deno KV](https://deno.land/api?s=Deno.openKv&unstable) database file path. A
temporary file is used by default. temporary file is used by default.
- `LOG_LEVEL` - [Log level](https://deno.land/std@0.201.0/log/mod.ts?s=LogLevels). Default: `INFO`. - `LOG_LEVEL` - [Log level](https://deno.land/std@0.201.0/log/mod.ts?s=LogLevels). Default: `INFO`.
You can configure more stuff in [Eris WebUI](http://localhost:5999/) when running.
## Running ## Running
- Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory) 1. Start Eris: `deno task start`
- Start Eris: `deno task start` 2. Visit [Eris WebUI](http://localhost:5999/) and login via Telegram.
3. Promote yourself to admin in the Eris WebUI.
4. Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory)
5. Add a new worker in the Eris WebUI.
## Codegen ## Codegen

129
api/adminsRoute.ts Normal file
View File

@ -0,0 +1,129 @@
import { Model } from "indexed_kv";
import { info } from "std/log/mod.ts";
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
import { Admin, adminSchema, adminStore } from "../app/adminStore.ts";
import { getUser, withAdmin, withUser } from "./withUser.ts";
export type AdminData = Admin & { id: string };
function getAdminData(adminEntry: Model<Admin>): AdminData {
return { id: adminEntry.id, ...adminEntry.value };
}
export const adminsRoute = createPathFilter({
"": createMethodFilter({
GET: createEndpoint(
{},
async () => {
const adminEntries = await adminStore.getAll();
const admins = adminEntries.map(getAdminData);
return {
status: 200,
body: { type: "application/json", data: admins satisfies AdminData[] },
};
},
),
POST: createEndpoint(
{
query: {
sessionId: { type: "string" },
},
body: {
type: "application/json",
schema: {
type: "object",
properties: {
tgUserId: adminSchema.properties.tgUserId,
},
required: ["tgUserId"],
},
},
},
async ({ query, body }) => {
return withAdmin(query, async (user, adminEntry) => {
const newAdminUser = await getUser(body.data.tgUserId);
const newAdminEntry = await adminStore.create({
tgUserId: body.data.tgUserId,
promotedBy: adminEntry.id,
});
info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`);
return {
status: 200,
body: { type: "application/json", data: getAdminData(newAdminEntry) },
};
});
},
),
}),
"promote_self": createMethodFilter({
POST: createEndpoint(
{
query: {
sessionId: { type: "string" },
},
},
// if there are no admins, allow any user to promote themselves
async ({ query }) => {
return withUser(query, async (user) => {
const adminEntries = await adminStore.getAll();
if (adminEntries.length === 0) {
const newAdminEntry = await adminStore.create({
tgUserId: user.id,
promotedBy: null,
});
info(`User ${user.first_name} promoted themselves to admin`);
return {
status: 200,
body: { type: "application/json", data: getAdminData(newAdminEntry) },
};
}
return {
status: 403,
body: { type: "text/plain", data: `You are not allowed to promote yourself` },
};
});
},
),
}),
"{adminId}": createPathFilter({
"": createMethodFilter({
GET: createEndpoint(
{},
async ({ params }) => {
const adminEntry = await adminStore.getById(params.adminId!);
if (!adminEntry) {
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
}
return {
status: 200,
body: { type: "application/json", data: getAdminData(adminEntry) },
};
},
),
DELETE: createEndpoint(
{
query: {
sessionId: { type: "string" },
},
},
async ({ params, query }) => {
const deletedAdminEntry = await adminStore.getById(params.adminId!);
if (!deletedAdminEntry) {
return { status: 404, body: { type: "text/plain", data: `Admin not found` } };
}
const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId);
return withUser(query, async (chat) => {
await deletedAdminEntry.delete();
info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`);
return {
status: 200,
body: { type: "application/json", data: null },
};
});
},
),
}),
}),
});

View File

@ -2,7 +2,7 @@ import { deepMerge } from "std/collections/deep_merge.ts";
import { info } from "std/log/mod.ts"; import { info } from "std/log/mod.ts";
import { createEndpoint, createMethodFilter } from "t_rest/server"; import { createEndpoint, createMethodFilter } from "t_rest/server";
import { configSchema, getConfig, setConfig } from "../app/config.ts"; import { configSchema, getConfig, setConfig } from "../app/config.ts";
import { withUser } from "./withUser.ts"; import { withAdmin } from "./withUser.ts";
export const paramsRoute = createMethodFilter({ export const paramsRoute = createMethodFilter({
GET: createEndpoint( GET: createEndpoint(
@ -22,13 +22,13 @@ export const paramsRoute = createMethodFilter({
}, },
}, },
async ({ query, body }) => { async ({ query, body }) => {
return withUser(query, async (chat) => { return withAdmin(query, async (user) => {
const config = await getConfig(); const config = await getConfig();
info(`User ${chat.username} updated default params: ${JSON.stringify(body.data)}`); info(`User ${user.first_name} updated default params: ${JSON.stringify(body.data)}`);
const defaultParams = deepMerge(config.defaultParams ?? {}, body.data); const defaultParams = deepMerge(config.defaultParams ?? {}, body.data);
await setConfig({ defaultParams }); await setConfig({ defaultParams });
return { status: 200, body: { type: "application/json", data: config.defaultParams } }; return { status: 200, body: { type: "application/json", data: config.defaultParams } };
}, { admin: true }); });
}, },
), ),
}); });

View File

@ -1,4 +1,5 @@
import { createLoggerMiddleware, createPathFilter } from "t_rest/server"; import { createLoggerMiddleware, createPathFilter } from "t_rest/server";
import { adminsRoute } from "./adminsRoute.ts";
import { botRoute } from "./botRoute.ts"; import { botRoute } from "./botRoute.ts";
import { jobsRoute } from "./jobsRoute.ts"; import { jobsRoute } from "./jobsRoute.ts";
import { paramsRoute } from "./paramsRoute.ts"; import { paramsRoute } from "./paramsRoute.ts";
@ -9,13 +10,14 @@ import { workersRoute } from "./workersRoute.ts";
export const serveApi = createLoggerMiddleware( export const serveApi = createLoggerMiddleware(
createPathFilter({ createPathFilter({
"admins": adminsRoute,
"bot": botRoute,
"jobs": jobsRoute, "jobs": jobsRoute,
"sessions": sessionsRoute, "sessions": sessionsRoute,
"users": usersRoute,
"settings/params": paramsRoute, "settings/params": paramsRoute,
"stats": statsRoute, "stats": statsRoute,
"users": usersRoute,
"workers": workersRoute, "workers": workersRoute,
"bot": botRoute,
}), }),
{ filterStatus: (status) => status >= 400 }, { filterStatus: (status) => status >= 400 },
); );

View File

@ -1,20 +1,18 @@
import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server";
import { getConfig } from "../app/config.ts";
import { bot } from "../bot/mod.ts"; import { bot } from "../bot/mod.ts";
import { getUser } from "./withUser.ts";
import { adminStore } from "../app/adminStore.ts";
export const usersRoute = createPathFilter({ export const usersRoute = createPathFilter({
"{userId}/photo": createMethodFilter({ "{userId}/photo": createMethodFilter({
GET: createEndpoint( GET: createEndpoint(
{ query: null, body: null }, { query: null, body: null },
async ({ params }) => { async ({ params }) => {
const chat = await bot.api.getChat(params.userId!); const user = await getUser(Number(params.userId!));
if (chat.type !== "private") { const photoData = user.photo?.small_file_id
throw new Error("Chat is not private");
}
const photoData = chat.photo?.small_file_id
? await fetch( ? await fetch(
`https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile( `https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile(
chat.photo.small_file_id, user.photo.small_file_id,
).then((file) => file.file_path)}`, ).then((file) => file.file_path)}`,
).then((resp) => resp.arrayBuffer()) ).then((resp) => resp.arrayBuffer())
: undefined; : undefined;
@ -36,17 +34,14 @@ export const usersRoute = createPathFilter({
GET: createEndpoint( GET: createEndpoint(
{ query: null, body: null }, { query: null, body: null },
async ({ params }) => { async ({ params }) => {
const chat = await bot.api.getChat(params.userId!); const user = await getUser(Number(params.userId!));
if (chat.type !== "private") { const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id });
throw new Error("Chat is not private"); const admin = adminEntry?.value;
}
const config = await getConfig();
const isAdmin = chat.username && config?.adminUsernames?.includes(chat.username);
return { return {
status: 200, status: 200,
body: { body: {
type: "application/json", type: "application/json",
data: { ...chat, isAdmin }, data: { ...user, admin },
}, },
}; };
}, },

View File

@ -1,28 +1,40 @@
import { Chat } from "grammy_types"; import { Chat } from "grammy_types";
import { Model } from "indexed_kv";
import { Output } from "t_rest/client"; import { Output } from "t_rest/client";
import { getConfig } from "../app/config.ts"; import { Admin, adminStore } from "../app/adminStore.ts";
import { bot } from "../bot/mod.ts"; import { bot } from "../bot/mod.ts";
import { sessions } from "./sessionsRoute.ts"; import { sessions } from "./sessionsRoute.ts";
export async function withUser<O extends Output>( export async function withUser<O extends Output>(
query: { sessionId: string }, query: { sessionId: string },
cb: (user: Chat.PrivateGetChat) => Promise<O>, cb: (user: Chat.PrivateGetChat) => Promise<O>,
options?: { admin?: boolean },
) { ) {
const session = sessions.get(query.sessionId); const session = sessions.get(query.sessionId);
if (!session?.userId) { if (!session?.userId) {
return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const;
} }
const chat = await bot.api.getChat(session.userId); const user = await getUser(session.userId);
if (chat.type !== "private") throw new Error("Chat is not private"); return cb(user);
if (options?.admin) { }
if (!chat.username) {
return { status: 403, body: { type: "text/plain", data: "Must have a username" } } as const; export async function withAdmin<O extends Output>(
} query: { sessionId: string },
const config = await getConfig(); cb: (user: Chat.PrivateGetChat, admin: Model<Admin>) => Promise<O>,
if (!config?.adminUsernames?.includes(chat.username)) { ) {
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const; const session = sessions.get(query.sessionId);
} if (!session?.userId) {
} return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const;
return cb(chat); }
const user = await getUser(session.userId);
const [admin] = await adminStore.getBy("tgUserId", { value: session.userId });
if (!admin) {
return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const;
}
return cb(user, admin);
}
export async function getUser(userId: number): Promise<Chat.PrivateGetChat> {
const chat = await bot.api.getChat(userId);
if (chat.type !== "private") throw new Error("Chat is not private");
return chat;
} }

View File

@ -13,7 +13,7 @@ import {
} from "../app/workerInstanceStore.ts"; } from "../app/workerInstanceStore.ts";
import { getAuthHeader } from "../utils/getAuthHeader.ts"; import { getAuthHeader } from "../utils/getAuthHeader.ts";
import { omitUndef } from "../utils/omitUndef.ts"; import { omitUndef } from "../utils/omitUndef.ts";
import { withUser } from "./withUser.ts"; import { withAdmin } from "./withUser.ts";
export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & { export type WorkerData = Omit<WorkerInstance, "sdUrl" | "sdAuth"> & {
id: string; id: string;
@ -89,14 +89,14 @@ export const workersRoute = createPathFilter({
}, },
}, },
async ({ query, body }) => { async ({ query, body }) => {
return withUser(query, async (chat) => { return withAdmin(query, async (user) => {
const workerInstance = await workerInstanceStore.create(body.data); const workerInstance = await workerInstanceStore.create(body.data);
info(`User ${chat.username} created worker ${workerInstance.id}`); info(`User ${user.first_name} created worker ${workerInstance.value.name}`);
return { return {
status: 200, status: 200,
body: { type: "application/json", data: await getWorkerData(workerInstance) }, body: { type: "application/json", data: await getWorkerData(workerInstance) },
}; };
}, { admin: true }); });
}, },
), ),
}), }),
@ -131,9 +131,9 @@ export const workersRoute = createPathFilter({
if (!workerInstance) { if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
} }
return withUser(query, async (chat) => { return withAdmin(query, async (user) => {
info( info(
`User ${chat.username} updated worker ${params.workerId}: ${ `User ${user.first_name} updated worker ${workerInstance.value.name}: ${
JSON.stringify(body.data) JSON.stringify(body.data)
}`, }`,
); );
@ -142,7 +142,7 @@ export const workersRoute = createPathFilter({
status: 200, status: 200,
body: { type: "application/json", data: await getWorkerData(workerInstance) }, body: { type: "application/json", data: await getWorkerData(workerInstance) },
}; };
}, { admin: true }); });
}, },
), ),
DELETE: createEndpoint( DELETE: createEndpoint(
@ -157,11 +157,11 @@ export const workersRoute = createPathFilter({
if (!workerInstance) { if (!workerInstance) {
return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; return { status: 404, body: { type: "text/plain", data: `Worker not found` } };
} }
return withUser(query, async (chat) => { return withAdmin(query, async (user) => {
info(`User ${chat.username} deleted worker ${params.workerId}`); info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`);
await workerInstance.delete(); await workerInstance.delete();
return { status: 200, body: { type: "application/json", data: null } }; return { status: 200, body: { type: "application/json", data: null } };
}, { admin: true }); });
}, },
), ),
}), }),

24
app/adminStore.ts Normal file
View File

@ -0,0 +1,24 @@
import { Store } from "indexed_kv";
import { JsonSchema, jsonType } from "t_rest/server";
import { db } from "./db.ts";
export const adminSchema = {
type: "object",
properties: {
tgUserId: { type: "number" },
promotedBy: { type: ["string", "null"] },
},
required: ["tgUserId", "promotedBy"],
} as const satisfies JsonSchema;
export type Admin = jsonType<typeof adminSchema>;
type AdminIndices = {
tgUserId: number;
};
export const adminStore = new Store<Admin, AdminIndices>(db, "adminUsers", {
indices: {
tgUserId: { getValue: (adminUser) => adminUser.tgUserId },
},
});

View File

@ -4,7 +4,6 @@ import { JsonSchema, jsonType } from "t_rest/server";
export const configSchema = { export const configSchema = {
type: "object", type: "object",
properties: { properties: {
adminUsernames: { type: "array", items: { type: "string" } },
pausedReason: { type: ["string", "null"] }, pausedReason: { type: ["string", "null"] },
maxUserJobs: { type: "number" }, maxUserJobs: { type: "number" },
maxJobs: { type: "number" }, maxJobs: { type: "number" },
@ -31,7 +30,6 @@ export async function getConfig(): Promise<Config> {
const configEntry = await db.get<Config>(["config"]); const configEntry = await db.get<Config>(["config"]);
const config = configEntry?.value; const config = configEntry?.value;
return { return {
adminUsernames: config?.adminUsernames ?? Deno.env.get("TG_ADMIN_USERNAMES")?.split(",") ?? [],
pausedReason: config?.pausedReason ?? null, pausedReason: config?.pausedReason ?? null,
maxUserJobs: config?.maxUserJobs ?? Infinity, maxUserJobs: config?.maxUserJobs ?? Infinity,
maxJobs: config?.maxJobs ?? Infinity, maxJobs: config?.maxJobs ?? Infinity,
@ -42,7 +40,6 @@ export async function getConfig(): Promise<Config> {
export async function setConfig(newConfig: Partial<Config>): Promise<void> { export async function setConfig(newConfig: Partial<Config>): Promise<void> {
const oldConfig = await getConfig(); const oldConfig = await getConfig();
const config: Config = { const config: Config = {
adminUsernames: newConfig.adminUsernames ?? oldConfig.adminUsernames,
pausedReason: newConfig.pausedReason ?? oldConfig.pausedReason, pausedReason: newConfig.pausedReason ?? oldConfig.pausedReason,
maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs, maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs,
maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs, maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs,

View File

@ -2,7 +2,7 @@ import { CommandContext } from "grammy";
import { bold, fmt, FormattedString } from "grammy_parse_mode"; import { bold, fmt, FormattedString } from "grammy_parse_mode";
import { distinctBy } from "std/collections/distinct_by.ts"; import { distinctBy } from "std/collections/distinct_by.ts";
import { error, info } from "std/log/mod.ts"; import { error, info } from "std/log/mod.ts";
import { getConfig } from "../app/config.ts"; import { adminStore } from "../app/adminStore.ts";
import { generationStore } from "../app/generationStore.ts"; import { generationStore } from "../app/generationStore.ts";
import { formatUserChat } from "../utils/formatUserChat.ts"; import { formatUserChat } from "../utils/formatUserChat.ts";
import { ErisContext } from "./mod.ts"; import { ErisContext } from "./mod.ts";
@ -12,9 +12,9 @@ export async function broadcastCommand(ctx: CommandContext<ErisContext>) {
return ctx.reply("I don't know who you are."); return ctx.reply("I don't know who you are.");
} }
const config = await getConfig(); const [admin] = await adminStore.getBy("tgUserId", { value: ctx.from.id });
if (!config.adminUsernames.includes(ctx.from.username)) { if (!admin) {
return ctx.reply("Only a bot admin can use this command."); return ctx.reply("Only a bot admin can use this command.");
} }

View File

@ -4,7 +4,6 @@ import { hydrateReply, ParseModeFlavor } from "grammy_parse_mode";
import { run, sequentialize } from "grammy_runner"; import { run, sequentialize } from "grammy_runner";
import { error, info, warning } from "std/log/mod.ts"; import { error, info, warning } from "std/log/mod.ts";
import { sessions } from "../api/sessionsRoute.ts"; import { sessions } from "../api/sessionsRoute.ts";
import { getConfig, setConfig } from "../app/config.ts";
import { formatUserChat } from "../utils/formatUserChat.ts"; import { formatUserChat } from "../utils/formatUserChat.ts";
import { omitUndef } from "../utils/omitUndef.ts"; import { omitUndef } from "../utils/omitUndef.ts";
import { broadcastCommand } from "./broadcastCommand.ts"; import { broadcastCommand } from "./broadcastCommand.ts";
@ -170,30 +169,6 @@ bot.command("cancel", cancelCommand);
bot.command("broadcast", broadcastCommand); bot.command("broadcast", broadcastCommand);
bot.command("pause", async (ctx) => {
if (!ctx.from?.username) return;
const config = await getConfig();
if (!config.adminUsernames.includes(ctx.from.username)) return;
if (config.pausedReason != null) {
return ctx.reply(`Already paused: ${config.pausedReason}`);
}
await setConfig({
pausedReason: ctx.match || "No reason given",
});
warning(`Bot paused by ${ctx.from.first_name} because ${config.pausedReason}`);
return ctx.reply("Paused");
});
bot.command("resume", async (ctx) => {
if (!ctx.from?.username) return;
const config = await getConfig();
if (!config.adminUsernames.includes(ctx.from.username)) return;
if (config.pausedReason == null) return ctx.reply("Already running");
await setConfig({ pausedReason: null });
info(`Bot resumed by ${ctx.from.first_name}`);
return ctx.reply("Resumed");
});
bot.command("crash", () => { bot.command("crash", () => {
throw new Error("Crash command used"); throw new Error("Crash command used");
}); });

View File

@ -45,8 +45,7 @@
"t_rest/server": "https://esm.sh/ty-rest@0.4.1/server", "t_rest/server": "https://esm.sh/ty-rest@0.4.1/server",
"twind/core": "https://esm.sh/@twind/core@1.1.3", "twind/core": "https://esm.sh/@twind/core@1.1.3",
"twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4", "twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4",
"ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts", "ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts"
"use-local-storage": "https://esm.sh/use-local-storage@3.0.0?external=react&dev"
}, },
"lint": { "lint": {
"rules": { "rules": {

250
ui/AdminsPage.tsx Normal file
View File

@ -0,0 +1,250 @@
import React, { useRef } from "react";
import useSWR, { useSWRConfig } from "swr";
import { AdminData } from "../api/adminsRoute.ts";
import { fetchApi, handleResponse } from "./apiClient.ts";
export function AdminsPage(props: { sessionId: string | null }) {
const { sessionId } = props;
const { mutate } = useSWRConfig();
const addDialogRef = useRef<HTMLDialogElement>(null);
const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse),
);
const getUser = useSWR(
getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
: null,
(args) => fetchApi(...args).then(handleResponse),
);
const getAdmins = useSWR(
["admins", "GET", {}] as const,
(args) => fetchApi(...args).then(handleResponse),
);
return (
<>
{getUser.data && getAdmins.data && getAdmins.data.length === 0 && (
<button
className="button-filled"
onClick={() => {
mutate(
(key) => Array.isArray(key) && key[0] === "admins",
async () =>
fetchApi("admins/promote_self", "POST", {
query: { sessionId: sessionId ?? "" },
}).then(handleResponse),
{ populateCache: false },
);
}}
>
Promote me to admin
</button>
)}
{getAdmins.data?.length
? (
<ul className="my-4 flex flex-col gap-2">
{getAdmins.data.map((admin) => (
<AdminListItem key={admin.id} admin={admin} sessionId={sessionId} />
))}
</ul>
)
: getAdmins.data?.length === 0
? <p>No admins</p>
: getAdmins.error
? <p className="alert">Loading admins failed</p>
: <div className="spinner self-center" />}
{getUser.data?.admin && (
<button
className="button-filled"
onClick={() => addDialogRef.current?.showModal()}
>
Add admin
</button>
)}
<AddAdminDialog
dialogRef={addDialogRef}
sessionId={sessionId}
/>
</>
);
}
function AddAdminDialog(props: {
dialogRef: React.RefObject<HTMLDialogElement>;
sessionId: string | null;
}) {
const { dialogRef, sessionId } = props;
const { mutate } = useSWRConfig();
return (
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
<form
method="dialog"
className="flex flex-col gap-4 p-4"
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
mutate(
(key) => Array.isArray(key) && key[0] === "admins",
async () =>
fetchApi("admins", "POST", {
query: { sessionId: sessionId! },
body: {
type: "application/json",
data: {
tgUserId: Number(data.get("tgUserId") as string),
},
},
}).then(handleResponse),
{ populateCache: false },
);
dialogRef.current?.close();
}}
>
<label className="flex flex-col items-stretch gap-1">
<span className="text-sm">
Telegram user ID
</span>
<input
className="input-text"
type="text"
name="tgUserId"
required
pattern="-?\d+"
/>
</label>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
className="button-outlined"
onClick={() => dialogRef.current?.close()}
>
Close
</button>
<button type="submit" className="button-filled">
Save
</button>
</div>
</form>
</dialog>
);
}
function AdminListItem(props: { admin: AdminData; sessionId: string | null }) {
const { admin, sessionId } = props;
const deleteDialogRef = useRef<HTMLDialogElement>(null);
const getAdminUser = useSWR(
["users/{userId}", "GET", { params: { userId: String(admin.tgUserId) } }] as const,
(args) => fetchApi(...args).then(handleResponse),
);
const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse),
);
const getUser = useSWR(
getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
: null,
(args) => fetchApi(...args).then(handleResponse),
);
return (
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
<p className="font-bold">
{getAdminUser.data?.first_name ?? admin.id} {getAdminUser.data?.last_name}{" "}
{getAdminUser.data?.username
? (
<a href={`https://t.me/${getAdminUser.data.username}`} className="link">
@{getAdminUser.data.username}
</a>
)
: null}
</p>
{getAdminUser.data?.bio
? (
<p>
{getAdminUser.data?.bio}
</p>
)
: null}
{getUser.data?.admin && (
<p className="flex gap-2">
<button
className="button-outlined"
onClick={() => deleteDialogRef.current?.showModal()}
>
Delete
</button>
</p>
)}
<DeleteAdminDialog
dialogRef={deleteDialogRef}
adminId={admin.id}
sessionId={sessionId}
/>
</li>
);
}
function DeleteAdminDialog(props: {
dialogRef: React.RefObject<HTMLDialogElement>;
adminId: string;
sessionId: string | null;
}) {
const { dialogRef, adminId, sessionId } = props;
const { mutate } = useSWRConfig();
return (
<dialog
ref={dialogRef}
className="dialog animate-pop-in backdrop-animate-fade-in"
>
<form
method="dialog"
className="flex flex-col gap-4 p-4"
onSubmit={(e) => {
e.preventDefault();
mutate(
(key) => Array.isArray(key) && key[0] === "admins",
async () =>
fetchApi("admins/{adminId}", "DELETE", {
query: { sessionId: sessionId! },
params: { adminId: adminId },
}).then(handleResponse),
{ populateCache: false },
);
dialogRef.current?.close();
}}
>
<p>
Are you sure you want to delete this admin?
</p>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
className="button-outlined"
onClick={() => dialogRef.current?.close()}
>
Close
</button>
<button type="submit" className="button-filled">
Delete
</button>
</div>
</form>
</dialog>
);
}

View File

@ -1,16 +1,17 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from "react-router-dom";
import useLocalStorage from "use-local-storage"; import { AdminsPage } from "./AdminsPage.tsx";
import { AppHeader } from "./AppHeader.tsx"; import { AppHeader } from "./AppHeader.tsx";
import { QueuePage } from "./QueuePage.tsx"; import { QueuePage } from "./QueuePage.tsx";
import { SettingsPage } from "./SettingsPage.tsx"; import { SettingsPage } from "./SettingsPage.tsx";
import { StatsPage } from "./StatsPage.tsx"; import { StatsPage } from "./StatsPage.tsx";
import { WorkersPage } from "./WorkersPage.tsx"; import { WorkersPage } from "./WorkersPage.tsx";
import { fetchApi, handleResponse } from "./apiClient.tsx"; import { fetchApi, handleResponse } from "./apiClient.ts";
import { useLocalStorage } from "./useLocalStorage.ts";
export function App() { export function App() {
// store session ID in the local storage // store session ID in the local storage
const [sessionId, setSessionId] = useLocalStorage("sessionId", ""); const [sessionId, setSessionId] = useLocalStorage("sessionId");
// initialize a new session when there is no session ID // initialize a new session when there is no session ID
useEffect(() => { useEffect(() => {
@ -27,11 +28,12 @@ export function App() {
<AppHeader <AppHeader
className="self-stretch" className="self-stretch"
sessionId={sessionId} sessionId={sessionId}
onLogOut={() => setSessionId("")} onLogOut={() => setSessionId(null)}
/> />
<div className="self-center w-full max-w-screen-md flex flex-col items-stretch gap-4 p-4"> <div className="self-center w-full max-w-screen-md flex flex-col items-stretch gap-4 p-4">
<Routes> <Routes>
<Route path="/" element={<StatsPage />} /> <Route path="/" element={<StatsPage />} />
<Route path="/admins" element={<AdminsPage sessionId={sessionId} />} />
<Route path="/workers" element={<WorkersPage sessionId={sessionId} />} /> <Route path="/workers" element={<WorkersPage sessionId={sessionId} />} />
<Route path="/queue" element={<QueuePage />} /> <Route path="/queue" element={<QueuePage />} />
<Route path="/settings" element={<SettingsPage sessionId={sessionId} />} /> <Route path="/settings" element={<SettingsPage sessionId={sessionId} />} />

View File

@ -2,7 +2,7 @@ import React, { ReactNode } from "react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
import { cx } from "twind/core"; import { cx } from "twind/core";
import { fetchApi, handleResponse } from "./apiClient.tsx"; import { fetchApi, handleResponse } from "./apiClient.ts";
function NavTab(props: { to: string; children: ReactNode }) { function NavTab(props: { to: string; children: ReactNode }) {
return ( return (
@ -15,33 +15,35 @@ function NavTab(props: { to: string; children: ReactNode }) {
); );
} }
export function AppHeader( export function AppHeader(props: {
props: { className?: string; sessionId?: string; onLogOut: () => void }, className?: string;
) { sessionId: string | null;
onLogOut: () => void;
}) {
const { className, sessionId, onLogOut } = props; const { className, sessionId, onLogOut } = props;
const session = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ onError: () => onLogOut() }, { onError: () => onLogOut() },
); );
const user = useSWR( const getUser = useSWR(
session.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const bot = useSWR( const getBot = useSWR(
["bot", "GET", {}] as const, ["bot", "GET", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const userPhoto = useSWR( const getUserPhoto = useSWR(
session.data?.userId getSession.data?.userId
? ["users/{userId}/photo", "GET", { ? ["users/{userId}/photo", "GET", {
params: { userId: String(session.data.userId) }, params: { userId: String(getSession.data.userId) },
}] as const }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)), (args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)),
@ -62,6 +64,9 @@ export function AppHeader(
<NavTab to="/"> <NavTab to="/">
Stats Stats
</NavTab> </NavTab>
<NavTab to="/admins">
Admins
</NavTab>
<NavTab to="/workers"> <NavTab to="/workers">
Workers Workers
</NavTab> </NavTab>
@ -74,29 +79,29 @@ export function AppHeader(
</nav> </nav>
{/* loading indicator */} {/* loading indicator */}
{session.isLoading || user.isLoading ? <div className="spinner" /> : null} {getSession.isLoading || getUser.isLoading ? <div className="spinner" /> : null}
{/* user avatar */} {/* user avatar */}
{user.data {getUser.data
? userPhoto.data ? getUserPhoto.data
? ( ? (
<img <img
src={userPhoto.data} src={getUserPhoto.data}
alt="avatar" alt="avatar"
className="w-9 h-9 rounded-full" className="w-9 h-9 rounded-full"
/> />
) )
: ( : (
<div className="w-9 h-9 rounded-full bg-zinc-400 dark:bg-zinc-500 flex items-center justify-center text-white text-2xl font-bold select-none"> <div className="w-9 h-9 rounded-full bg-zinc-400 dark:bg-zinc-500 flex items-center justify-center text-white text-2xl font-bold select-none">
{user.data.first_name.at(0)?.toUpperCase()} {getUser.data.first_name.at(0)?.toUpperCase()}
</div> </div>
) )
: null} : null}
{/* login/logout button */} {/* login/logout button */}
{!session.isLoading && !user.isLoading && bot.data && sessionId {!getSession.isLoading && !getUser.isLoading && getBot.data && sessionId
? ( ? (
user.data getUser.data
? ( ? (
<button className="button-outlined" onClick={() => onLogOut()}> <button className="button-outlined" onClick={() => onLogOut()}>
Logout Logout
@ -105,7 +110,7 @@ export function AppHeader(
: ( : (
<a <a
className="button-filled" className="button-filled"
href={`https://t.me/${bot.data.username}?start=${sessionId}`} href={`https://t.me/${getBot.data.username}?start=${sessionId}`}
target="_blank" target="_blank"
> >
Login Login

View File

@ -3,10 +3,10 @@ import FlipMove from "react-flip-move";
import useSWR from "swr"; import useSWR from "swr";
import { getFlagEmoji } from "../utils/getFlagEmoji.ts"; import { getFlagEmoji } from "../utils/getFlagEmoji.ts";
import { Progress } from "./Progress.tsx"; import { Progress } from "./Progress.tsx";
import { fetchApi, handleResponse } from "./apiClient.tsx"; import { fetchApi, handleResponse } from "./apiClient.ts";
export function QueuePage() { export function QueuePage() {
const jobs = useSWR( const getJobs = useSWR(
["jobs", "GET", {}] as const, ["jobs", "GET", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ refreshInterval: 2000 }, { refreshInterval: 2000 },
@ -19,7 +19,7 @@ export function QueuePage() {
enterAnimation="fade" enterAnimation="fade"
leaveAnimation="fade" leaveAnimation="fade"
> >
{jobs.data?.map((job) => ( {getJobs.data?.map((job) => (
<li <li
className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-xl" className="flex items-baseline gap-2 bg-zinc-100 dark:bg-zinc-700 px-2 py-1 rounded-xl"
key={job.id.join("/")} key={job.id.join("/")}

View File

@ -1,79 +1,82 @@
import React, { ReactNode, useState } from "react"; import React, { useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { cx } from "twind/core"; import { cx } from "twind/core";
import { fetchApi, handleResponse } from "./apiClient.tsx"; import { fetchApi, handleResponse } from "./apiClient.ts";
export function SettingsPage(props: { sessionId: string }) { export function SettingsPage(props: { sessionId: string | null }) {
const { sessionId } = props; const { sessionId } = props;
const session = useSWR(
const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const user = useSWR( const getUser = useSWR(
session.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const params = useSWR( const getParams = useSWR(
["settings/params", "GET", {}] as const, ["settings/params", "GET", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const [changedParams, setChangedParams] = useState<Partial<typeof params.data>>({}); const [newParams, setNewParams] = useState<Partial<typeof getParams.data>>({});
const [error, setError] = useState<string>(); const [patchParamsError, setPatchParamsError] = useState<string>();
return ( return (
<form <form
className="flex flex-col items-stretch gap-4" className="flex flex-col items-stretch gap-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
params.mutate(() => getParams.mutate(() =>
fetchApi("settings/params", "PATCH", { fetchApi("settings/params", "PATCH", {
query: { sessionId }, query: { sessionId: sessionId ?? "" },
body: { type: "application/json", data: changedParams ?? {} }, body: { type: "application/json", data: newParams ?? {} },
}).then(handleResponse) }).then(handleResponse)
) )
.then(() => setChangedParams({})) .then(() => setNewParams({}))
.catch((e) => setError(String(e))); .catch((e) => setPatchParamsError(String(e)));
}} }}
> >
<label className="flex flex-col items-stretch gap-1"> <label className="flex flex-col items-stretch gap-1">
<span className="text-sm"> <span className="text-sm">
Negative prompt {changedParams?.negative_prompt != null ? "(Changed)" : ""} Negative prompt {newParams?.negative_prompt != null ? "(Changed)" : ""}
</span> </span>
<textarea <textarea
className="input-text" className="input-text"
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.negative_prompt ?? value={newParams?.negative_prompt ??
params.data?.negative_prompt ?? getParams.data?.negative_prompt ??
""} ""}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
negative_prompt: e.target.value, negative_prompt: e.target.value,
}))} }))}
/> />
</label> </label>
<label className="flex flex-col items-stretch gap-1"> <label className="flex flex-col items-stretch gap-1">
<span className="text-sm"> <span className="text-sm">
Sampler {changedParams?.sampler_name != null ? "(Changed)" : ""} Sampler {newParams?.sampler_name != null ? "(Changed)" : ""}
</span> </span>
<input <input
className="input-text" className="input-text"
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.sampler_name ?? value={newParams?.sampler_name ??
params.data?.sampler_name ?? getParams.data?.sampler_name ??
""} ""}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
sampler_name: e.target.value, sampler_name: e.target.value,
}))} }))}
/> />
</label> </label>
<label className="flex flex-col items-stretch gap-1"> <label className="flex flex-col items-stretch gap-1">
<span className="text-sm"> <span className="text-sm">
Steps {changedParams?.steps != null ? "(Changed)" : ""} Steps {newParams?.steps != null ? "(Changed)" : ""}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<input <input
@ -82,12 +85,12 @@ export function SettingsPage(props: { sessionId: string }) {
min={5} min={5}
max={50} max={50}
step={5} step={5}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.steps ?? value={newParams?.steps ??
params.data?.steps ?? getParams.data?.steps ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
steps: Number(e.target.value), steps: Number(e.target.value),
}))} }))}
@ -98,21 +101,22 @@ export function SettingsPage(props: { sessionId: string }) {
min={5} min={5}
max={50} max={50}
step={5} step={5}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.steps ?? value={newParams?.steps ??
params.data?.steps ?? getParams.data?.steps ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
steps: Number(e.target.value), steps: Number(e.target.value),
}))} }))}
/> />
</span> </span>
</label> </label>
<label className="flex flex-col items-stretch gap-1"> <label className="flex flex-col items-stretch gap-1">
<span className="text-sm"> <span className="text-sm">
Detail {changedParams?.cfg_scale != null ? "(Changed)" : ""} Detail {newParams?.cfg_scale != null ? "(Changed)" : ""}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<input <input
@ -121,12 +125,12 @@ export function SettingsPage(props: { sessionId: string }) {
min={1} min={1}
max={20} max={20}
step={1} step={1}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.cfg_scale ?? value={newParams?.cfg_scale ??
params.data?.cfg_scale ?? getParams.data?.cfg_scale ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
cfg_scale: Number(e.target.value), cfg_scale: Number(e.target.value),
}))} }))}
@ -137,22 +141,23 @@ export function SettingsPage(props: { sessionId: string }) {
min={1} min={1}
max={20} max={20}
step={1} step={1}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.cfg_scale ?? value={newParams?.cfg_scale ??
params.data?.cfg_scale ?? getParams.data?.cfg_scale ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
cfg_scale: Number(e.target.value), cfg_scale: Number(e.target.value),
}))} }))}
/> />
</span> </span>
</label> </label>
<div className="flex gap-4"> <div className="flex gap-4">
<label className="flex flex-1 flex-col items-stretch gap-1"> <label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm"> <span className="text-sm">
Width {changedParams?.width != null ? "(Changed)" : ""} Width {newParams?.width != null ? "(Changed)" : ""}
</span> </span>
<input <input
className="input-text" className="input-text"
@ -160,12 +165,12 @@ export function SettingsPage(props: { sessionId: string }) {
min={64} min={64}
max={2048} max={2048}
step={64} step={64}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.width ?? value={newParams?.width ??
params.data?.width ?? getParams.data?.width ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
width: Number(e.target.value), width: Number(e.target.value),
}))} }))}
@ -176,12 +181,12 @@ export function SettingsPage(props: { sessionId: string }) {
min={64} min={64}
max={2048} max={2048}
step={64} step={64}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.width ?? value={newParams?.width ??
params.data?.width ?? getParams.data?.width ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
width: Number(e.target.value), width: Number(e.target.value),
}))} }))}
@ -189,7 +194,7 @@ export function SettingsPage(props: { sessionId: string }) {
</label> </label>
<label className="flex flex-1 flex-col items-stretch gap-1"> <label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm"> <span className="text-sm">
Height {changedParams?.height != null ? "(Changed)" : ""} Height {newParams?.height != null ? "(Changed)" : ""}
</span> </span>
<input <input
className="input-text" className="input-text"
@ -197,12 +202,12 @@ export function SettingsPage(props: { sessionId: string }) {
min={64} min={64}
max={2048} max={2048}
step={64} step={64}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.height ?? value={newParams?.height ??
params.data?.height ?? getParams.data?.height ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
height: Number(e.target.value), height: Number(e.target.value),
}))} }))}
@ -213,35 +218,54 @@ export function SettingsPage(props: { sessionId: string }) {
min={64} min={64}
max={2048} max={2048}
step={64} step={64}
disabled={params.isLoading || !user.data?.isAdmin} disabled={getParams.isLoading || !getUser.data?.admin}
value={changedParams?.height ?? value={newParams?.height ??
params.data?.height ?? getParams.data?.height ??
0} 0}
onChange={(e) => onChange={(e) =>
setChangedParams((params) => ({ setNewParams((params) => ({
...params, ...params,
height: Number(e.target.value), height: Number(e.target.value),
}))} }))}
/> />
</label> </label>
</div> </div>
{error ? <Alert onClose={() => setError(undefined)}>{error}</Alert> : null}
{params.error ? <Alert>{params.error.message}</Alert> : null} {patchParamsError
? (
<p className="alert">
<span className="flex-grow">Updating params failed: {patchParamsError}</span>
<button className="button-ghost" onClick={() => setPatchParamsError(undefined)}>
Close
</button>
</p>
)
: null}
{getParams.error
? (
<p className="alert">
<span className="flex-grow">
Loading params failed: {String(getParams.error)}
</span>
</p>
)
: null}
<div className="flex gap-2 items-center justify-end"> <div className="flex gap-2 items-center justify-end">
<button <button
type="button" type="button"
className={cx("button-outlined ripple", params.isLoading && "bg-stripes")} className={cx("button-outlined ripple", getParams.isLoading && "bg-stripes")}
disabled={params.isLoading || !user.data?.isAdmin || disabled={getParams.isLoading || !getUser.data?.admin ||
Object.keys(changedParams ?? {}).length === 0} Object.keys(newParams ?? {}).length === 0}
onClick={() => setChangedParams({})} onClick={() => setNewParams({})}
> >
Reset Reset
</button> </button>
<button <button
type="submit" type="submit"
className={cx("button-filled ripple", params.isLoading && "bg-stripes")} className={cx("button-filled ripple", getParams.isLoading && "bg-stripes")}
disabled={params.isLoading || !user.data?.isAdmin || disabled={getParams.isLoading || !getUser.data?.admin ||
Object.keys(changedParams ?? {}).length === 0} Object.keys(newParams ?? {}).length === 0}
> >
Save Save
</button> </button>
@ -249,26 +273,3 @@ export function SettingsPage(props: { sessionId: string }) {
</form> </form>
); );
} }
function Alert(props: { children: ReactNode; onClose?: () => void }) {
const { children, onClose } = props;
return (
<p
role="alert"
className="px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md"
>
<span className="flex-grow">{children}</span>
{onClose
? (
<button
type="button"
className="button-ghost"
onClick={() => onClose()}
>
Close
</button>
)
: null}
</p>
);
}

View File

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import { fetchApi, handleResponse } from "./apiClient.tsx"; import { fetchApi, handleResponse } from "./apiClient.ts";
import useSWR from "swr"; import useSWR from "swr";
import { Counter } from "./Counter.tsx"; import { Counter } from "./Counter.tsx";
export function StatsPage() { export function StatsPage() {
const globalStats = useSWR( const getGlobalStats = useSWR(
["stats", "GET", {}] as const, ["stats", "GET", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ refreshInterval: 2_000 }, { refreshInterval: 2_000 },
@ -16,13 +16,13 @@ export function StatsPage() {
<span>Pixelsteps diffused</span> <span>Pixelsteps diffused</span>
<Counter <Counter
className="font-bold text-zinc-700 dark:text-zinc-300" className="font-bold text-zinc-700 dark:text-zinc-300"
value={globalStats.data?.pixelStepCount ?? 0} value={getGlobalStats.data?.pixelStepCount ?? 0}
digits={15} digits={15}
transitionDurationMs={3_000} transitionDurationMs={3_000}
/> />
<Counter <Counter
className="text-base" className="text-base"
value={(globalStats.data?.pixelStepsPerMinute ?? 0) / 60} value={(getGlobalStats.data?.pixelStepsPerMinute ?? 0) / 60}
digits={9} digits={9}
transitionDurationMs={2_000} transitionDurationMs={2_000}
postfix="/s" postfix="/s"
@ -32,13 +32,13 @@ export function StatsPage() {
<span>Pixels painted</span> <span>Pixels painted</span>
<Counter <Counter
className="font-bold text-zinc-700 dark:text-zinc-300" className="font-bold text-zinc-700 dark:text-zinc-300"
value={globalStats.data?.pixelCount ?? 0} value={getGlobalStats.data?.pixelCount ?? 0}
digits={15} digits={15}
transitionDurationMs={3_000} transitionDurationMs={3_000}
/> />
<Counter <Counter
className="text-base" className="text-base"
value={(globalStats.data?.pixelsPerMinute ?? 0) / 60} value={(getGlobalStats.data?.pixelsPerMinute ?? 0) / 60}
digits={9} digits={9}
transitionDurationMs={2_000} transitionDurationMs={2_000}
postfix="/s" postfix="/s"
@ -49,13 +49,13 @@ export function StatsPage() {
<span>Steps processed</span> <span>Steps processed</span>
<Counter <Counter
className="font-bold text-zinc-700 dark:text-zinc-300" className="font-bold text-zinc-700 dark:text-zinc-300"
value={globalStats.data?.stepCount ?? 0} value={getGlobalStats.data?.stepCount ?? 0}
digits={9} digits={9}
transitionDurationMs={3_000} transitionDurationMs={3_000}
/> />
<Counter <Counter
className="text-base" className="text-base"
value={(globalStats.data?.stepsPerMinute ?? 0) / 60} value={(getGlobalStats.data?.stepsPerMinute ?? 0) / 60}
digits={3} digits={3}
fractionDigits={3} fractionDigits={3}
transitionDurationMs={2_000} transitionDurationMs={2_000}
@ -66,13 +66,13 @@ export function StatsPage() {
<span>Images generated</span> <span>Images generated</span>
<Counter <Counter
className="font-bold text-zinc-700 dark:text-zinc-300" className="font-bold text-zinc-700 dark:text-zinc-300"
value={globalStats.data?.imageCount ?? 0} value={getGlobalStats.data?.imageCount ?? 0}
digits={9} digits={9}
transitionDurationMs={3_000} transitionDurationMs={3_000}
/> />
<Counter <Counter
className="text-base" className="text-base"
value={(globalStats.data?.imagesPerMinute ?? 0) / 60} value={(getGlobalStats.data?.imagesPerMinute ?? 0) / 60}
digits={3} digits={3}
fractionDigits={3} fractionDigits={3}
transitionDurationMs={2_000} transitionDurationMs={2_000}
@ -84,7 +84,7 @@ export function StatsPage() {
<span>Unique users</span> <span>Unique users</span>
<Counter <Counter
className="font-bold text-zinc-700 dark:text-zinc-300" className="font-bold text-zinc-700 dark:text-zinc-300"
value={globalStats.data?.userCount ?? 0} value={getGlobalStats.data?.userCount ?? 0}
digits={6} digits={6}
transitionDurationMs={1_500} transitionDurationMs={1_500}
/> />

View File

@ -1,27 +1,27 @@
import React, { useRef } from "react"; import React, { RefObject, useRef, useState } from "react";
import { FormattedRelativeTime } from "react-intl"; import { FormattedRelativeTime } from "react-intl";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { WorkerData } from "../api/workersRoute.ts"; import { WorkerData } from "../api/workersRoute.ts";
import { Counter } from "./Counter.tsx"; import { Counter } from "./Counter.tsx";
import { fetchApi, handleResponse } from "./apiClient.tsx"; import { fetchApi, handleResponse } from "./apiClient.ts";
export function WorkersPage(props: { sessionId?: string }) { export function WorkersPage(props: { sessionId: string | null }) {
const { sessionId } = props; const { sessionId } = props;
const createWorkerModalRef = useRef<HTMLDialogElement>(null); const addDialogRef = useRef<HTMLDialogElement>(null);
const session = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const user = useSWR( const getUser = useSWR(
session.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const workers = useSWR( const getWorkers = useSWR(
["workers", "GET", {}] as const, ["workers", "GET", {}] as const,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
{ refreshInterval: 5000 }, { refreshInterval: 5000 },
@ -29,37 +29,63 @@ export function WorkersPage(props: { sessionId?: string }) {
return ( return (
<> <>
<ul className="my-4 flex flex-col gap-2"> {getWorkers.data?.length
{workers.data?.map((worker) => ( ? (
<WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} /> <ul className="my-4 flex flex-col gap-2">
))} {getWorkers.data?.map((worker) => (
</ul> <WorkerListItem key={worker.id} worker={worker} sessionId={sessionId} />
{user.data?.isAdmin && ( ))}
</ul>
)
: getWorkers.data?.length === 0
? <p>No workers</p>
: getWorkers.error
? <p className="alert">Loading workers failed</p>
: <div className="spinner self-center" />}
{getUser.data?.admin && (
<button <button
className="button-filled" className="button-filled"
onClick={() => createWorkerModalRef.current?.showModal()} onClick={() => addDialogRef.current?.showModal()}
> >
Add worker Add worker
</button> </button>
)} )}
<dialog
className="dialog animate-pop-in backdrop-animate-fade-in" <AddWorkerDialog
ref={createWorkerModalRef} dialogRef={addDialogRef}
> sessionId={sessionId}
<form />
method="dialog" </>
className="flex flex-col gap-4 p-4" );
onSubmit={(e) => { }
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement); function AddWorkerDialog(props: {
const key = data.get("key") as string; dialogRef: RefObject<HTMLDialogElement>;
const name = data.get("name") as string; sessionId: string | null;
const sdUrl = data.get("url") as string; }) {
const user = data.get("user") as string; const { dialogRef, sessionId } = props;
const password = data.get("password") as string;
console.log(key, name, user, password); const { mutate } = useSWRConfig();
workers.mutate(async () => {
const worker = await fetchApi("workers", "POST", { return (
<dialog ref={dialogRef} className="dialog animate-pop-in backdrop-animate-fade-in">
<form
method="dialog"
className="flex flex-col gap-4 p-4"
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const key = data.get("key") as string;
const name = data.get("name") as string;
const sdUrl = data.get("url") as string;
const user = data.get("user") as string;
const password = data.get("password") as string;
console.log(key, name, user, password);
mutate(
(key) => Array.isArray(key) && key[0] === "workers",
async () =>
fetchApi("workers", "POST", {
query: { sessionId: sessionId! }, query: { sessionId: sessionId! },
body: { body: {
type: "application/json", type: "application/json",
@ -70,113 +96,112 @@ export function WorkersPage(props: { sessionId?: string }) {
sdAuth: user && password ? { user, password } : null, sdAuth: user && password ? { user, password } : null,
}, },
}, },
}).then(handleResponse); }).then(handleResponse),
return [...(workers.data ?? []), worker]; { populateCache: false },
}); );
dialogRef.current?.close();
createWorkerModalRef.current?.close(); }}
}} >
> <div className="flex gap-4">
<div className="flex gap-4"> <label className="flex flex-1 flex-col items-stretch gap-1">
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
Key
</span>
<input
className="input-text"
type="text"
name="key"
required
/>
<span className="text-sm text-zinc-500">
Used for counting statistics
</span>
</label>
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
Name
</span>
<input
className="input-text"
type="text"
name="name"
/>
<span className="text-sm text-zinc-500">
Used for display
</span>
</label>
</div>
<label className="flex flex-col items-stretch gap-1">
<span className="text-sm"> <span className="text-sm">
URL Key
</span> </span>
<input <input
className="input-text" className="input-text"
type="url" type="text"
name="url" name="key"
required required
pattern="https?://.*" />
<span className="text-sm text-zinc-500">
Used for counting statistics
</span>
</label>
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
Name
</span>
<input
className="input-text"
type="text"
name="name"
/>
<span className="text-sm text-zinc-500">
Used for display
</span>
</label>
</div>
<label className="flex flex-col items-stretch gap-1">
<span className="text-sm">
URL
</span>
<input
className="input-text"
type="url"
name="url"
required
pattern="https?://.*"
/>
</label>
<div className="flex gap-4">
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
User
</span>
<input
className="input-text"
type="text"
name="user"
/> />
</label> </label>
<div className="flex gap-4"> <label className="flex flex-1 flex-col items-stretch gap-1">
<label className="flex flex-1 flex-col items-stretch gap-1"> <span className="text-sm">
<span className="text-sm"> Password
User </span>
</span> <input
<input className="input-text"
className="input-text" type="password"
type="text" name="password"
name="user" />
/> </label>
</label> </div>
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
Password
</span>
<input
className="input-text"
type="password"
name="password"
/>
</label>
</div>
<div className="flex gap-2 items-center justify-end"> <div className="flex gap-2 items-center justify-end">
<button <button
type="button" type="button"
className="button-outlined" className="button-outlined"
onClick={() => createWorkerModalRef.current?.close()} onClick={() => dialogRef.current?.close()}
> >
Close Close
</button> </button>
<button type="submit" disabled={!sessionId} className="button-filled"> <button type="submit" disabled={!sessionId} className="button-filled">
Save Save
</button> </button>
</div> </div>
</form> </form>
</dialog> </dialog>
</>
); );
} }
function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefined }) { function WorkerListItem(props: {
worker: WorkerData;
sessionId: string | null;
}) {
const { worker, sessionId } = props; const { worker, sessionId } = props;
const editWorkerModalRef = useRef<HTMLDialogElement>(null); const editDialogRef = useRef<HTMLDialogElement>(null);
const deleteWorkerModalRef = useRef<HTMLDialogElement>(null); const deleteDialogRef = useRef<HTMLDialogElement>(null);
const session = useSWR( const getSession = useSWR(
sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const user = useSWR( const getUser = useSWR(
session.data?.userId getSession.data?.userId
? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const
: null, : null,
(args) => fetchApi(...args).then(handleResponse), (args) => fetchApi(...args).then(handleResponse),
); );
const { mutate } = useSWRConfig();
return ( return (
<li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2"> <li className="flex flex-col gap-2 rounded-md bg-zinc-100 dark:bg-zinc-800 p-2">
<p className="font-bold"> <p className="font-bold">
@ -212,119 +237,162 @@ function WorkerListItem(props: { worker: WorkerData; sessionId: string | undefin
{" "} {" "}
<Counter value={worker.stepsPerMinute} digits={3} /> steps per minute <Counter value={worker.stepsPerMinute} digits={3} /> steps per minute
</p> </p>
<p className="flex gap-2"> {getUser.data?.admin && (
{user.data?.isAdmin && ( <p className="flex gap-2">
<> <button
<button className="button-outlined"
className="button-outlined" onClick={() => editDialogRef.current?.showModal()}
onClick={() => editWorkerModalRef.current?.showModal()} >
> Settings
Settings </button>
</button> <button
<button className="button-outlined"
className="button-outlined" onClick={() => deleteDialogRef.current?.showModal()}
onClick={() => deleteWorkerModalRef.current?.showModal()} >
> Delete
Delete </button>
</button> </p>
</> )}
)} <EditWorkerDialog
</p> dialogRef={editDialogRef}
<dialog workerId={worker.id}
className="dialog animate-pop-in backdrop-animate-fade-in" sessionId={sessionId}
ref={editWorkerModalRef} />
> <DeleteWorkerDialog
<form dialogRef={deleteDialogRef}
method="dialog" workerId={worker.id}
className="flex flex-col gap-4 p-4" sessionId={sessionId}
onSubmit={(e) => { />
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const user = data.get("user") as string;
const password = data.get("password") as string;
console.log(user, password);
fetchApi("workers/{workerId}", "PATCH", {
params: { workerId: worker.id },
query: { sessionId: sessionId! },
body: {
type: "application/json",
data: {
sdAuth: user && password ? { user, password } : null,
},
},
});
editWorkerModalRef.current?.close();
}}
>
<div className="flex gap-4">
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
User
</span>
<input
className="input-text"
type="text"
name="user"
/>
</label>
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
Password
</span>
<input
className="input-text"
type="password"
name="password"
/>
</label>
</div>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
className="button-outlined"
onClick={() => editWorkerModalRef.current?.close()}
>
Close
</button>
<button type="submit" disabled={!sessionId} className="button-filled">
Save
</button>
</div>
</form>
</dialog>
<dialog
className="dialog animate-pop-in backdrop-animate-fade-in"
ref={deleteWorkerModalRef}
>
<form
method="dialog"
className="flex flex-col gap-4 p-4"
onSubmit={(e) => {
e.preventDefault();
fetchApi("workers/{workerId}", "DELETE", {
params: { workerId: worker.id },
query: { sessionId: sessionId! },
}).then(handleResponse).then(() => mutate(["workers", "GET", {}]));
deleteWorkerModalRef.current?.close();
}}
>
<p>
Are you sure you want to delete this worker?
</p>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
className="button-outlined"
onClick={() => deleteWorkerModalRef.current?.close()}
>
Close
</button>
<button type="submit" disabled={!sessionId} className="button-filled">
Delete
</button>
</div>
</form>
</dialog>
</li> </li>
); );
} }
function EditWorkerDialog(props: {
dialogRef: RefObject<HTMLDialogElement>;
workerId: string;
sessionId: string | null;
}) {
const { dialogRef, workerId, sessionId } = props;
const { mutate } = useSWRConfig();
return (
<dialog
className="dialog animate-pop-in backdrop-animate-fade-in"
ref={dialogRef}
>
<form
method="dialog"
className="flex flex-col gap-4 p-4"
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const user = data.get("user") as string;
const password = data.get("password") as string;
console.log(user, password);
mutate(
(key) => Array.isArray(key) && key[0] === "workers",
async () =>
fetchApi("workers/{workerId}", "PATCH", {
params: { workerId: workerId },
query: { sessionId: sessionId! },
body: {
type: "application/json",
data: {
sdAuth: user && password ? { user, password } : null,
},
},
}).then(handleResponse),
{ populateCache: false },
);
dialogRef.current?.close();
}}
>
<div className="flex gap-4">
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
User
</span>
<input
className="input-text"
type="text"
name="user"
/>
</label>
<label className="flex flex-1 flex-col items-stretch gap-1">
<span className="text-sm">
Password
</span>
<input
className="input-text"
type="password"
name="password"
/>
</label>
</div>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
className="button-outlined"
onClick={() => dialogRef.current?.close()}
>
Close
</button>
<button type="submit" disabled={!sessionId} className="button-filled">
Save
</button>
</div>
</form>
</dialog>
);
}
function DeleteWorkerDialog(props: {
dialogRef: RefObject<HTMLDialogElement>;
workerId: string;
sessionId: string | null;
}) {
const { dialogRef, workerId, sessionId } = props;
const { mutate } = useSWRConfig();
return (
<dialog
className="dialog animate-pop-in backdrop-animate-fade-in"
ref={dialogRef}
>
<form
method="dialog"
className="flex flex-col gap-4 p-4"
onSubmit={(e) => {
e.preventDefault();
mutate(
(key) => Array.isArray(key) && key[0] === "workers",
async () =>
fetchApi("workers/{workerId}", "DELETE", {
params: { workerId: workerId },
query: { sessionId: sessionId! },
}).then(handleResponse),
{ populateCache: false },
);
dialogRef.current?.close();
}}
>
<p>
Are you sure you want to delete this worker?
</p>
<div className="flex gap-2 items-center justify-end">
<button
type="button"
className="button-outlined"
onClick={() => dialogRef.current?.close()}
>
Close
</button>
<button type="submit" disabled={!sessionId} className="button-filled">
Delete
</button>
</div>
</form>
</dialog>
);
}

View File

@ -3,9 +3,10 @@ import presetTailwind from "twind/preset-tailwind";
const twConfig = defineConfig({ const twConfig = defineConfig({
presets: [presetTailwind()], presets: [presetTailwind()],
hash: false,
}); });
install(twConfig); install(twConfig, false);
injectGlobal` injectGlobal`
@layer base { @layer base {
@ -89,6 +90,10 @@ injectGlobal`
@apply h-8 w-8 animate-spin rounded-full border-4 border-transparent border-t-current; @apply h-8 w-8 animate-spin rounded-full border-4 border-transparent border-t-current;
} }
.alert {
@apply px-4 py-2 flex gap-2 items-center bg-red-500 text-white rounded-sm shadow-md;
}
.button-filled { .button-filled {
@apply rounded-md bg-sky-600 px-3 py-2 min-h-12 @apply rounded-md bg-sky-600 px-3 py-2 min-h-12
text-sm font-semibold uppercase tracking-wider text-white shadow-sm transition-all text-sm font-semibold uppercase tracking-wider text-white shadow-sm transition-all
@ -111,7 +116,7 @@ injectGlobal`
} }
.tab { .tab {
@apply inline-flex items-center px-4 text-sm text-center bg-transparent border-b-2 sm:text-base whitespace-nowrap focus:outline-none @apply inline-flex items-center px-4 text-sm text-center bg-transparent border-b-2 sm:text-base whitespace-nowrap outline-none
border-transparent dark:text-white cursor-base hover:border-zinc-400 focus:border-zinc-400 transition-all; border-transparent dark:text-white cursor-base hover:border-zinc-400 focus:border-zinc-400 transition-all;
} }
@ -131,8 +136,8 @@ injectGlobal`
} }
.dialog { .dialog {
@apply overflow-hidden overflow-y-auto rounded-md @apply overflow-hidden overflow-y-auto rounded-md shadow-xl
bg-zinc-100 text-zinc-900 shadow-lg backdrop:bg-black/30 dark:bg-zinc-800 dark:text-zinc-100; bg-white text-zinc-900 shadow-lg backdrop:bg-black/30 dark:bg-zinc-800 dark:text-zinc-100;
} }
} }
`; `;

31
ui/useLocalStorage.ts Normal file
View File

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
export function useLocalStorage(key: string) {
const [value, setValue] = useState(() => window.localStorage.getItem(key));
useEffect(() => {
console.log(key, value);
if (value != null) {
window.localStorage.setItem(key, value);
} else {
window.localStorage.removeItem(key);
}
}, [key, value]);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key === key) {
console.log(key, event.newValue);
setValue(event.newValue);
}
};
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("storage", handleStorage);
};
}, [key]);
return [value, setValue] as const;
}