Merge pull request #28 from Tyrthurey/fixes-v3

Fixed the dev environment + A bunch of improvements and fixes
This commit is contained in:
Naterfute
2026-01-14 10:49:18 -08:00
committed by GitHub
10 changed files with 796 additions and 716 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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>
);
};

View 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;

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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);
};

View File

@@ -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