Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/admin/settings/cache.tsx
T

460 lines
17 KiB
TypeScript

import { ActionFunctionArgs, Link, LoaderFunctionArgs, useActionData, useLoaderData } from "react-router";
import { useLocation, useNavigation, useSearchParams, useSubmit } from "react-router";
import { cache } from "~/utils/cache.server";
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
import TableSimple from "~/components/ui/tables/TableSimple";
import InputSearch from "~/components/ui/input/InputSearch";
import { Fragment, useEffect, useState } from "react";
import InputCombobox from "~/components/ui/input/InputCombobox";
import ShowPayloadModalButton from "~/components/ui/json/ShowPayloadModalButton";
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
import DateCell from "~/components/ui/dates/DateCell";
import { adminGetAllTenantsIdsAndNames } from "~/utils/db/tenants.db.server";
import { adminGetAllUsersNames, UserWithNames } from "~/utils/db/users.db.server";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService";
import BackButtonWithTitle from "~/components/ui/buttons/BackButtonWithTitle";
import { LucideRefreshCcw } from "~/components/ui/icons/vendor";
import { Button } from "~/components/ui/button";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { validateCSRFToken } from "~/utils/session.server";
import { appendCSRFToken } from "~/utils/csrf";
import { getTranslations } from "~/locale/i18next.server";
import { createLocaleFormatters, useLocaleFormatters } from "~/locale/format";
type CachedValue = {
key: string;
value: any;
sizeMb: number;
createdAt: Date;
createdTime: number;
};
type LoaderData = {
cachedValues: CachedValue[];
allTenants: { id: string; name: string; slug: string }[];
allUsers: UserWithNames[];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await verifyUserHasPermission(request, "admin.settings.general.update");
const cachedValues: CachedValue[] = [];
for (const key of cache.keys()) {
if (cachedValues.find((x) => x.key === key)) {
continue;
}
const value = cache.get(key);
if (!value) {
continue;
}
const sizeBytes = new TextEncoder().encode(JSON.stringify(value)).length;
const sizeMb = sizeBytes / 1024 / 1024;
const createdTime = value.metadata.createdTime;
const createdAt = new Date(createdTime);
const cachedValue = { key, value: value.value, sizeMb, createdAt, createdTime };
cachedValues.push(cachedValue);
}
const allTenants = await adminGetAllTenantsIdsAndNames();
const allUsers = await adminGetAllUsersNames();
const data: LoaderData = {
cachedValues,
allTenants,
allUsers,
};
return data;
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
await verifyUserHasPermission(request, "admin.settings.general.update");
await validateCSRFToken(request);
const { t, locale } = await getTranslations(request);
const formatters = createLocaleFormatters(locale);
const form = await request.formData();
const action = form.get("action")?.toString() ?? "";
if (action === "delete-key") {
const key = form.get("key")?.toString() ?? "";
cache.delete(key);
return Response.json({ success: t("settings.admin.cache.keyDeleted") });
} else if (action === "delete-all") {
let keyCount = cache.size;
cache.clear();
return Response.json({ success: t("settings.admin.cache.cacheCleared", { count: keyCount, formattedCount: formatters.number(keyCount) }) });
} else if (action === "delete-keys") {
const keys = form.get("keys")?.toString() ?? "";
const keysArray = keys.split(",");
let keyCount = 0;
for (const key of keysArray) {
cache.delete(key);
keyCount++;
}
return Response.json({ success: t("settings.admin.cache.keysDeleted", { count: keyCount, formattedCount: formatters.number(keyCount) }) });
}
return Response.json({ error: t("settings.admin.cache.invalidAction") }, { status: 400 });
};
export default function AdminCacheRoute() {
const { t } = useTranslation();
const formatters = useLocaleFormatters();
const data = useLoaderData<LoaderData>();
const actionData = useActionData<{ success?: string; error?: string }>();
const submit = useSubmit();
const navigation = useNavigation();
const location = useLocation();
const [searchParams] = useSearchParams();
const [searchInput, setSearchInput] = useState("");
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [selectedTenants, setSelectedTenants] = useState<string[]>([]);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [keyGroups, setKeyGroups] = useState<{ value: string; count: number }[]>([]);
useEffect(() => {
const keyGroups: { [key: string]: number } = {};
for (const key of data.cachedValues.map((f) => f.key)) {
const keyGroup = key.split(":")[0];
if (!keyGroups[keyGroup]) {
keyGroups[keyGroup] = 0;
}
keyGroups[keyGroup]++;
}
const keyGroupsArray = Object.keys(keyGroups).map((key) => ({ value: key, count: keyGroups[key] }));
setKeyGroups(keyGroupsArray);
}, [data.cachedValues]);
useEffect(() => {
if (actionData?.success) {
toast.success(actionData.success);
} else if (actionData?.error) {
toast.error(actionData.error);
}
}, [actionData]);
const filteredItems = () => {
let items = data.cachedValues;
if (selectedKeys.length) {
items = items.filter((item) => selectedKeys.includes(item.key.split(":")[0]));
}
if (selectedTenants.length) {
items = items.filter((item) => {
const tenantIds = selectedTenants;
for (const tenantId of tenantIds) {
const tenant = data.allTenants.find((f) => f.id === tenantId);
if (!tenant) {
return false;
}
const { id, slug } = tenant;
if (item.key.includes(":" + id) || item.key.includes(":" + slug)) {
return true;
}
}
return false;
});
}
if (selectedUsers.length) {
items = items.filter((item) => {
const userIds = selectedUsers;
for (const userId of userIds) {
const user = data.allUsers.find((f) => f.id === userId);
if (!user) {
return false;
}
const { id } = user;
if (item.key.includes(":" + id)) {
return true;
}
}
return false;
});
}
if (searchInput) {
items = items.filter(
(item) =>
item.key.toLowerCase().includes(searchInput.toLowerCase()) ||
JSON.stringify(item.value ?? "")
?.toLowerCase()
.includes(searchInput.toLowerCase())
);
}
return items;
};
const sortedItems = () => {
const items = filteredItems();
const sortBy = searchParams.get("sort") ?? "-createdTime";
const sortAsc = sortBy.startsWith("-");
const sortKey = sortBy.replace("-", "");
const sorted = items.sort((a, b) => {
if (sortKey === "key") {
return sortAsc ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key);
} else if (sortKey === "sizeMb") {
return sortAsc ? a.sizeMb - b.sizeMb : b.sizeMb - a.sizeMb;
} else if (sortKey === "createdTime") {
return sortAsc ? a.createdTime - b.createdTime : b.createdTime - a.createdTime;
}
return 0;
});
return sorted;
};
const availableUsers = () => {
let users = data.allUsers.filter((f) => {
if (!data.cachedValues.find((item) => item.key.includes(":" + f.id))) {
return false;
}
return true;
});
return users;
};
const availableTenants = () => {
let tenants = data.allTenants.filter((f) => {
if (!data.cachedValues.find((item) => item.key.includes(":" + f.id) || item.key.includes(":" + f.slug))) {
return false;
}
return true;
});
return tenants;
};
function onDeleteAll() {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete-all");
submit(form, {
method: "post",
});
}
function onDelete(key: string) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete-key");
form.set("key", key);
submit(form, {
method: "post",
});
}
function onDeleteSelected(keys: string[]) {
const form = new FormData();
appendCSRFToken(form);
form.set("action", "delete-keys");
form.set("keys", keys.join(","));
submit(form, {
method: "post",
});
}
function getValue(item: CachedValue) {
try {
// if is array, count
if (Array.isArray(item.value)) {
return t("settings.admin.cache.valuePreview.arrayItems", { count: item.value.length });
} else if (typeof item.value === "object") {
return t("settings.admin.cache.valuePreview.objectKeys", { count: Object.keys(item.value).length });
}
} catch {}
return item.value;
}
return (
<EditPageLayout
title={
<BackButtonWithTitle href={adminPath("settings", { mode: "host-aware" })}>
{t("settings.admin.cache.summary", {
keys: formatters.number(data.cachedValues.length),
size: formatters.number(data.cachedValues.reduce((a, b) => a + b.sizeMb, 0), {
style: "unit",
unit: "megabyte",
unitDisplay: "short",
maximumFractionDigits: 2,
}),
})}
</BackButtonWithTitle>
}
buttons={
<>
<div className="flex justify-between space-x-2">
<InputSearch size="sm" className="w-full grow" value={searchInput} setValue={setSearchInput} />
<Button size="sm" asChild type="button" variant="secondary" isLoading={navigation.state !== "idle"}>
<Link to={location.pathname + location.search}>
<LucideRefreshCcw className="size-3" />
</Link>
</Button>
</div>
</>
}
>
<div className="space-y-3">
<div className="flex items-start space-x-4">
<div className="w-52 shrink-0 space-y-2 p-1">
<InputCombobox
title={t("settings.admin.cache.filters.group")}
name="keyGroupBy"
// prefix="Key group"
selectPlaceholder={t("settings.admin.cache.filters.selectGroup")}
options={keyGroups.map((f) => ({ name: `${f.value} (${formatters.number(f.count)})`, value: f.value }))}
value={selectedKeys}
onChange={(value) => {
setSelectedKeys(value as string[]);
}}
onClear={() => setSelectedKeys([])}
disabled={keyGroups.length === 0}
minDisplayCount={2}
className="bg-background"
/>
<InputCombobox
title={t("settings.admin.cache.filters.tenant")}
name="tenants"
// prefix="Tenant"
selectPlaceholder={t("settings.admin.cache.filters.selectTenant")}
options={availableTenants().map((f) => ({ name: f.name + " (" + f.slug + ")", value: f.id }))}
value={selectedTenants}
onChange={(value) => {
setSelectedTenants(value as string[]);
}}
onClear={() => setSelectedTenants([])}
disabled={availableTenants().length === 0}
minDisplayCount={1}
className="bg-background"
/>
<InputCombobox
title={t("settings.admin.cache.filters.user")}
name="users"
// prefix="User"
selectPlaceholder={t("settings.admin.cache.filters.selectUser")}
options={availableUsers().map((f) => ({ name: f.email, value: f.id }))}
value={selectedUsers}
onChange={(value) => {
setSelectedUsers(value as string[]);
}}
onClear={() => setSelectedUsers([])}
disabled={availableUsers().length === 0}
minDisplayCount={1}
className="bg-background"
/>
<div className="border-border border-t pt-2">
<DeleteButton items={sortedItems()} searchInput={searchInput} onDeleteAll={onDeleteAll} onDeleteSelected={onDeleteSelected} />
</div>
</div>
<div className="grow space-y-2 overflow-auto p-1">
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium">{t("settings.admin.cache.cachedValues")}</label>
<TableSimple
items={sortedItems()}
noRecords={
<div className="flex flex-col space-y-1 py-4 text-sm">
<div>{t("shared.noRecords")}</div>
{searchInput && (
<div>
<button type="button" className="text-blue-500 underline" onClick={() => setSearchInput("")}>
{t("settings.admin.cache.clearSearch")}
</button>
</div>
)}
</div>
}
headers={[
{
name: "key",
title: t("settings.admin.cache.columns.key"),
value: (item) => <div className="max-w-xs truncate">{item.key}</div>,
sortBy: "key",
sortable: true,
},
{
name: "type",
title: t("settings.admin.cache.columns.type"),
value: (item) => (Array.isArray(item.value) ? "array" : typeof item.value),
},
{
name: "value",
title: t("settings.admin.cache.columns.value"),
value: (item) => (
<div className="flex justify-end">
<ShowPayloadModalButton description={getValue(item)} payload={item.value} />
</div>
),
},
{
name: "size",
title: t("settings.admin.cache.columns.size"),
value: (item) => (
<div>
{formatters.number(item.sizeMb, {
style: "unit",
unit: "megabyte",
unitDisplay: "short",
maximumFractionDigits: 4,
})}
</div>
),
sortBy: "sizeMb",
},
{
name: "createdAt",
title: t("shared.createdAt"),
value: (item) => item.createdAt,
formattedValue: (item) => <DateCell date={item.createdAt} displays={["ymdhmsms"]} />,
sortBy: "createdTime",
},
]}
actions={[
{
title: t("shared.delete"),
onClick: (_, item) => onDelete(item.key),
disabled: (item) => navigation.formData?.get("action") === "delete-key" && navigation.formData?.get("key") === item.key,
},
]}
/>
</div>
</div>
</div>
</div>
</EditPageLayout>
);
}
function DeleteButton({
items,
searchInput,
onDeleteAll,
onDeleteSelected,
}: {
items: CachedValue[];
searchInput: string;
onDeleteAll: () => void;
onDeleteSelected: (keys: string[]) => void;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const formatters = useLocaleFormatters();
const formattedCount = formatters.number(items.length);
return (
<Fragment>
{searchInput ? (
<ButtonSecondary
className="w-full"
disabled={items.length === 0}
destructive
onClick={() => onDeleteSelected(items.map((f) => f.key))}
isLoading={navigation.formData?.get("action") === "delete-selected"}
>
{t("settings.admin.cache.clearKeys", { count: items.length, formattedCount })}
</ButtonSecondary>
) : (
<ButtonSecondary
disabled={items.length === 0}
className="w-full"
destructive
onClick={() => onDeleteAll()}
isLoading={navigation.formData?.get("action") === "delete-all"}
>
{t("settings.admin.cache.clearKeys", { count: items.length, formattedCount })}
</ButtonSecondary>
)}
</Fragment>
);
}