mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-05 19:51:59 +02:00
Merge pull request #28 from Tyrthurey/fixes-v3
Fixed the dev environment + A bunch of improvements and fixes
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"cronstrue": "^3.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"debounce": "^2.2.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -113,6 +113,9 @@ importers:
|
||||
copy-to-clipboard:
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
cronstrue:
|
||||
specifier: ^3.9.0
|
||||
version: 3.9.0
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
@@ -2280,6 +2283,10 @@ packages:
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cronstrue@3.9.0:
|
||||
resolution: {integrity: sha512-T3S35zmD0Ai2B4ko6+mEM+k9C6tipe2nB9RLiGT6QL2Wn0Vsn2cCZAC8Oeuf4CaE00GZWVdpYitbpWCNlIWqdA==}
|
||||
hasBin: true
|
||||
|
||||
cross-env@10.0.0:
|
||||
resolution: {integrity: sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6098,6 +6105,8 @@ snapshots:
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cronstrue@3.9.0: {}
|
||||
|
||||
cross-env@10.0.0:
|
||||
dependencies:
|
||||
'@epic-web/invariant': 1.0.0
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import {
|
||||
AbbrApi,
|
||||
Box,
|
||||
BranchesDown,
|
||||
Clock,
|
||||
ClockArrowRotateLeft,
|
||||
CloudArrowUpIn,
|
||||
Database,
|
||||
Ellipsis,
|
||||
FolderOpen,
|
||||
Gear,
|
||||
House,
|
||||
Key,
|
||||
PencilToLine,
|
||||
Persons,
|
||||
Terminal,
|
||||
Xmark,
|
||||
} from '@gravity-ui/icons';
|
||||
import React from 'react';
|
||||
import { AbbrApi, Gear, House, Key, Xmark } from '@gravity-ui/icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import type { FeatureLimitKey, ServerRouteDefinition } from '@/routers/routes';
|
||||
import { getServerNavRoutes } from '@/routers/routes';
|
||||
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
import { getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface MobileFullScreenMenuProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -52,182 +42,150 @@ const MobileFullScreenMenu = ({ isVisible, onClose, children }: MobileFullScreen
|
||||
);
|
||||
};
|
||||
|
||||
interface NavigationItemProps {
|
||||
to: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
children: React.ReactNode;
|
||||
end?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NavigationItem = ({ to, icon: Icon, children, end = false, onClick }: NavigationItemProps) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-4 p-4 rounded-md transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/20 to-brand/10 border-l-4 border-brand text-white'
|
||||
: 'text-white/80 hover:text-white hover:bg-[#ffffff11] border-l-4 border-transparent'
|
||||
}`
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>
|
||||
<Icon width={22} height={22} fill='currentColor' />
|
||||
</div>
|
||||
<span className='text-lg font-medium'>{children}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
interface DashboardMobileMenuProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DashboardMobileMenu = ({ isVisible, onClose }: DashboardMobileMenuProps) => {
|
||||
const NavigationItem = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
children,
|
||||
end = false,
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ComponentType<{ width: number; height: number; fill: string }>;
|
||||
children: React.ReactNode;
|
||||
end?: boolean;
|
||||
}) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-4 p-4 rounded-md transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/20 to-brand/10 border-l-4 border-brand text-white'
|
||||
: 'text-white/80 hover:text-white hover:bg-[#ffffff11] border-l-4 border-transparent'
|
||||
}`
|
||||
}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div>
|
||||
<Icon width={22} height={22} fill='currentColor' />
|
||||
</div>
|
||||
<span className='text-lg font-medium'>{children}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<MobileFullScreenMenu isVisible={isVisible} onClose={onClose}>
|
||||
<NavigationItem to='/' icon={House} end>
|
||||
<NavigationItem to='/' icon={House} end onClick={onClose}>
|
||||
Servers
|
||||
</NavigationItem>
|
||||
<NavigationItem to='/account/api' icon={AbbrApi} end>
|
||||
<NavigationItem to='/account/api' icon={AbbrApi} end onClick={onClose}>
|
||||
API Keys
|
||||
</NavigationItem>
|
||||
<NavigationItem to='/account/ssh' icon={Key} end>
|
||||
<NavigationItem to='/account/ssh' icon={Key} end onClick={onClose}>
|
||||
SSH Keys
|
||||
</NavigationItem>
|
||||
<NavigationItem to='/account' icon={Gear} end>
|
||||
<NavigationItem to='/account' icon={Gear} end onClick={onClose}>
|
||||
Settings
|
||||
</NavigationItem>
|
||||
</MobileFullScreenMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerMobileNavItemProps {
|
||||
route: ServerRouteDefinition;
|
||||
serverId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile navigation item that handles permission and feature limit checks.
|
||||
*/
|
||||
const ServerMobileNavItem = ({ route, serverId, onClose }: ServerMobileNavItemProps) => {
|
||||
const { icon: Icon, name, path, permission, featureLimit, end } = route;
|
||||
|
||||
// Feature limits from server state
|
||||
const featureLimits = ServerContext.useStoreState((state) => state.server.data?.featureLimits);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
|
||||
// State for subdomain support check (only for network route)
|
||||
const [subdomainSupported, setSubdomainSupported] = useState(false);
|
||||
|
||||
// Check subdomain support for network feature
|
||||
useEffect(() => {
|
||||
if (featureLimit !== 'network' || !uuid) return;
|
||||
|
||||
const checkSubdomainSupport = async () => {
|
||||
try {
|
||||
const data = await getSubdomainInfo(uuid);
|
||||
setSubdomainSupported(data.supported);
|
||||
} catch {
|
||||
setSubdomainSupported(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubdomainSupport();
|
||||
}, [featureLimit, uuid]);
|
||||
|
||||
// Check if the item should be visible based on feature limits
|
||||
const isVisible = (): boolean => {
|
||||
if (!featureLimit) return true;
|
||||
|
||||
if (featureLimit === 'network') {
|
||||
const allocationLimit = featureLimits?.allocations ?? 0;
|
||||
return allocationLimit > 0 || subdomainSupported;
|
||||
}
|
||||
|
||||
const limitValue = featureLimits?.[featureLimit as FeatureLimitKey] ?? 0;
|
||||
return limitValue !== 0;
|
||||
};
|
||||
|
||||
if (!isVisible() || !Icon || !name) return null;
|
||||
|
||||
const to = path ? `/server/${serverId}/${path}` : `/server/${serverId}`;
|
||||
|
||||
const NavContent = (
|
||||
<NavigationItem to={to} icon={Icon} end={end} onClick={onClose}>
|
||||
{name}
|
||||
</NavigationItem>
|
||||
);
|
||||
|
||||
if (permission === null || permission === undefined) {
|
||||
return NavContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Can action={permission} matchAny>
|
||||
{NavContent}
|
||||
</Can>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerMobileMenuProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
serverId?: string;
|
||||
// These props are kept for backwards compatibility but are no longer used
|
||||
// The component now reads feature limits directly from ServerContext
|
||||
databaseLimit?: number | null;
|
||||
backupLimit?: number | null;
|
||||
allocationLimit?: number | null;
|
||||
subdomainSupported?: boolean;
|
||||
}
|
||||
|
||||
export const ServerMobileMenu = ({
|
||||
isVisible,
|
||||
onClose,
|
||||
serverId,
|
||||
databaseLimit,
|
||||
backupLimit,
|
||||
allocationLimit,
|
||||
subdomainSupported = false,
|
||||
}: ServerMobileMenuProps) => {
|
||||
const NavigationItem = ({
|
||||
to,
|
||||
icon: Icon,
|
||||
children,
|
||||
end = false,
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ComponentType<{ width: number; height: number; fill: string }>;
|
||||
children: React.ReactNode;
|
||||
end?: boolean;
|
||||
}) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-4 p-4 rounded-md transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/20 to-brand/10 border-l-4 border-brand text-white'
|
||||
: 'text-white/80 hover:text-white hover:bg-[#ffffff11] border-l-4 border-transparent'
|
||||
}`
|
||||
}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon width={22} height={22} fill='currentColor' />
|
||||
<span className='text-lg font-medium'>{children}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export const ServerMobileMenu = ({ isVisible, onClose, serverId }: ServerMobileMenuProps) => {
|
||||
if (!serverId) return null;
|
||||
|
||||
// Get navigation routes from centralized config
|
||||
const navRoutes = getServerNavRoutes();
|
||||
|
||||
return (
|
||||
<MobileFullScreenMenu isVisible={isVisible} onClose={onClose}>
|
||||
<NavigationItem to={`/server/${serverId}`} icon={House} end>
|
||||
Home
|
||||
</NavigationItem>
|
||||
|
||||
<>
|
||||
<Can action={'file.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/files`} icon={FolderOpen}>
|
||||
Files
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
{databaseLimit !== 0 && (
|
||||
<Can action={'database.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/databases`} icon={Database} end>
|
||||
Databases
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
{backupLimit !== 0 && (
|
||||
<Can action={'backup.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/backups`} icon={CloudArrowUpIn} end>
|
||||
Backups
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
{(allocationLimit > 0 || subdomainSupported) && (
|
||||
<Can action={'allocation.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/network`} icon={BranchesDown} end>
|
||||
Networking
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
)}
|
||||
|
||||
<Can action={'user.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/users`} icon={Persons} end>
|
||||
Users
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={['startup.read', 'startup.update', 'startup.command', 'startup.docker-image']} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/startup`} icon={Terminal} end>
|
||||
Startup
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={'schedule.*'} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/schedules`} icon={Clock}>
|
||||
Schedules
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={['settings.*', 'file.sftp']} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/settings`} icon={Gear} end>
|
||||
Settings
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
|
||||
<Can action={['activity.*', 'activity.read']} matchAny>
|
||||
<NavigationItem to={`/server/${serverId}/activity`} icon={PencilToLine} end>
|
||||
Activity
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
</>
|
||||
|
||||
<Can action={'startup.software'}>
|
||||
<NavigationItem to={`/server/${serverId}/shell`} icon={Box} end>
|
||||
Software
|
||||
</NavigationItem>
|
||||
</Can>
|
||||
{navRoutes.map((route) => (
|
||||
<ServerMobileNavItem key={route.path || 'home'} route={route} serverId={serverId} onClose={onClose} />
|
||||
))}
|
||||
</MobileFullScreenMenu>
|
||||
);
|
||||
};
|
||||
|
||||
101
resources/scripts/components/server/ServerSidebarNavItem.tsx
Normal file
101
resources/scripts/components/server/ServerSidebarNavItem.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import type { FeatureLimitKey, ServerRouteDefinition } from '@/routers/routes';
|
||||
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
import { getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface ServerSidebarNavItemProps {
|
||||
route: ServerRouteDefinition;
|
||||
serverId: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dynamic sidebar navigation item that handles:
|
||||
* - Permission checking via Can component
|
||||
* - Feature limit visibility (databases, backups, allocations)
|
||||
* - Network feature with subdomain support check
|
||||
*/
|
||||
const ServerSidebarNavItem = forwardRef<HTMLAnchorElement, ServerSidebarNavItemProps>(
|
||||
({ route, serverId, onClick }, ref) => {
|
||||
const { icon: Icon, name, path, permission, featureLimit, end } = route;
|
||||
|
||||
// Feature limits from server state
|
||||
const featureLimits = ServerContext.useStoreState((state) => state.server.data?.featureLimits);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
|
||||
// State for subdomain support check (only for network route)
|
||||
const [subdomainSupported, setSubdomainSupported] = useState(false);
|
||||
|
||||
// Check subdomain support for network feature
|
||||
useEffect(() => {
|
||||
if (featureLimit !== 'network' || !uuid) return;
|
||||
|
||||
const checkSubdomainSupport = async () => {
|
||||
try {
|
||||
const data = await getSubdomainInfo(uuid);
|
||||
setSubdomainSupported(data.supported);
|
||||
} catch {
|
||||
setSubdomainSupported(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubdomainSupport();
|
||||
}, [featureLimit, uuid]);
|
||||
|
||||
// Check if the item should be visible based on feature limits
|
||||
const isVisible = (): boolean => {
|
||||
if (!featureLimit) return true;
|
||||
|
||||
if (featureLimit === 'network') {
|
||||
// Network is visible if allocations > 0 OR subdomain is supported
|
||||
const allocationLimit = featureLimits?.allocations ?? 0;
|
||||
return allocationLimit > 0 || subdomainSupported;
|
||||
}
|
||||
|
||||
// For other feature limits (databases, backups, allocations)
|
||||
const limitValue = featureLimits?.[featureLimit as FeatureLimitKey] ?? 0;
|
||||
return limitValue !== 0;
|
||||
};
|
||||
|
||||
// Don't render if feature limit hides this item
|
||||
if (!isVisible()) return null;
|
||||
|
||||
// Build the navigation link
|
||||
const to = path ? `/server/${serverId}/${path}` : `/server/${serverId}`;
|
||||
|
||||
const NavContent = (
|
||||
<NavLink
|
||||
ref={ref}
|
||||
to={to}
|
||||
end={end}
|
||||
onClick={onClick}
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
>
|
||||
{Icon && <Icon className='ml-3' width={22} height={22} fill='currentColor' />}
|
||||
<p>{name}</p>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
// If permission is null or undefined, render without permission check
|
||||
if (permission === null || permission === undefined) {
|
||||
return NavContent;
|
||||
}
|
||||
|
||||
// Wrap with permission check
|
||||
return (
|
||||
<Can action={permission} matchAny>
|
||||
{NavContent}
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ServerSidebarNavItem.displayName = 'ServerSidebarNavItem';
|
||||
|
||||
export default ServerSidebarNavItem;
|
||||
@@ -128,6 +128,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
const BackupContainer = () => {
|
||||
const { page, setPage } = useContext(ServerBackupContext);
|
||||
const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
|
||||
const liveProgress = useContext(LiveProgressContext);
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
const [deleteAllModalVisible, setDeleteAllModalVisible] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -151,6 +152,9 @@ const BackupContainer = () => {
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
|
||||
const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb);
|
||||
|
||||
// Check if any backup operation is in progress
|
||||
const hasActiveOperation = Object.values(liveProgress).some((op) => !op.completed);
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('backups:create');
|
||||
}, [createModalVisible]);
|
||||
@@ -389,7 +393,11 @@ const BackupContainer = () => {
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
{backupCount > 0 && (
|
||||
<ActionButton variant='danger' onClick={() => setDeleteAllModalVisible(true)}>
|
||||
<ActionButton
|
||||
variant='danger'
|
||||
onClick={() => setDeleteAllModalVisible(true)}
|
||||
disabled={hasActiveOperation}
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4 mr-2'
|
||||
fill='none'
|
||||
@@ -408,7 +416,11 @@ const BackupContainer = () => {
|
||||
)}
|
||||
{(backupLimit === null || backupLimit > backupCount) &&
|
||||
(!backupStorageLimit || !storage?.is_over_limit) && (
|
||||
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
disabled={hasActiveOperation}
|
||||
>
|
||||
New Backup
|
||||
</ActionButton>
|
||||
)}
|
||||
@@ -419,7 +431,8 @@ const BackupContainer = () => {
|
||||
>
|
||||
<p className='text-sm text-neutral-400 leading-relaxed'>
|
||||
Create and manage server backups to protect your data. Schedule automated backups, download existing
|
||||
ones, and restore when needed.
|
||||
ones, and restore when needed. Backups are deduplicated, meaning unchanged files are only stored
|
||||
once across all backups
|
||||
</p>
|
||||
</MainPageHeader>
|
||||
|
||||
@@ -814,6 +827,7 @@ const BackupContainerWrapper = () => {
|
||||
if (isDeletionOperation) {
|
||||
// Optimistically remove the deleted backup from SWR cache immediately
|
||||
// note: this is incredibly buggy sometimes, somebody please refactor how "live" backups work. - ellie
|
||||
// Changed this to use "revalidate: false" so the optimistic update persists - tyr
|
||||
mutate(
|
||||
(currentData) => {
|
||||
if (!currentData) return currentData;
|
||||
@@ -823,17 +837,15 @@ const BackupContainerWrapper = () => {
|
||||
backupCount: Math.max(0, (currentData.backupCount || 0) - 1),
|
||||
};
|
||||
},
|
||||
{ revalidate: true },
|
||||
{ revalidate: false },
|
||||
);
|
||||
|
||||
// Remove from live progress
|
||||
setTimeout(() => {
|
||||
setLiveProgress((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
}, 500);
|
||||
// Remove from live progress immediately
|
||||
setLiveProgress((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[backup_uuid];
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
// For new backups, wait for them to appear in the API
|
||||
mutate();
|
||||
|
||||
@@ -78,14 +78,12 @@ const NetworkContainer = () => {
|
||||
<div className='flex items-center gap-4'>
|
||||
{allocationLimit === null && (
|
||||
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
|
||||
{data.filter((allocation) => !allocation.isDefault).length} allocations
|
||||
(unlimited)
|
||||
{data.length} allocations (unlimited)
|
||||
</span>
|
||||
)}
|
||||
{allocationLimit > 0 && (
|
||||
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
|
||||
{data.filter((allocation) => !allocation.isDefault).length} of{' '}
|
||||
{allocationLimit}
|
||||
{data.length} of {allocationLimit}
|
||||
</span>
|
||||
)}
|
||||
{allocationLimit === 0 && (
|
||||
@@ -94,9 +92,7 @@ const NetworkContainer = () => {
|
||||
</span>
|
||||
)}
|
||||
{(allocationLimit === null ||
|
||||
(allocationLimit > 0 &&
|
||||
allocationLimit >
|
||||
data.filter((allocation) => !allocation.isDefault).length)) && (
|
||||
(allocationLimit > 0 && allocationLimit > data.length)) && (
|
||||
<ActionButton variant='primary' onClick={onCreateAllocation} size='sm'>
|
||||
New Allocation
|
||||
</ActionButton>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { Link, TriangleExclamation } from '@gravity-ui/icons';
|
||||
import { toString } from 'cronstrue';
|
||||
import { format } from 'date-fns';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
@@ -101,6 +102,35 @@ const formatTimezoneDisplay = (timezone: string, offset: string) => {
|
||||
return `${timezone} (${offset})`;
|
||||
};
|
||||
|
||||
const getCronDescription = (
|
||||
minute: string,
|
||||
hour: string,
|
||||
dayOfMonth: string,
|
||||
month: string,
|
||||
dayOfWeek: string,
|
||||
): string => {
|
||||
try {
|
||||
// Build cron expression: minute hour dayOfMonth month dayOfWeek
|
||||
const cronExpression = `${minute} ${hour} ${dayOfMonth} ${month} ${dayOfWeek}`;
|
||||
const description = toString(cronExpression, {
|
||||
throwExceptionOnParseError: false,
|
||||
verbose: true,
|
||||
});
|
||||
|
||||
// Check if cronstrue returned an error message
|
||||
if (
|
||||
description ===
|
||||
'An error occurred when generating the expression description. Check the cron expression syntax.'
|
||||
) {
|
||||
return 'Invalid cron expression';
|
||||
}
|
||||
|
||||
return description;
|
||||
} catch {
|
||||
return 'Invalid cron expression.';
|
||||
}
|
||||
};
|
||||
|
||||
const EditScheduleModal = ({ schedule }: Props) => {
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
@@ -167,116 +197,130 @@ const EditScheduleModal = ({ schedule }: Props) => {
|
||||
} as Values
|
||||
}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'schedule:edit'} />
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Schedule name'}
|
||||
description={'A human readable identifier for this schedule.'}
|
||||
/>
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
|
||||
<Field name={'minute'} label={'Minute'} />
|
||||
<Field name={'hour'} label={'Hour'} />
|
||||
<Field name={'dayOfWeek'} label={'Day of week'} />
|
||||
<Field name={'dayOfMonth'} label={'Day of month'} />
|
||||
<Field name={'month'} label={'Month'} />
|
||||
</div>
|
||||
{({ isSubmitting, values }) => {
|
||||
const cronDescription = getCronDescription(
|
||||
values.minute,
|
||||
values.hour,
|
||||
values.dayOfMonth,
|
||||
values.month,
|
||||
values.dayOfWeek,
|
||||
);
|
||||
|
||||
<p className={`text-zinc-400 text-xs mt-2`}>
|
||||
The schedule system uses Cronjob syntax when defining when tasks should begin running. Use the
|
||||
fields above to specify when these tasks should begin running.
|
||||
</p>
|
||||
return (
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'schedule:edit'} />
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Schedule name'}
|
||||
description={'A human readable identifier for this schedule.'}
|
||||
/>
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
|
||||
<Field name={'minute'} label={'Minute'} />
|
||||
<Field name={'hour'} label={'Hour'} />
|
||||
<Field name={'dayOfWeek'} label={'Day of week'} />
|
||||
<Field name={'dayOfMonth'} label={'Day of month'} />
|
||||
<Field name={'month'} label={'Month'} />
|
||||
</div>
|
||||
|
||||
{timezoneInfo.isDifferent && (
|
||||
<div className={'bg-blue-900/20 border border-blue-400/30 rounded-lg p-4 my-2'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<TriangleExclamation
|
||||
width={22}
|
||||
height={22}
|
||||
fill='currentColor'
|
||||
className={'text-blue-400 mt-0.5 flex-shrink-0 h-5 w-5'}
|
||||
/>
|
||||
<div className={'text-sm'}>
|
||||
<p className={'text-blue-100 font-medium mb-1'}>Timezone Information</p>
|
||||
<p className={'text-blue-200/80 text-xs mb-2'}>
|
||||
Times shown here are configured for the server timezone.
|
||||
{timezoneInfo.difference !== 'same time' && (
|
||||
<span className={'text-blue-100 font-medium'}>
|
||||
{' '}
|
||||
The server is {timezoneInfo.difference} your timezone.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className={'mt-2 text-xs space-y-1'}>
|
||||
<div className={'text-blue-200/60'}>
|
||||
Your timezone:
|
||||
<span className={'font-mono'}>
|
||||
{' '}
|
||||
{formatTimezoneDisplay(
|
||||
timezoneInfo.user.timezone,
|
||||
timezoneInfo.user.offset,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'text-blue-200/60'}>
|
||||
Server timezone:
|
||||
<span className={'font-mono'}>
|
||||
{' '}
|
||||
{formatTimezoneDisplay(
|
||||
timezoneInfo.server.timezone,
|
||||
timezoneInfo.server.offset,
|
||||
)}
|
||||
</span>
|
||||
<div className={`mt-3 p-3 rounded-lg bg-zinc-800/50 border border-zinc-700/50`}>
|
||||
<p className={`text-sm text-zinc-200 font-medium`}>{cronDescription}</p>
|
||||
</div>
|
||||
|
||||
<p className={`text-zinc-400 text-xs mt-2`}>
|
||||
The schedule system uses Cronjob syntax when defining when tasks should begin running. Use
|
||||
the fields above to specify when these tasks should begin running.
|
||||
</p>
|
||||
|
||||
{timezoneInfo.isDifferent && (
|
||||
<div className={'bg-blue-900/20 border border-blue-400/30 rounded-lg p-4 my-2'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<TriangleExclamation
|
||||
width={22}
|
||||
height={22}
|
||||
fill='currentColor'
|
||||
className={'text-blue-400 mt-0.5 flex-shrink-0 h-5 w-5'}
|
||||
/>
|
||||
<div className={'text-sm'}>
|
||||
<p className={'text-blue-100 font-medium mb-1'}>Timezone Information</p>
|
||||
<p className={'text-blue-200/80 text-xs mb-2'}>
|
||||
Times shown here are configured for the server timezone.
|
||||
{timezoneInfo.difference !== 'same time' && (
|
||||
<span className={'text-blue-100 font-medium'}>
|
||||
{' '}
|
||||
The server is {timezoneInfo.difference} your timezone.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className={'mt-2 text-xs space-y-1'}>
|
||||
<div className={'text-blue-200/60'}>
|
||||
Your timezone:
|
||||
<span className={'font-mono'}>
|
||||
{' '}
|
||||
{formatTimezoneDisplay(
|
||||
timezoneInfo.user.timezone,
|
||||
timezoneInfo.user.offset,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'text-blue-200/60'}>
|
||||
Server timezone:
|
||||
<span className={'font-mono'}>
|
||||
{' '}
|
||||
{formatTimezoneDisplay(
|
||||
timezoneInfo.server.timezone,
|
||||
timezoneInfo.server.offset,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className='gap-3 my-6 flex flex-col'>
|
||||
<a href='https://crontab.guru/' target='_blank' rel='noreferrer'>
|
||||
<ItemContainer
|
||||
description={'Online editor for cron schedule experessions.'}
|
||||
title={'Crontab Guru'}
|
||||
// defaultChecked={showCheatsheet}
|
||||
// onChange={() => setShowCheetsheet((s) => !s)}
|
||||
labelClasses='cursor-pointer'
|
||||
>
|
||||
<Link width={22} height={22} fill='currentColor' className={`px-5 h-5 w-5`} />
|
||||
</ItemContainer>
|
||||
</a>
|
||||
{/* This table would be pretty awkward to make look nice
|
||||
<div className='gap-3 my-6 flex flex-col'>
|
||||
<a href='https://crontab.guru/' target='_blank' rel='noreferrer'>
|
||||
<ItemContainer
|
||||
description={'Online editor for cron schedule experessions.'}
|
||||
title={'Crontab Guru'}
|
||||
// defaultChecked={showCheatsheet}
|
||||
// onChange={() => setShowCheetsheet((s) => !s)}
|
||||
labelClasses='cursor-pointer'
|
||||
>
|
||||
<Link width={22} height={22} fill='currentColor' className={`px-5 h-5 w-5`} />
|
||||
</ItemContainer>
|
||||
</a>
|
||||
{/* This table would be pretty awkward to make look nice
|
||||
Maybe there could be an element for a dropdown later? */}
|
||||
{/* {showCheatsheet && (
|
||||
{/* {showCheatsheet && (
|
||||
<div className={`block md:flex w-full`}>
|
||||
<ScheduleCheatsheetCards />
|
||||
</div>
|
||||
)} */}
|
||||
<FormikSwitchV2
|
||||
name={'onlyWhenOnline'}
|
||||
description={'Only execute this schedule when the server is running.'}
|
||||
label={'Only When Server Is Online'}
|
||||
/>
|
||||
<FormikSwitchV2
|
||||
name={'enabled'}
|
||||
description={'This schedule will be executed automatically if enabled.'}
|
||||
label={'Schedule Enabled'}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mb-6 text-right`}>
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
className={'w-full sm:w-auto'}
|
||||
type={'submit'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{schedule ? 'Save changes' : 'Create schedule'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
<FormikSwitchV2
|
||||
name={'onlyWhenOnline'}
|
||||
description={'Only execute this schedule when the server is running.'}
|
||||
label={'Only When Server Is Online'}
|
||||
/>
|
||||
<FormikSwitchV2
|
||||
name={'enabled'}
|
||||
description={'This schedule will be executed automatically if enabled.'}
|
||||
label={'Schedule Enabled'}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mb-6 text-right`}>
|
||||
<ActionButton
|
||||
variant='primary'
|
||||
className={'w-full sm:w-auto'}
|
||||
type={'submit'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{schedule ? 'Save changes' : 'Create schedule'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Box,
|
||||
BranchesDown,
|
||||
ClockArrowRotateLeft,
|
||||
CloudArrowUpIn,
|
||||
Database,
|
||||
Ellipsis,
|
||||
FolderOpen,
|
||||
Gear,
|
||||
House,
|
||||
PencilToLine,
|
||||
Persons,
|
||||
Terminal,
|
||||
} from '@gravity-ui/icons';
|
||||
import { Ellipsis } from '@gravity-ui/icons';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import React, { Fragment, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import { Fragment, Suspense, createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import routes from '@/routers/routes';
|
||||
import routes, { type ServerRouteDefinition, getServerNavRoutes } from '@/routers/routes';
|
||||
|
||||
import Can from '@/components/elements/Can';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -33,125 +20,23 @@ import MainSidebar from '@/components/elements/MainSidebar';
|
||||
import MainWrapper from '@/components/elements/MainWrapper';
|
||||
import { ServerMobileMenu } from '@/components/elements/MobileFullScreenMenu';
|
||||
import MobileTopBar from '@/components/elements/MobileTopBar';
|
||||
import ModrinthLogo from '@/components/elements/ModrinthLogo';
|
||||
import PermissionRoute from '@/components/elements/PermissionRoute';
|
||||
import Logo from '@/components/elements/PyroLogo';
|
||||
import { NotFound, ServerError } from '@/components/elements/ScreenBlock';
|
||||
import CommandMenu from '@/components/elements/commandk/CmdK';
|
||||
import ConflictStateRenderer from '@/components/server/ConflictStateRenderer';
|
||||
import InstallListener from '@/components/server/InstallListener';
|
||||
import ServerSidebarNavItem from '@/components/server/ServerSidebarNavItem';
|
||||
import TransferListener from '@/components/server/TransferListener';
|
||||
import WebsocketHandler from '@/components/server/WebsocketHandler';
|
||||
import StatBlock from '@/components/server/console/StatBlock';
|
||||
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import http from '@/api/http';
|
||||
import { SubdomainInfo, getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
import { getSubdomainInfo } from '@/api/server/network/subdomain';
|
||||
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
// Sidebar item components that check both permissions and feature limits
|
||||
const DatabasesSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
|
||||
|
||||
// Hide if databases are disabled (limit is 0)
|
||||
if (databaseLimit === 0) return null;
|
||||
|
||||
return (
|
||||
<Can action={'database.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-white/10 rounded-md'
|
||||
ref={ref}
|
||||
to={`/server/${id}/databases`}
|
||||
onClick={onClick}
|
||||
end
|
||||
>
|
||||
<Database width={22} height={22} fill='currentColor' />
|
||||
<p>Databases</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
DatabasesSidebarItem.displayName = 'DatabasesSidebarItem';
|
||||
|
||||
const BackupsSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
|
||||
|
||||
// Hide if backups are disabled (limit is 0)
|
||||
if (backupLimit === 0) return null;
|
||||
|
||||
return (
|
||||
<Can action={'backup.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={ref}
|
||||
to={`/server/${id}/backups`}
|
||||
onClick={onClick}
|
||||
end
|
||||
>
|
||||
<CloudArrowUpIn width={22} height={22} fill='currentColor' />
|
||||
<p>Backups</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
BackupsSidebarItem.displayName = 'BackupsSidebarItem';
|
||||
|
||||
const NetworkingSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
|
||||
({ id, onClick }, ref) => {
|
||||
const [subdomainSupported, setSubdomainSupported] = useState(false);
|
||||
const allocationLimit = ServerContext.useStoreState(
|
||||
(state) => state.server.data?.featureLimits.allocations ?? 0,
|
||||
);
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSubdomainSupport = async () => {
|
||||
try {
|
||||
if (uuid) {
|
||||
const data = await getSubdomainInfo(uuid);
|
||||
setSubdomainSupported(data.supported);
|
||||
}
|
||||
} catch (error) {
|
||||
setSubdomainSupported(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkSubdomainSupport();
|
||||
}, [uuid]);
|
||||
|
||||
// Show if either allocations are available OR subdomains are supported
|
||||
if (allocationLimit === 0 && !subdomainSupported) return null;
|
||||
|
||||
return (
|
||||
<Can action={'allocation.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={ref}
|
||||
to={`/server/${id}/network`}
|
||||
onClick={onClick}
|
||||
end
|
||||
>
|
||||
<BranchesDown width={22} height={22} fill='currentColor' />
|
||||
<p>Networking</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
);
|
||||
},
|
||||
);
|
||||
NetworkingSidebarItem.displayName = 'NetworkingSidebarItem';
|
||||
|
||||
/**
|
||||
* Creates a swipe event from an X and Y location at start and current co-ords.
|
||||
* Important to create a shared, but not public, space for methods.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
|
||||
const ServerRouter = () => {
|
||||
const params = useParams<'id'>();
|
||||
const location = useLocation();
|
||||
@@ -167,7 +52,6 @@ const ServerRouter = () => {
|
||||
const serverName = ServerContext.useStoreState((state) => state.server.data?.name);
|
||||
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
|
||||
const clearServerState = ServerContext.useStoreActions((actions) => actions.clearServerState);
|
||||
const egg_id = ServerContext.useStoreState((state) => state.server.data?.egg);
|
||||
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
|
||||
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
|
||||
const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations);
|
||||
@@ -175,6 +59,25 @@ const ServerRouter = () => {
|
||||
// Mobile menu state
|
||||
const [isMobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
|
||||
// Scroll tracking for highlight indicator
|
||||
const navContainerRef = useRef<HTMLUListElement>(null);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const [containerTop, setContainerTop] = useState(0);
|
||||
|
||||
// Get navigation routes from centralized config
|
||||
const navRoutes = useMemo(() => getServerNavRoutes(), []);
|
||||
|
||||
// Create refs dynamically for each navigation route
|
||||
const navRefs = useMemo(() => {
|
||||
const refs: Record<string, RefObject<HTMLAnchorElement | null>> = {};
|
||||
navRoutes.forEach((route) => {
|
||||
const key = route.path || 'home';
|
||||
refs[key] = createRef<HTMLAnchorElement>();
|
||||
});
|
||||
return refs;
|
||||
}, [navRoutes]);
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuVisible(!isMobileMenuVisible);
|
||||
};
|
||||
@@ -235,69 +138,43 @@ const ServerRouter = () => {
|
||||
}
|
||||
}, [uuid]);
|
||||
|
||||
// Define refs for navigation buttons.
|
||||
const NavigationHome = useRef(null);
|
||||
const NavigationFiles = useRef(null);
|
||||
const NavigationDatabases = useRef(null);
|
||||
const NavigationBackups = useRef(null);
|
||||
const NavigationNetworking = useRef(null);
|
||||
const NavigationUsers = useRef(null);
|
||||
const NavigationStartup = useRef(null);
|
||||
const NavigationSchedules = useRef(null);
|
||||
const NavigationSettings = useRef(null);
|
||||
const NavigationActivity = useRef(null);
|
||||
const NavigationMod = useRef(null);
|
||||
const NavigationShell = useRef(null);
|
||||
|
||||
const calculateTop = (pathname: string) => {
|
||||
/**
|
||||
* Calculate the top position of the highlight indicator based on the current route.
|
||||
* Dynamically matches routes using the route config instead of hardcoded paths.
|
||||
*/
|
||||
const calculateTop = (pathname: string): string | number => {
|
||||
if (!id) return '0';
|
||||
|
||||
// Get currents of navigation refs.
|
||||
const ButtonHome = NavigationHome.current;
|
||||
const ButtonFiles = NavigationFiles.current;
|
||||
const ButtonDatabases = NavigationDatabases.current;
|
||||
const ButtonBackups = NavigationBackups.current;
|
||||
const ButtonNetworking = NavigationNetworking.current;
|
||||
const ButtonUsers = NavigationUsers.current;
|
||||
const ButtonStartup = NavigationStartup.current;
|
||||
const ButtonSchedules = NavigationSchedules.current;
|
||||
const ButtonSettings = NavigationSettings.current;
|
||||
const ButtonShell = NavigationShell.current;
|
||||
const ButtonActivity = NavigationActivity.current;
|
||||
const ButtonMod = NavigationMod.current;
|
||||
const HighlightOffset = 8;
|
||||
|
||||
// Perfectly center the page highlighter with simple math.
|
||||
// Height of navigation links (56) minus highlight height (40) equals 16. 16 devided by 2 is 8.
|
||||
const HighlightOffset: number = 8;
|
||||
// Find matching route for the current pathname
|
||||
for (const route of navRoutes) {
|
||||
const key = route.path || 'home';
|
||||
const ref = navRefs[key];
|
||||
|
||||
if (pathname.endsWith(`/server/${id}`) && ButtonHome != null)
|
||||
return (ButtonHome as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/files`) && ButtonFiles != null)
|
||||
return (ButtonFiles as any).offsetTop + HighlightOffset;
|
||||
if (new RegExp(`^/server/${id}/files(/(new|edit).*)?$`).test(pathname) && ButtonFiles != null)
|
||||
return (ButtonFiles as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/databases`) && ButtonDatabases != null)
|
||||
return (ButtonDatabases as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/backups`) && ButtonBackups != null)
|
||||
return (ButtonBackups as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/network`) && ButtonNetworking != null)
|
||||
return (ButtonNetworking as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/users`) && ButtonUsers != null)
|
||||
return (ButtonUsers as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/startup`) && ButtonStartup != null)
|
||||
return (ButtonStartup as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/schedules`) && ButtonSchedules != null)
|
||||
return (ButtonSchedules as any).offsetTop + HighlightOffset;
|
||||
if (new RegExp(`^/server/${id}/schedules/\\d+$`).test(pathname) && ButtonSchedules != null)
|
||||
return (ButtonSchedules as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/settings`) && ButtonSettings != null)
|
||||
return (ButtonSettings as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/shell`) && ButtonShell != null)
|
||||
return (ButtonShell as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/activity`) && ButtonActivity != null)
|
||||
return (ButtonActivity as any).offsetTop + HighlightOffset;
|
||||
if (pathname.endsWith(`/server/${id}/mods`) && ButtonMod != null)
|
||||
return (ButtonMod as any).offsetTop + HighlightOffset;
|
||||
if (!ref?.current) continue;
|
||||
|
||||
const basePath = route.path ? `/server/${id}/${route.path}` : `/server/${id}`;
|
||||
|
||||
// Check if exact match (for routes with end: true)
|
||||
if (route.end && pathname === basePath) {
|
||||
return ref.current.offsetTop + HighlightOffset;
|
||||
}
|
||||
|
||||
// Check highlight patterns if defined
|
||||
if (route.highlightPatterns) {
|
||||
for (const pattern of route.highlightPatterns) {
|
||||
if (pattern.test(pathname)) {
|
||||
return ref.current.offsetTop + HighlightOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pathname starts with base path (for routes without end)
|
||||
if (!route.end && pathname.startsWith(basePath)) {
|
||||
return ref.current.offsetTop + HighlightOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return '0';
|
||||
};
|
||||
@@ -312,6 +189,65 @@ const ServerRouter = () => {
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [top]);
|
||||
|
||||
// Track scroll position of the nav container
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLUListElement>) => {
|
||||
setScrollOffset(e.currentTarget.scrollTop);
|
||||
setContainerHeight(e.currentTarget.clientHeight);
|
||||
}, []);
|
||||
|
||||
// Measure container dimensions
|
||||
const measureContainer = useCallback(() => {
|
||||
if (navContainerRef.current) {
|
||||
setContainerHeight(navContainerRef.current.clientHeight);
|
||||
setContainerTop(navContainerRef.current.offsetTop);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Measure container on mount/update and window resize (debounced)
|
||||
useEffect(() => {
|
||||
measureContainer();
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(measureContainer, 150);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
clearTimeout(resizeTimeout);
|
||||
};
|
||||
}, [id, uuid, measureContainer]);
|
||||
|
||||
// Adjust top position based on scroll offset
|
||||
const adjustedTop = typeof top === 'number' ? top - scrollOffset : top;
|
||||
|
||||
// Check if the highlighted item is within the visible scroll area
|
||||
const isHighlightVisible = useMemo(() => {
|
||||
if (typeof top !== 'number' || top === 0) return false;
|
||||
if (containerHeight === 0) return true; // Not yet measured, assume visible
|
||||
|
||||
const itemHeight = 40; // Height of a nav item
|
||||
// top is relative to sidebar, containerTop is where the scrollable area starts
|
||||
const itemTopRelativeToContainer = top - containerTop;
|
||||
const itemBottomRelativeToContainer = itemTopRelativeToContainer + itemHeight;
|
||||
|
||||
// Check if item is within the visible scroll window
|
||||
const visibleTop = scrollOffset;
|
||||
const visibleBottom = scrollOffset + containerHeight;
|
||||
|
||||
return itemBottomRelativeToContainer > visibleTop && itemTopRelativeToContainer < visibleBottom;
|
||||
}, [top, scrollOffset, containerHeight, containerTop]);
|
||||
|
||||
/**
|
||||
* Get the ref for a specific route by its path key.
|
||||
*/
|
||||
const getRefForRoute = (route: ServerRouteDefinition) => {
|
||||
const key = route.path || 'home';
|
||||
return navRefs[key];
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment key={'server-router'}>
|
||||
{!uuid || !id ? (
|
||||
@@ -342,24 +278,6 @@ const ServerRouter = () => {
|
||||
<div className='flex flex-row w-full lg:pt-0 pt-16'>
|
||||
{/* Desktop Sidebar */}
|
||||
<MainSidebar className='hidden lg:flex lg:relative lg:shrink-0 w-[300px] bg-[#1a1a1a] flex flex-col h-screen'>
|
||||
<div
|
||||
className='absolute bg-brand w-[3px] h-10 left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top,
|
||||
height,
|
||||
opacity: top === '0' ? 0 : 1,
|
||||
transition:
|
||||
'linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001) 390ms',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute bg-zinc-900 w-12 h-10 blur-2xl left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top,
|
||||
opacity: top === '0' ? 0 : 0.5,
|
||||
transition: 'all 300ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-row items-center justify-between h-8'>
|
||||
<NavLink to={'/'} className='flex shrink-0 h-8 w-fit'>
|
||||
<Logo uniqueId='server-desktop-sidebar' />
|
||||
@@ -385,120 +303,41 @@ const ServerRouter = () => {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div aria-hidden className='mt-8 mb-4 bg-[#ffffff33] min-h-[1px] w-6'></div>
|
||||
{/* Highlight */}
|
||||
<div
|
||||
className='absolute bg-brand w-[3px] h-10 left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top: adjustedTop,
|
||||
height,
|
||||
opacity: isHighlightVisible ? 1 : 0,
|
||||
transition:
|
||||
'linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001) 390ms',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='absolute bg-zinc-900 w-12 h-10 blur-2xl left-0 rounded-full pointer-events-none'
|
||||
style={{
|
||||
top: adjustedTop,
|
||||
opacity: isHighlightVisible ? 0.5 : 0,
|
||||
transition: 'all 300ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
/>
|
||||
<ul
|
||||
ref={navContainerRef}
|
||||
onScroll={handleScroll}
|
||||
data-pyro-subnav-routes-wrapper=''
|
||||
className='pyro-subnav-routes-wrapper flex-grow overflow-y-auto'
|
||||
>
|
||||
{/* lord forgive me for hardcoding this */}
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationHome}
|
||||
to={`/server/${id}`}
|
||||
end
|
||||
>
|
||||
<House width={22} height={22} fill='currentColor' />
|
||||
<p>Home</p>
|
||||
</NavLink>
|
||||
<>
|
||||
<Can action={'file.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationFiles}
|
||||
to={`/server/${id}/files`}
|
||||
>
|
||||
<FolderOpen width={22} height={22} fill='currentColor' />
|
||||
<p>Files</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<DatabasesSidebarItem id={id} ref={NavigationDatabases} onClick={() => { }} />
|
||||
<BackupsSidebarItem id={id} ref={NavigationBackups} onClick={() => { }} />
|
||||
<NetworkingSidebarItem id={id} ref={NavigationNetworking} onClick={() => { }} />
|
||||
<Can action={'user.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationUsers}
|
||||
to={`/server/${id}/users`}
|
||||
end
|
||||
>
|
||||
<Persons width={22} height={22} fill='currentColor' />
|
||||
<p>Users</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can
|
||||
action={[
|
||||
'startup.read',
|
||||
'startup.update',
|
||||
'startup.command',
|
||||
'startup.docker-image',
|
||||
]}
|
||||
matchAny
|
||||
>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationStartup}
|
||||
to={`/server/${id}/startup`}
|
||||
end
|
||||
>
|
||||
<Terminal width={22} height={22} fill='currentColor' />
|
||||
<p>Startup</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can action={'schedule.*'} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationSchedules}
|
||||
to={`/server/${id}/schedules`}
|
||||
>
|
||||
<ClockArrowRotateLeft width={22} height={22} fill='currentColor' />
|
||||
<p>Schedules</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can action={['settings.*', 'file.sftp']} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationSettings}
|
||||
to={`/server/${id}/settings`}
|
||||
end
|
||||
>
|
||||
<Gear width={22} height={22} fill='currentColor' />
|
||||
<p>Settings</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
<Can action={['activity.*', 'activity.read']} matchAny>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationActivity}
|
||||
to={`/server/${id}/activity`}
|
||||
end
|
||||
>
|
||||
<PencilToLine width={22} height={22} fill='currentColor' />
|
||||
<p>Activity</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
{/* {/* TODO: finish modrinth support *\} */}
|
||||
{/* <Can action={['modrinth.*', 'modrinth.download']} matchAny> */}
|
||||
{/* <NavLink */}
|
||||
{/* className='flex flex-row items-center sm:hidden md:show' */}
|
||||
{/* ref={NavigationMod} */}
|
||||
{/* to={`/server/${id}/mods`} */}
|
||||
{/* end */}
|
||||
{/* > */}
|
||||
{/* <ModrinthLogo /> */}
|
||||
{/* <p>Mods/Plugins</p> */}
|
||||
{/* </NavLink> */}
|
||||
{/* </Can> */}
|
||||
</>
|
||||
<Can action={'startup.software'}>
|
||||
<NavLink
|
||||
className='flex flex-row items-center transition-colors duration-200 hover:bg-[#ffffff11] rounded-md'
|
||||
ref={NavigationShell}
|
||||
to={`/server/${id}/shell`}
|
||||
end
|
||||
>
|
||||
<Box width={22} height={22} fill='currentColor' />
|
||||
<p>Software</p>
|
||||
</NavLink>
|
||||
</Can>
|
||||
{/* Dynamic navigation items from routes config */}
|
||||
{navRoutes.map((route) => (
|
||||
<ServerSidebarNavItem
|
||||
key={route.path || 'home'}
|
||||
ref={getRefForRoute(route)}
|
||||
route={route}
|
||||
serverId={id}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div className='shrink-0'>
|
||||
<div aria-hidden className='mt-8 mb-4 bg-[#ffffff33] min-h-[1px] w-full'></div>
|
||||
@@ -532,7 +371,7 @@ const ServerRouter = () => {
|
||||
key={route}
|
||||
path={route}
|
||||
element={
|
||||
<PermissionRoute permission={permission}>
|
||||
<PermissionRoute permission={permission ?? undefined}>
|
||||
<Suspense fallback={null}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
Box,
|
||||
BranchesDown,
|
||||
ClockArrowRotateLeft,
|
||||
CloudArrowUpIn,
|
||||
Database,
|
||||
FolderOpen,
|
||||
Gear,
|
||||
House,
|
||||
PencilToLine,
|
||||
Persons,
|
||||
Terminal,
|
||||
} from '@gravity-ui/icons';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
|
||||
@@ -28,6 +41,12 @@ import UsersContainer from '@/components/server/users/UsersContainer';
|
||||
const FileEditContainer = lazy(() => import('@/components/server/files/FileEditContainer'));
|
||||
const ScheduleEditContainer = lazy(() => import('@/components/server/schedules/ScheduleEditContainer'));
|
||||
|
||||
// Icon component type that works with Gravity UI icons
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
// Feature limit types for visibility conditions
|
||||
export type FeatureLimitKey = 'databases' | 'backups' | 'allocations';
|
||||
|
||||
interface RouteDefinition {
|
||||
/**
|
||||
* Route is the path that will be matched against, this field supports wildcards.
|
||||
@@ -35,7 +54,7 @@ interface RouteDefinition {
|
||||
route: string;
|
||||
/**
|
||||
* Path is the path that will be used for any navbars or links, do not use wildcards or fancy
|
||||
* matchers here. If this field is left undefined, this route will not have a navigation element,
|
||||
* matchers here. If this field is left undefined, this route will not have a navigation element.
|
||||
*/
|
||||
path?: string;
|
||||
// If undefined is passed this route is still rendered into the router itself
|
||||
@@ -45,8 +64,27 @@ interface RouteDefinition {
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
interface ServerRouteDefinition extends RouteDefinition {
|
||||
permission?: string | string[];
|
||||
export interface ServerRouteDefinition extends RouteDefinition {
|
||||
permission?: string | string[] | null;
|
||||
/**
|
||||
* Icon to display in the sidebar/navigation.
|
||||
* If undefined, the route won't have a navigation element.
|
||||
*/
|
||||
icon?: IconComponent;
|
||||
/**
|
||||
* Feature limit key to check. If the limit is 0, the nav item is hidden.
|
||||
* Special value 'network' checks both allocations limit AND subdomain support.
|
||||
*/
|
||||
featureLimit?: FeatureLimitKey | 'network';
|
||||
/**
|
||||
* Whether this is a sub-route that shouldn't appear in navigation.
|
||||
*/
|
||||
isSubRoute?: boolean;
|
||||
/**
|
||||
* Route path patterns that should highlight this nav item.
|
||||
* Used for matching nested routes to parent nav items.
|
||||
*/
|
||||
highlightPatterns?: RegExp[];
|
||||
}
|
||||
|
||||
interface Routes {
|
||||
@@ -56,7 +94,7 @@ interface Routes {
|
||||
server: ServerRouteDefinition[];
|
||||
}
|
||||
|
||||
export default {
|
||||
const routes: Routes = {
|
||||
account: [
|
||||
{
|
||||
route: '',
|
||||
@@ -89,8 +127,9 @@ export default {
|
||||
route: '',
|
||||
path: '',
|
||||
permission: null,
|
||||
name: 'Console',
|
||||
name: 'Home',
|
||||
component: ServerConsoleContainer,
|
||||
icon: House,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
@@ -99,12 +138,15 @@ export default {
|
||||
permission: 'file.*',
|
||||
name: 'Files',
|
||||
component: FileManagerContainer,
|
||||
icon: FolderOpen,
|
||||
highlightPatterns: [/^\/server\/[^/]+\/files(\/.*)?$/],
|
||||
},
|
||||
{
|
||||
route: 'files/:action/*',
|
||||
permission: 'file.*',
|
||||
name: undefined,
|
||||
component: FileEditContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'databases/*',
|
||||
@@ -112,38 +154,9 @@ export default {
|
||||
permission: 'database.*',
|
||||
name: 'Databases',
|
||||
component: DatabasesContainer,
|
||||
},
|
||||
{
|
||||
route: 'schedules/*',
|
||||
path: 'schedules',
|
||||
permission: 'schedule.*',
|
||||
name: 'Schedules',
|
||||
component: ScheduleContainer,
|
||||
},
|
||||
{
|
||||
route: 'schedules/:id/*',
|
||||
permission: 'schedule.*',
|
||||
name: undefined,
|
||||
component: ScheduleEditContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/*',
|
||||
path: 'users',
|
||||
permission: 'user.*',
|
||||
name: 'Users',
|
||||
component: UsersContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/new',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: CreateUserContainer,
|
||||
},
|
||||
{
|
||||
route: 'users/:id/edit',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: EditUserContainer,
|
||||
icon: Database,
|
||||
featureLimit: 'databases',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'backups/*',
|
||||
@@ -151,13 +164,42 @@ export default {
|
||||
permission: 'backup.*',
|
||||
name: 'Backups',
|
||||
component: BackupContainer,
|
||||
icon: CloudArrowUpIn,
|
||||
featureLimit: 'backups',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'network/*',
|
||||
path: 'network',
|
||||
permission: 'allocation.*',
|
||||
name: 'Network',
|
||||
name: 'Networking',
|
||||
component: NetworkContainer,
|
||||
icon: BranchesDown,
|
||||
featureLimit: 'network',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'users/*',
|
||||
path: 'users',
|
||||
permission: 'user.*',
|
||||
name: 'Users',
|
||||
component: UsersContainer,
|
||||
icon: Persons,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'users/new',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: CreateUserContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'users/:id/edit',
|
||||
permission: 'user.*',
|
||||
name: undefined,
|
||||
component: EditUserContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'startup/*',
|
||||
@@ -165,6 +207,24 @@ export default {
|
||||
permission: ['startup.read', 'startup.update', 'startup.docker-image'],
|
||||
name: 'Startup',
|
||||
component: StartupContainer,
|
||||
icon: Terminal,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'schedules/*',
|
||||
path: 'schedules',
|
||||
permission: 'schedule.*',
|
||||
name: 'Schedules',
|
||||
component: ScheduleContainer,
|
||||
icon: ClockArrowRotateLeft,
|
||||
highlightPatterns: [/^\/server\/[^/]+\/schedules(\/\d+)?$/],
|
||||
},
|
||||
{
|
||||
route: 'schedules/:id/*',
|
||||
permission: 'schedule.*',
|
||||
name: undefined,
|
||||
component: ScheduleEditContainer,
|
||||
isSubRoute: true,
|
||||
},
|
||||
{
|
||||
route: 'settings/*',
|
||||
@@ -172,20 +232,8 @@ export default {
|
||||
permission: ['settings.*', 'file.sftp'],
|
||||
name: 'Settings',
|
||||
component: SettingsContainer,
|
||||
},
|
||||
{
|
||||
route: 'shell/*',
|
||||
path: 'shell',
|
||||
permission: 'startup.software',
|
||||
name: 'Software',
|
||||
component: ShellContainer,
|
||||
},
|
||||
{
|
||||
route: 'mods/*',
|
||||
path: 'mods',
|
||||
permission: ['modrinth.download', 'settings.modrinth'],
|
||||
name: 'Modrinth',
|
||||
component: ModrinthContainer,
|
||||
icon: Gear,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'activity/*',
|
||||
@@ -193,6 +241,35 @@ export default {
|
||||
permission: 'activity.*',
|
||||
name: 'Activity',
|
||||
component: ServerActivityLogContainer,
|
||||
icon: PencilToLine,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'shell/*',
|
||||
path: 'shell',
|
||||
permission: 'startup.software',
|
||||
name: 'Software',
|
||||
component: ShellContainer,
|
||||
icon: Box,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
route: 'mods/*',
|
||||
path: 'mods',
|
||||
permission: ['modrinth.download', 'settings.modrinth'],
|
||||
name: 'Modrinth',
|
||||
component: ModrinthContainer,
|
||||
isSubRoute: true, // Hidden until modrinth support is complete
|
||||
},
|
||||
],
|
||||
} as Routes;
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
/**
|
||||
* Get navigation routes (routes that should appear in sidebar/mobile menu).
|
||||
* Filters out sub-routes and routes without names or icons.
|
||||
*/
|
||||
export const getServerNavRoutes = (): ServerRouteDefinition[] => {
|
||||
return routes.server.filter((route) => route.name && route.icon && !route.isSubRoute);
|
||||
};
|
||||
|
||||
@@ -72,11 +72,32 @@ pm.max_requests = 500
|
||||
chdir = /
|
||||
EOF
|
||||
|
||||
phpenmod -v 8.4 dom xml simplexml
|
||||
phpenmod -v 8.4 dom xml simplexml mbstring
|
||||
usermod -a -G www-data vagrant
|
||||
|
||||
systemctl enable --now php8.4-fpm
|
||||
|
||||
# ensure CLI sees correct php version
|
||||
if ! php -v | grep -q "PHP 8.4"; then
|
||||
warn "CLI PHP is not 8.4 — forcing PHP 8.4 as system default"
|
||||
|
||||
update-alternatives --set php /usr/bin/php8.4
|
||||
update-alternatives --set phar /usr/bin/phar8.4
|
||||
update-alternatives --set phar.phar /usr/bin/phar.phar8.4
|
||||
|
||||
# re-check
|
||||
if ! php -v | grep -q "PHP 8.4"; then
|
||||
err "Failed to force PHP 8.4 as CLI default"
|
||||
php -v
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "PHP CLI successfully set to 8.4"
|
||||
else
|
||||
log "PHP CLI using 8.4"
|
||||
fi
|
||||
|
||||
|
||||
log Installing and configuring Nginx
|
||||
apt-get install -y nginx
|
||||
if grep -qE '^user\s+' /etc/nginx/nginx.conf; then
|
||||
@@ -179,7 +200,11 @@ setfacl -Rm u:vagrant:rwX storage bootstrap/cache >/dev/null 2>&1 || true
|
||||
chown -R vagrant:vagrant storage bootstrap/cache
|
||||
|
||||
# helper (append --no-interaction automatically; avoid quoted, spaced values)
|
||||
artisan() { sudo -u vagrant -H bash -lc "cd /home/vagrant/pyrodactyl && php artisan $* --no-interaction"; }
|
||||
artisan() {
|
||||
sudo -u vagrant -H bash -lc \
|
||||
"cd /home/vagrant/pyrodactyl && /usr/bin/php8.4 artisan $* --no-interaction"
|
||||
}
|
||||
|
||||
|
||||
# generate key only if empty/missing
|
||||
if ! grep -qE '^APP_KEY=base64:.+' .env; then
|
||||
@@ -227,7 +252,7 @@ NODE_ID=$(mysql -u root -D panel -N -B -e "SELECT id FROM nodes WHERE name='loca
|
||||
log Adding dummy allocations
|
||||
for i in $(seq 25500 25600); do
|
||||
mysql -u root -e "INSERT IGNORE INTO panel.allocations (node_id, ip, port)
|
||||
VALUES ($NODE_ID, 'localhost', $i)"
|
||||
VALUES ($NODE_ID, '0.0.0.0', $i)"
|
||||
done
|
||||
|
||||
log Registering database host
|
||||
@@ -281,6 +306,9 @@ install -d -m 0755 /etc/pterodactyl
|
||||
install -d -m 0755 /etc/elytra
|
||||
if [ ! -f /usr/local/bin/elytra ]; then
|
||||
ARCH=$(uname -m); [[ $ARCH == x86_64 ]] && ARCH=amd64 || ARCH=arm64
|
||||
# TODO: MAKE SURE TO MERGE https://github.com/BoredHF/elytra IT HAS THE FIXES FOR THE ELYTRA BINARY.
|
||||
# Without it, the elytra binary won't work properly. You would need to jump through a few hoops
|
||||
# post install by creating a pterodactyl user using "sudo useradd -M -s /sbin/nologin pyrodactyl" - Tyr
|
||||
curl -fsSL -o /usr/local/bin/elytra "https://github.com/pyrohost/elytra/releases/latest/download/elytra_linux_${ARCH}"
|
||||
chmod u+x /usr/local/bin/elytra
|
||||
else
|
||||
@@ -301,7 +329,7 @@ else
|
||||
fi
|
||||
|
||||
if [ ! -f /etc/pterodactyl/config.yml ]; then
|
||||
sudo -u vagrant -H bash -lc 'cd /home/vagrant/pyrodactyl && php artisan p:node:configuration 1' >/etc/pterodactyl/config.yml || true
|
||||
sudo -u vagrant -H bash -lc 'cd /home/vagrant/pyrodactyl && /usr/bin/php8.4 artisan p:node:configuration 1' >/etc/pterodactyl/config.yml || true
|
||||
else
|
||||
log "Elytra config already exists, skipping"
|
||||
fi
|
||||
@@ -335,13 +363,13 @@ if ! command -v minio >/dev/null 2>&1; then
|
||||
ARCH=$(uname -m); [[ $ARCH == x86_64 ]] && ARCH=amd64 || ARCH=arm64
|
||||
curl -fsSL -o /usr/local/bin/minio "https://dl.min.io/server/minio/release/linux-${ARCH}/minio"
|
||||
chmod +x /usr/local/bin/minio
|
||||
|
||||
|
||||
# Create minio user and directories
|
||||
useradd -r minio-user || true
|
||||
mkdir -p /opt/minio/data
|
||||
mkdir -p /etc/minio
|
||||
chown minio-user:minio-user /opt/minio/data
|
||||
|
||||
|
||||
# Create MinIO environment file
|
||||
cat >/etc/minio/minio.conf <<EOF
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
@@ -349,7 +377,7 @@ MINIO_ROOT_PASSWORD=minioadmin
|
||||
MINIO_VOLUMES=/opt/minio/data
|
||||
MINIO_OPTS="--console-address :9001"
|
||||
EOF
|
||||
|
||||
|
||||
# Create systemd service
|
||||
cat >/etc/systemd/system/minio.service <<EOF
|
||||
[Unit]
|
||||
@@ -386,80 +414,86 @@ SendSIGKILL=no
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now minio
|
||||
|
||||
|
||||
# Wait for MinIO to start
|
||||
sleep 5
|
||||
|
||||
|
||||
# Create default buckets using mc (MinIO Client)
|
||||
curl -fsSL -o /usr/local/bin/mc "https://dl.min.io/client/mc/release/linux-${ARCH}/mc"
|
||||
chmod +x /usr/local/bin/mc
|
||||
|
||||
|
||||
# Configure mc alias and create buckets
|
||||
sudo -u vagrant -H bash -c '
|
||||
/usr/local/bin/mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
/usr/local/bin/mc mb local/pterodactyl-backups --ignore-existing
|
||||
' || true
|
||||
|
||||
|
||||
# Configure MinIO in .env for rustic_s3 backups
|
||||
pushd /home/vagrant/pyrodactyl >/dev/null
|
||||
if [ -f .env ]; then
|
||||
# Set rustic_s3 backup configuration for MinIO
|
||||
sed -i '/^APP_BACKUP_DRIVER=/c\APP_BACKUP_DRIVER=rustic_s3' .env
|
||||
|
||||
# Configure rustic_s3 settings for MinIO
|
||||
if grep -q "^RUSTIC_S3_ENDPOINT=" .env; then
|
||||
sed -i '/^RUSTIC_S3_ENDPOINT=/c\RUSTIC_S3_ENDPOINT=http://localhost:9000' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_ENDPOINT=http://localhost:9000' >> .env
|
||||
ENV_FILE=".env"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
warn "No $ENV_FILE found in /home/vagrant/pyrodactyl — skipping MinIO .env configuration"
|
||||
else
|
||||
log "Configuring $ENV_FILE for MinIO (rustic_s3)"
|
||||
|
||||
# Ensure file ends with a newline to prevent concatenation when appending
|
||||
if [ -s "$ENV_FILE" ] && [ "$(tail -c1 "$ENV_FILE")" != $'\n' ]; then
|
||||
log "Adding missing trailing newline to $ENV_FILE"
|
||||
printf '\n' >> "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if grep -q "^RUSTIC_S3_REGION=" .env; then
|
||||
sed -i '/^RUSTIC_S3_REGION=/c\RUSTIC_S3_REGION=us-east-1' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_REGION=us-east-1' >> .env
|
||||
# If APP_SERVICE_AUTHOR was written without a trailing newline and RUSTIC_S3 variables
|
||||
# were appended immediately (e.g. ...\"dev@...\"RUSTIC_S3_...), insert a newline before RUSTIC_S3.
|
||||
if grep -q 'APP_SERVICE_AUTHOR=.*RUSTIC_S3' "$ENV_FILE" 2>/dev/null; then
|
||||
log "Fixing concatenated APP_SERVICE_AUTHOR + RUSTIC_S3 entries"
|
||||
sed -E -i 's/(APP_SERVICE_AUTHOR=.*)RUSTIC_S3/\1\nRUSTIC_S3/g' "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if grep -q "^RUSTIC_S3_BUCKET=" .env; then
|
||||
sed -i '/^RUSTIC_S3_BUCKET=/c\RUSTIC_S3_BUCKET=pterodactyl-backups' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_BUCKET=pterodactyl-backups' >> .env
|
||||
fi
|
||||
# Helper: replace key if present, otherwise append key=value
|
||||
set_env_key() {
|
||||
local file="$1"; shift
|
||||
local key="$1"; local val="$2"
|
||||
|
||||
if grep -q "^RUSTIC_S3_PREFIX=" .env; then
|
||||
sed -i '/^RUSTIC_S3_PREFIX=/c\RUSTIC_S3_PREFIX=pterodactyl-backups/' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_PREFIX=pterodactyl-backups/' >> .env
|
||||
fi
|
||||
# Ensure trailing newline before appending
|
||||
if [ -s "$file" ] && [ "$(tail -c1 "$file")" != $'\n' ]; then
|
||||
printf '\n' >> "$file"
|
||||
fi
|
||||
|
||||
if grep -q "^RUSTIC_S3_ACCESS_KEY_ID=" .env; then
|
||||
sed -i '/^RUSTIC_S3_ACCESS_KEY_ID=/c\RUSTIC_S3_ACCESS_KEY_ID=minioadmin' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_ACCESS_KEY_ID=minioadmin' >> .env
|
||||
fi
|
||||
if grep -qE "^${key}=" "$file"; then
|
||||
# Replace existing line (preserve other lines)
|
||||
awk -v K="$key" -v V="$val" 'BEGIN{FS=OFS="="}
|
||||
$1==K { print K"="V; next }
|
||||
{ print }
|
||||
' "$file" > "$file.tmp" && mv "$file.tmp" "$file"
|
||||
else
|
||||
printf '%s=%s\n' "$key" "$val" >> "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
if grep -q "^RUSTIC_S3_SECRET_ACCESS_KEY=" .env; then
|
||||
sed -i '/^RUSTIC_S3_SECRET_ACCESS_KEY=/c\RUSTIC_S3_SECRET_ACCESS_KEY=minioadmin' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_SECRET_ACCESS_KEY=minioadmin' >> .env
|
||||
fi
|
||||
# Set APP_BACKUP_DRIVER and all RUSTIC_S3_* keys idempotently
|
||||
set_env_key "$ENV_FILE" "APP_BACKUP_DRIVER" "rustic_s3"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_ENDPOINT" "http://localhost:9000"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_REGION" "us-east-1"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_BUCKET" "pterodactyl-backups"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_PREFIX" "pterodactyl-backups/"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_ACCESS_KEY_ID" "minioadmin"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_SECRET_ACCESS_KEY" "minioadmin"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_FORCE_PATH_STYLE" "true"
|
||||
set_env_key "$ENV_FILE" "RUSTIC_S3_DISABLE_SSL" "true"
|
||||
|
||||
if grep -q "^RUSTIC_S3_FORCE_PATH_STYLE=" .env; then
|
||||
sed -i '/^RUSTIC_S3_FORCE_PATH_STYLE=/c\RUSTIC_S3_FORCE_PATH_STYLE=true' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_FORCE_PATH_STYLE=true' >> .env
|
||||
fi
|
||||
log "Finished configuring $ENV_FILE for MinIO (rustic_s3)"
|
||||
|
||||
if grep -q "^RUSTIC_S3_DISABLE_SSL=" .env; then
|
||||
sed -i '/^RUSTIC_S3_DISABLE_SSL=/c\RUSTIC_S3_DISABLE_SSL=true' .env
|
||||
else
|
||||
echo 'RUSTIC_S3_DISABLE_SSL=true' >> .env
|
||||
if ! grep -qE '^APP_BACKUP_DRIVER=|^RUSTIC_S3_' "$ENV_FILE"; then
|
||||
warn "Expected MinIO keys not found in $ENV_FILE"
|
||||
fi
|
||||
fi
|
||||
popd >/dev/null
|
||||
|
||||
|
||||
log Installing phpMyAdmin
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo 'phpmyadmin phpmyadmin/dbconfig-install boolean true' | debconf-set-selections
|
||||
@@ -529,10 +563,10 @@ if ! command -v mailpit >/dev/null 2>&1; then
|
||||
mv /tmp/mailpit /usr/local/bin/
|
||||
chmod +x /usr/local/bin/mailpit
|
||||
rm -f /tmp/mailpit.tar.gz
|
||||
|
||||
|
||||
# Create mailpit user
|
||||
useradd -r mailpit-user || true
|
||||
|
||||
|
||||
# Create systemd service
|
||||
cat >/etc/systemd/system/mailpit.service <<EOF
|
||||
[Unit]
|
||||
@@ -549,10 +583,10 @@ RestartSec=5
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now mailpit
|
||||
|
||||
|
||||
# Configure Mailpit in .env for mail testing
|
||||
pushd /home/vagrant/pyrodactyl >/dev/null
|
||||
if [ -f .env ]; then
|
||||
@@ -562,37 +596,37 @@ EOF
|
||||
else
|
||||
echo 'MAIL_MAILER=smtp' >> .env
|
||||
fi
|
||||
|
||||
|
||||
if grep -q "^MAIL_HOST=" .env; then
|
||||
sed -i '/^MAIL_HOST=/c\MAIL_HOST=localhost' .env
|
||||
else
|
||||
echo 'MAIL_HOST=localhost' >> .env
|
||||
fi
|
||||
|
||||
|
||||
if grep -q "^MAIL_PORT=" .env; then
|
||||
sed -i '/^MAIL_PORT=/c\MAIL_PORT=1025' .env
|
||||
else
|
||||
echo 'MAIL_PORT=1025' >> .env
|
||||
fi
|
||||
|
||||
|
||||
if grep -q "^MAIL_USERNAME=" .env; then
|
||||
sed -i '/^MAIL_USERNAME=/c\MAIL_USERNAME=' .env
|
||||
else
|
||||
echo 'MAIL_USERNAME=' >> .env
|
||||
fi
|
||||
|
||||
|
||||
if grep -q "^MAIL_PASSWORD=" .env; then
|
||||
sed -i '/^MAIL_PASSWORD=/c\MAIL_PASSWORD=' .env
|
||||
else
|
||||
echo 'MAIL_PASSWORD=' >> .env
|
||||
fi
|
||||
|
||||
|
||||
if grep -q "^MAIL_ENCRYPTION=" .env; then
|
||||
sed -i '/^MAIL_ENCRYPTION=/c\MAIL_ENCRYPTION=' .env
|
||||
else
|
||||
echo 'MAIL_ENCRYPTION=' >> .env
|
||||
fi
|
||||
|
||||
|
||||
if grep -q "^MAIL_FROM_ADDRESS=" .env; then
|
||||
sed -i '/^MAIL_FROM_ADDRESS=/c\MAIL_FROM_ADDRESS=no-reply@localhost' .env
|
||||
else
|
||||
@@ -600,19 +634,25 @@ EOF
|
||||
fi
|
||||
fi
|
||||
popd >/dev/null
|
||||
|
||||
|
||||
log "Mailpit installed and configured successfully"
|
||||
log "Mailpit Web UI: http://localhost:8025"
|
||||
else
|
||||
log "Mailpit already installed, skipping"
|
||||
fi
|
||||
|
||||
log "Forcing PHP 8.4 as system default"
|
||||
|
||||
update-alternatives --set php /usr/bin/php8.4
|
||||
update-alternatives --set phar /usr/bin/phar8.4
|
||||
update-alternatives --set phar.phar /usr/bin/phar.phar8.4
|
||||
|
||||
systemctl restart php8.4-fpm
|
||||
systemctl reload nginx || systemctl restart nginx || true
|
||||
|
||||
log Generating Application API Key
|
||||
pushd /home/vagrant/pyrodactyl >/dev/null
|
||||
API_KEY_RESULT=$(sudo -u vagrant -H bash -lc 'php artisan tinker --execute="
|
||||
API_KEY_RESULT=$(sudo -u vagrant -H bash -lc '/usr/bin/php8.4 artisan tinker --execute="
|
||||
use Pterodactyl\Models\ApiKey;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Services\Api\KeyCreationService;
|
||||
@@ -637,7 +677,7 @@ if [ -n "${API_KEY:-}" ]; then
|
||||
log Creating Minecraft server
|
||||
# Get the first allocation ID for this node
|
||||
ALLOCATION_ID=$(mysql -u root -D panel -N -B -e "SELECT id FROM allocations WHERE node_id=$NODE_ID LIMIT 1;" 2>/dev/null || echo "")
|
||||
|
||||
|
||||
if [ -z "$ALLOCATION_ID" ]; then
|
||||
warn "No allocations found for node $NODE_ID, skipping server creation"
|
||||
else
|
||||
@@ -647,7 +687,7 @@ if [ -n "${API_KEY:-}" ]; then
|
||||
"name": "Minecraft Vanilla Dev Server",
|
||||
"description": "Development Minecraft Vanilla Server with 4GB RAM, 32GB storage, 4 cores",
|
||||
"user": 1,
|
||||
"egg": 3,
|
||||
"egg": 8,
|
||||
"docker_image": "ghcr.io/pterodactyl/yolks:java_17",
|
||||
"startup": "java -Xms128M -Xmx4096M -jar {{SERVER_JARFILE}}",
|
||||
"environment": {
|
||||
@@ -682,10 +722,10 @@ if [ -n "${API_KEY:-}" ]; then
|
||||
"oom_disabled": false
|
||||
}
|
||||
EOF
|
||||
|
||||
|
||||
# Check if a Minecraft server already exists using Laravel
|
||||
pushd /home/vagrant/pyrodactyl >/dev/null
|
||||
EXISTING_SERVER_CHECK=$(sudo -u vagrant -H bash -lc 'php artisan tinker --execute="
|
||||
EXISTING_SERVER_CHECK=$(sudo -u vagrant -H bash -lc '/usr/bin/php8.4 artisan tinker --execute="
|
||||
use Pterodactyl\Models\Server;
|
||||
\$server = Server::where(\"name\", \"Minecraft Vanilla Dev Server\")->first();
|
||||
if (\$server) {
|
||||
@@ -743,10 +783,13 @@ if (\$server) {
|
||||
warn "Server creation succeeded but failed to extract server ID"
|
||||
fi
|
||||
else
|
||||
warn "Server creation failed. Response saved to /tmp/server_response.json"
|
||||
if [ -f /tmp/server_response.json ]; then
|
||||
log "Error details: $(head -3 /tmp/server_response.json)"
|
||||
fi
|
||||
warn "Server creation failed. API response below:"
|
||||
|
||||
echo
|
||||
echo "---------------- API RESPONSE ----------------"
|
||||
echo "$SERVER_RESPONSE" | jq . 2>/dev/null || echo "$SERVER_RESPONSE"
|
||||
echo "----------------------------------------------"
|
||||
echo
|
||||
fi
|
||||
rm -f /tmp/server_create.json
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user