460 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|