diff --git a/apps/webpanel/src/pages/resources/index.tsx b/apps/webpanel/src/pages/resources/index.tsx
index 550c42e9..231054e2 100644
--- a/apps/webpanel/src/pages/resources/index.tsx
+++ b/apps/webpanel/src/pages/resources/index.tsx
@@ -15,6 +15,11 @@ import {
SelectValue,
} from '@fxmanager/ui/components/select';
import { Skeleton } from '@fxmanager/ui/components/skeleton';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@fxmanager/ui/components/collapsible';
import { cn } from '@fxmanager/ui/lib/utils';
import {
LayoutList,
@@ -25,27 +30,261 @@ import {
AlertCircle,
RefreshCcwIcon,
FileQuestionMark,
+ Plus,
+ Minus,
+ ChevronsUpDown,
+ FolderIcon,
} from 'lucide-react';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@fxmanager/ui/components/tooltip';
type FilterValue = 'all' | 'started' | 'stopped';
-function formatPath(path: string): string {
- const normalized = path.replace(/\\/g, '/');
+type ResourceGroupType = {
+ resources: ResourceData[];
+ [key: string]: ResourceData[] | ResourceGroupType;
+};
- const parts = normalized.split(/\/resources\/+/i);
- if (parts.length > 1) {
- return parts[parts.length - 1]!;
- }
+const REGEX_RESOURCE_GROUPS = /\[([^\]]+)\]/g;
+
+const STATE_CONFIG = {
+ stopped: {
+ icon: Square,
+ title: 'Server Stopped',
+ description:
+ 'The server is currently offline. Start it to view and manage resources.',
+ styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400',
+ titleStyle: 'text-zinc-900 dark:text-zinc-200',
+ },
+ crashed: {
+ icon: AlertCircle,
+ title: 'Server Crashed',
+ description:
+ 'The server process terminated unexpectedly. Check logs for more details.',
+ styles: 'bg-red-100 dark:bg-red-500/10 text-red-600 dark:text-red-500',
+ titleStyle: 'text-red-600 dark:text-red-500',
+ },
+ no_resources: {
+ icon: FileQuestionMark,
+ title: 'No Resources',
+ description: 'No resources were found on the server.',
+ styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400',
+ titleStyle: 'text-zinc-900 dark:text-zinc-200',
+ },
+} as const;
- return normalized;
+function formatVersion(version: string) {
+ if (version.startsWith('v')) return version;
+ return `v${version}`;
+}
+
+function ResourceCard({
+ resource,
+ handleAction,
+}: {
+ resource: ResourceData;
+ handleAction: (
+ action: 'start' | 'stop' | 'refresh',
+ resource?: string | undefined,
+ ) => void;
+}) {
+ const isStarted = resource.status === 'started';
+
+ return (
+
+
+
+
+
+
+
+ {resource.name}
+
+ {resource.version && (
+
+ {formatVersion(resource.version)}
+
+ )}
+ {resource.author && (
+
+ {resource.author}
+
+ )}
+
+
+ {resource.description && (
+
+ {resource.description}
+
+ )}
+
+
+
+
+
+ {isStarted ? (
+
+ ) : (
+
+ )}
+ {resource.status}
+
+
+
+ {isStarted ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
+
+function ResourceGroup({
+ name,
+ node,
+ rootElement,
+ handleAction,
+ globalExpand,
+}: {
+ name: string;
+ node: ResourceGroupType;
+ rootElement?: boolean;
+ handleAction: (
+ action: 'start' | 'stop' | 'refresh',
+ resource?: string | undefined,
+ ) => void;
+ globalExpand: boolean | null;
+}) {
+ const [collapsed, setCollapsed] = useState(false);
+
+ // Synchronize with global expand/collapse toggle changes
+ useEffect(() => {
+ if (globalExpand !== null) {
+ setCollapsed(!globalExpand);
+ }
+ }, [globalExpand]);
+
+ const { resources, ...subcategories } = node;
+ const totalResources = resources?.length || 0;
+
+ return (
+
+
setCollapsed(!open)}
+ >
+
+
+
+
+
+
+ {resources && resources.length > 0 && (
+
+ {[...resources]
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((res) => (
+
+ ))}
+
+ )}
+
+ {resources && resources.length === 0 && !rootElement && (
+
+ No resources in this group
+
+ )}
+
+ {Object.entries(subcategories).map(([subName, subNode]) => (
+
+ ))}
+
+
+
+
+ );
}
function ResourceCardSkeleton() {
const skeletonBg = 'bg-zinc-300 dark:bg-zinc-800/80';
return (
-
+
@@ -87,32 +326,6 @@ function LoadingSkeleton({ blur }: { blur?: boolean }) {
);
}
-const STATE_CONFIG = {
- stopped: {
- icon: Square,
- title: 'Server Stopped',
- description:
- 'The server is currently offline. Start it to view and manage resources.',
- styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400',
- titleStyle: 'text-zinc-900 dark:text-zinc-200',
- },
- crashed: {
- icon: AlertCircle,
- title: 'Server Crashed',
- description:
- 'The server process terminated unexpectedly. Check logs for more details.',
- styles: 'bg-red-100 dark:bg-red-500/10 text-red-600 dark:text-red-500',
- titleStyle: 'text-red-600 dark:text-red-500',
- },
- no_resources: {
- icon: FileQuestionMark,
- title: 'No Resources',
- description: 'No resources were found on the server.',
- styles: 'bg-zinc-200 dark:bg-zinc-700/40 text-zinc-600 dark:text-zinc-400',
- titleStyle: 'text-zinc-900 dark:text-zinc-200',
- },
-} as const;
-
function StateDisplay({ type }: { type: keyof typeof STATE_CONFIG }) {
const config = STATE_CONFIG[type];
const Icon = config.icon;
@@ -147,6 +360,7 @@ export function ResourceList() {
const [searchValue, setSearchValue] = useState
('');
const [filterValue, setFilterValue] = useState('all');
const [refreshing, setRefresh] = useState(false);
+ const [globalExpand, setGlobalExpand] = useState(null);
useEffect(() => {
const filtered = resources.filter((res) => {
@@ -167,11 +381,6 @@ export function ResourceList() {
setSearchValue(value);
}
- function formatVersion(version: string) {
- if (version.startsWith('v')) return version;
- return `v${version}`;
- }
-
async function handleAction(
action: 'start' | 'stop' | 'refresh',
resource?: string,
@@ -212,6 +421,38 @@ export function ResourceList() {
}
}
+ const resourceGroups = useMemo(() => {
+ return displayedResources.reduce(
+ (acc, cur) => {
+ const groups = [...cur.path.matchAll(REGEX_RESOURCE_GROUPS)]
+ .map((match) => match[1])
+ .filter((i) => typeof i === 'string');
+
+ let location;
+ if (groups.length > 0 && groups[0] !== undefined) {
+ location = acc.other;
+ for (const grp of groups) {
+ if (!Object.hasOwn(location, grp)) {
+ location[grp] = { resources: [] };
+ }
+ location = location[grp];
+ }
+ location = location.resources;
+ } else {
+ location = acc.root;
+ }
+
+ location.push(cur);
+
+ return acc;
+ },
+ {
+ root: [] as ResourceData[],
+ other: {} as Record,
+ },
+ );
+ }, [displayedResources]);
+
return (
@@ -255,9 +496,9 @@ export function ResourceList() {
-
+
{state.status === 'running' && (
-
+
Total
@@ -287,6 +528,25 @@ export function ResourceList() {
)}
+
+
+
+
+
+ Collapse / Expand all resource folders
+
+
+
) : state.status === 'running' ? (
-
- {displayedResources.length > 0 ? (
- displayedResources.map((res) => (
-
-
-
-
-
-
- {res.name}
-
- {res.version && (
-
- {formatVersion(res.version)}
-
- )}
-
-
-
- {res.description ||
- 'No description provided for this resource.'}
-
-
-
- {res.author && (
-
- {res.author}
-
- )}
-
- {formatPath(res.path)}
-
-
-
-
-
-
- {res.status === 'started' ? (
-
- ) : (
-
- )}
- {res.status}
-
-
-
- {res.status === 'stopped' ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- ))
- ) : (
-
+
+ {resourceGroups.root.length > 0 && (
+
)}
+ {Object.entries(resourceGroups.other).map(([name, node]) => (
+
+ ))}
) : (