Admin interface MVP

This commit is contained in:
Ryan Pandya 2024-11-14 21:09:59 -08:00
parent ea2a9da1e0
commit 50a89d4104
19 changed files with 196 additions and 191 deletions

View File

@ -1,27 +0,0 @@
import {
printError,
printErrorMessageWithReason,
printObject,
} from "@/lib/output";
import { getAPIClient } from "@/lib/trpc";
import { Command } from "@commander-js/extra-typings";
export const helloWorldCmd = new Command()
.name("hello-world")
.description("returns info about the server and stats");
helloWorldCmd
.command("test")
.description("does something specific I guess")
.action(async () => {
const api = getAPIClient();
const whoami = await api.users.whoami.query();
try {
console.log("Hello " + whoami.name);
} catch (error) {
printErrorMessageWithReason(
"Something went horribly wrong",
error as object,
);
}
});

View File

@ -0,0 +1,21 @@
import {
printError,
printErrorMessageWithReason,
printObject,
} from "@/lib/output";
import { getAPIClient } from "@/lib/trpc";
import { Command } from "@commander-js/extra-typings";
export const whoamiCmd = new Command()
.name("whoami")
.description("returns info about the owner of this API key")
.action(async () => {
await getAPIClient()
.users.whoami.query()
.then(printObject)
.catch(
printError(
`Unable to fetch information about the owner of this API key`,
),
);
});

View File

@ -1,4 +1,4 @@
import { helloWorldCmd } from "@/commands/hello-world" import { whoamiCmd } from "@/commands/whoami";
import { setGlobalOptions } from "@/lib/globals"; import { setGlobalOptions } from "@/lib/globals";
import { Command, Option } from "@commander-js/extra-typings"; import { Command, Option } from "@commander-js/extra-typings";
@ -25,7 +25,10 @@ const program = new Command()
: "0.0.0", : "0.0.0",
); );
program.addCommand(helloWorldCmd); program.addCommand(whoamiCmd, {
isDefault: true
});
setGlobalOptions(program.opts()); setGlobalOptions(program.opts());
program.parse(); program.parse();

View File

@ -1,7 +1,7 @@
export interface GlobalOptions { export interface GlobalOptions {
apiKey: string; apiKey: string;
serverAddr: string; serverAddr: string;
json?: true; json?: false;
} }
export let globalOpts: GlobalOptions | undefined = undefined; export let globalOpts: GlobalOptions | undefined = undefined;

View File

@ -9,7 +9,7 @@ export default function LifetrackerLogo({
return ( return (
<span style={{ gap }} className="flex items-center"> <span style={{ gap }} className="flex items-center">
<span <span
style={{ fontSize: "50px" }} style={{ fontSize: "35px" }}
className={`fill-foreground`} className={`fill-foreground`}
>Lifetracker</span> >Lifetracker</span>
</span> </span>

View File

@ -33,7 +33,7 @@ import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zAdminCreateUserSchema } from "@hoarder/shared/types/admin"; import { zAdminCreateUserSchema } from "@lifetracker/shared/types/admin";
type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>; type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>;

View File

@ -5,67 +5,6 @@ import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
export default function AdminActions() { export default function AdminActions() {
const { mutate: recrawlLinks, isPending: isRecrawlPending } =
api.admin.recrawlLinks.useMutation({
onSuccess: () => {
toast({
description: "Recrawl enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const { mutate: reindexBookmarks, isPending: isReindexPending } =
api.admin.reindexAllBookmarks.useMutation({
onSuccess: () => {
toast({
description: "Reindex enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const {
mutate: reRunInferenceOnAllBookmarks,
isPending: isInferencePending,
} = api.admin.reRunInferenceOnAllBookmarks.useMutation({
onSuccess: () => {
toast({
description: "Inference jobs enqueued",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
const { mutateAsync: tidyAssets, isPending: isTidyAssetsPending } =
api.admin.tidyAssets.useMutation({
onSuccess: () => {
toast({
description: "Tidy assets request has been enqueued!",
});
},
onError: (e) => {
toast({
variant: "destructive",
description: e.message,
});
},
});
return ( return (
<div> <div>
@ -73,60 +12,10 @@ export default function AdminActions() {
<div className="flex flex-col gap-2 sm:w-1/2"> <div className="flex flex-col gap-2 sm:w-1/2">
<ActionButton <ActionButton
variant="destructive" variant="destructive"
loading={isRecrawlPending} loading={false}
onClick={() => onClick={() => alert("This does nothing yet")}
recrawlLinks({ crawlStatus: "failure", runInference: true })
}
> >
Recrawl Failed Links Only TODO: Add Stuff Here
</ActionButton>
<ActionButton
variant="destructive"
loading={isRecrawlPending}
onClick={() =>
recrawlLinks({ crawlStatus: "all", runInference: true })
}
>
Recrawl All Links
</ActionButton>
<ActionButton
variant="destructive"
loading={isRecrawlPending}
onClick={() =>
recrawlLinks({ crawlStatus: "all", runInference: false })
}
>
Recrawl All Links (Without Inference)
</ActionButton>
<ActionButton
variant="destructive"
loading={isInferencePending}
onClick={() =>
reRunInferenceOnAllBookmarks({ taggingStatus: "failure" })
}
>
Regenerate AI Tags for Failed Bookmarks Only
</ActionButton>
<ActionButton
variant="destructive"
loading={isInferencePending}
onClick={() => reRunInferenceOnAllBookmarks({ taggingStatus: "all" })}
>
Regenerate AI Tags for All Bookmarks
</ActionButton>
<ActionButton
variant="destructive"
loading={isReindexPending}
onClick={() => reindexBookmarks()}
>
Reindex All Bookmarks
</ActionButton>
<ActionButton
variant="destructive"
loading={isTidyAssetsPending}
onClick={() => tidyAssets()}
>
Compact Assets
</ActionButton> </ActionButton>
</div> </div>
</div> </div>

View File

@ -32,7 +32,7 @@ import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { changeRoleSchema } from "@hoarder/shared/types/admin"; import { changeRoleSchema } from "@lifetracker/shared/types/admin";
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>; type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;

View File

@ -26,7 +26,7 @@ import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { resetPasswordSchema } from "@hoarder/shared/types/admin"; import { resetPasswordSchema } from "@lifetracker/shared/types/admin";
interface ResetPasswordDialogProps { interface ResetPasswordDialogProps {
userId: string; userId: string;

View File

@ -14,8 +14,8 @@ import { api } from "@/lib/trpc";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
const REPO_LATEST_RELEASE_API = const REPO_LATEST_RELEASE_API =
"https://api.github.com/repos/hoarder-app/hoarder/releases/latest"; "https://git.ryanpandya.com/repos/ryan/lifetracker/releases/latest";
const REPO_RELEASE_PAGE = "https://github.com/hoarder-app/hoarder/releases"; const REPO_RELEASE_PAGE = "https://git.ryanpandya.com/ryan/lifetracker/releases";
function useLatestRelease() { function useLatestRelease() {
const { data } = useQuery({ const { data } = useQuery({
@ -55,7 +55,7 @@ function ReleaseInfo() {
return ( return (
<div className="text-nowrap"> <div className="text-nowrap">
<span className="text-3xl font-semibold">{currentRelease}</span> <span className="text-3xl font-semibold">{currentRelease}</span>
<span className="ml-1 text-sm">{newRelease}</span> {/* <span className="ml-1 text-sm">{newRelease}</span> */}
</div> </div>
); );
} }
@ -83,7 +83,7 @@ export default function ServerStats() {
Total Bookmarks Total Bookmarks
</div> </div>
<div className="text-3xl font-semibold"> <div className="text-3xl font-semibold">
{serverStats.numBookmarks} {serverStats.numEntries}
</div> </div>
</div> </div>
<div className="rounded-md border bg-background p-4 sm:w-1/4"> <div className="rounded-md border bg-background p-4 sm:w-1/4">
@ -105,28 +105,7 @@ export default function ServerStats() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell className="lg:w-2/3">Crawling Jobs</TableCell> <TableCell className="lg:w-2/3">No workers yet...</TableCell>
<TableCell>{serverStats.crawlStats.queued}</TableCell>
<TableCell>{serverStats.crawlStats.pending}</TableCell>
<TableCell>{serverStats.crawlStats.failed}</TableCell>
</TableRow>
<TableRow>
<TableCell>Indexing Jobs</TableCell>
<TableCell>{serverStats.indexingStats.queued}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
<TableRow>
<TableCell>Inference Jobs</TableCell>
<TableCell>{serverStats.inferenceStats.queued}</TableCell>
<TableCell>{serverStats.inferenceStats.pending}</TableCell>
<TableCell>{serverStats.inferenceStats.failed}</TableCell>
</TableRow>
<TableRow>
<TableCell>Tidy Assets Jobs</TableCell>
<TableCell>{serverStats.tidyAssetsStats.queued}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>

View File

@ -67,8 +67,8 @@ export default function UsersSection() {
<TableHeader className="bg-gray-200"> <TableHeader className="bg-gray-200">
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Num Bookmarks</TableHead> <TableHead>Days on Record</TableHead>
<TableHead>Asset Sizes</TableHead> <TableHead>Entries</TableHead>
<TableHead>Role</TableHead> <TableHead>Role</TableHead>
<TableHead>Local User</TableHead> <TableHead>Local User</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
@ -79,10 +79,10 @@ export default function UsersSection() {
<TableCell className="py-1">{u.name}</TableCell> <TableCell className="py-1">{u.name}</TableCell>
<TableCell className="py-1">{u.email}</TableCell> <TableCell className="py-1">{u.email}</TableCell>
<TableCell className="py-1"> <TableCell className="py-1">
{userStats[u.id].numBookmarks} {userStats[u.id].numDays}
</TableCell> </TableCell>
<TableCell className="py-1"> <TableCell className="py-1">
{toHumanReadableSize(userStats[u.id].assetSizes)} {userStats[u.id].numEntries}
</TableCell> </TableCell>
<TableCell className="py-1 capitalize">{u.role}</TableCell> <TableCell className="py-1 capitalize">{u.role}</TableCell>
<TableCell className="py-1 capitalize"> <TableCell className="py-1 capitalize">

View File

@ -26,7 +26,7 @@ import { cn } from "@/lib/utils";
import { distance } from "fastest-levenshtein"; import { distance } from "fastest-levenshtein";
import { Check, Combine, X } from "lucide-react"; import { Check, Combine, X } from "lucide-react";
import { useMergeTag } from "@hoarder/shared-react/hooks/tags"; import { useMergeTag } from "@lifetracker/shared-react/hooks/tags";
interface Suggestion { interface Suggestion {
mergeIntoId: string; mergeIntoId: string;

View File

@ -17,11 +17,6 @@ export async function createContextFromRequest(req: Request) {
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
const token = authorizationHeader.split(" ")[1]; const token = authorizationHeader.split(" ")[1];
try { try {
console.log("\n\nEeeeeeentering hell\n\n");
console.log(await authenticateApiKey(token));
console.log("\n\n\n\n");
const user = await authenticateApiKey(token); const user = await authenticateApiKey(token);
return { return {
user, user,
@ -44,7 +39,7 @@ export const createContext = async (
): Promise<Context> => { ): Promise<Context> => {
const session = await getServerAuthSession(); const session = await getServerAuthSession();
if (ip === undefined) { if (ip === undefined) {
const hdrs = headers(); const hdrs = await headers();
ip = requestIp.getClientIp({ ip = requestIp.getClientIp({
headers: Object.fromEntries(hdrs.entries()), headers: Object.fromEntries(hdrs.entries()),
}); });

View File

@ -2,8 +2,6 @@ import dotenv from "dotenv";
import type { Config } from "drizzle-kit"; import type { Config } from "drizzle-kit";
import serverConfig from "@lifetracker/shared/config"; import serverConfig from "@lifetracker/shared/config";
const databaseURL = serverConfig.dataDir const databaseURL = serverConfig.dataDir
? `${serverConfig.dataDir}/lifetracker.db` ? `${serverConfig.dataDir}/lifetracker.db`
: "./lifetracker.db"; : "./lifetracker.db";

View File

@ -8,7 +8,10 @@ import path from "path";
import dbConfig from "./drizzle.config"; import dbConfig from "./drizzle.config";
const sqlite = new Database(dbConfig.dbCredentials.url); const sqlite = new Database(dbConfig.dbCredentials.url);
export const db = drizzle(sqlite, { schema, logger: true }); export const db = drizzle(sqlite, {
schema,
// logger: true
});
export function getInMemoryDB(runMigrations: boolean) { export function getInMemoryDB(runMigrations: boolean) {
const mem = new Database(":memory:"); const mem = new Database(":memory:");

View File

@ -1,9 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import dotenv from "dotenv"; import dotenv from "dotenv";
console.log(dotenv.config({ dotenv.config({
path: ".env.local", path: ".env.local",
debug: true });
}));
const stringBool = (defaultValue: string) => const stringBool = (defaultValue: string) =>
z z
.string() .string()

View File

@ -0,0 +1,25 @@
import { z } from "zod";
import { PASSWORD_MAX_LENGTH, zSignUpSchema } from "./users";
export const zRoleSchema = z.object({
role: z.enum(["user", "admin"]),
});
export const zAdminCreateUserSchema = zSignUpSchema.and(zRoleSchema);
export const changeRoleSchema = z.object({
userId: z.string(),
role: z.enum(["user", "admin"]),
});
export const resetPasswordSchema = z
.object({
userId: z.string(),
newPassword: z.string().min(8).max(PASSWORD_MAX_LENGTH),
newPasswordConfirm: z.string(),
})
.refine((data) => data.newPassword === data.newPasswordConfirm, {
message: "Passwords don't match",
path: ["newPasswordConfirm"],
});

View File

@ -2,10 +2,12 @@ import { apiKeys } from "@lifetracker/db/schema";
import { router } from "../index"; import { router } from "../index";
import { usersAppRouter } from "./users"; import { usersAppRouter } from "./users";
import { apiKeysAppRouter } from "./apiKeys"; import { apiKeysAppRouter } from "./apiKeys";
import { adminAppRouter } from "./admin";
export const appRouter = router({ export const appRouter = router({
users: usersAppRouter, users: usersAppRouter,
apiKeys: apiKeysAppRouter, apiKeys: apiKeysAppRouter,
admin: adminAppRouter,
}); });
// export type definition of API // export type definition of API
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,118 @@
import { TRPCError } from "@trpc/server";
import { count, eq, sum } from "drizzle-orm";
import { z } from "zod";
import { users } from "@lifetracker/db/schema";
import {
changeRoleSchema,
resetPasswordSchema,
zAdminCreateUserSchema,
} from "@lifetracker/shared/types/admin";
import { hashPassword } from "../auth";
import { adminProcedure, router } from "../index";
import { createUser } from "./users";
export const adminAppRouter = router({
stats: adminProcedure
.output(
z.object({
numUsers: z.number(),
numEntries: z.number(),
}),
)
.query(async ({ ctx }) => {
const [
[{ value: numUsers }],
] = await Promise.all([
ctx.db.select({ value: count() }).from(users)]);
return {
numUsers,
numEntries: 420.69,
};
}),
userStats: adminProcedure
.output(
z.record(
z.string(),
z.object({
numDays: z.number(),
numEntries: z.number(),
}),
),
)
.query(async ({ ctx }) => {
const [userIds] = await Promise.all([
ctx.db.select({ id: users.id }).from(users)
]);
const results: Record<
string,
{ numDays: number; numEntries: number }
> = {};
for (const user of userIds) {
results[user.id] = {
numDays: 0,
numEntries: 0,
};
}
return results;
}),
createUser: adminProcedure
.input(zAdminCreateUserSchema)
.output(
z.object({
id: z.string(),
name: z.string(),
email: z.string(),
role: z.enum(["user", "admin"]).nullable(),
}),
)
.mutation(async ({ input, ctx }) => {
return createUser(input, ctx, input.role);
}),
changeRole: adminProcedure
.input(changeRoleSchema)
.mutation(async ({ input, ctx }) => {
if (ctx.user.id == input.userId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot change own role",
});
}
const result = await ctx.db
.update(users)
.set({ role: input.role })
.where(eq(users.id, input.userId));
if (!result.changes) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
}),
resetPassword: adminProcedure
.input(resetPasswordSchema)
.mutation(async ({ input, ctx }) => {
if (ctx.user.id == input.userId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot reset own password",
});
}
const hashedPassword = await hashPassword(input.newPassword);
const result = await ctx.db
.update(users)
.set({ password: hashedPassword })
.where(eq(users.id, input.userId));
if (result.changes == 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
}),
});