- {/*
-
-
*/}
{server.name}
{' '}
@@ -116,8 +129,6 @@ const ServerRow = ({ server, className }: { server: Server; className?: string }
))}
- {/* I don't think servers will ever have descriptions normall so I'll vaporize it */}
- {/* {!!server.description &&
{server.description}
} */}
- {!stats || isSuspended ? (
+ {!stats || isSuspended || isInstalling ? (
isSuspended ? (
@@ -140,15 +151,13 @@ const ServerRow = ({ server, className }: { server: Server; className?: string }
{server.isTransferring
? 'Transferring'
: server.status === 'installing'
- ? 'Installing'
- : server.status === 'restoring_backup'
- ? 'Restoring Backup'
- : 'Unavailable'}
+ ? 'Installing'
+ : server.status === 'restoring_backup'
+ ? 'Restoring Backup'
+ : 'Unavailable'}
) : (
- //
- // <>>
Sit tight!
)
) : (
diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx
index a99a3017b..0f6b15690 100644
--- a/resources/scripts/components/server/backups/BackupContainer.tsx
+++ b/resources/scripts/components/server/backups/BackupContainer.tsx
@@ -1,7 +1,7 @@
import { ArrowDownToLine } from '@gravity-ui/icons';
import { useStoreState } from 'easy-peasy';
import { Form, Formik, Field as FormikField, FormikHelpers, useFormikContext } from 'formik';
-import { createContext, useCallback, useContext, useEffect, useState } from 'react';
+import { createContext, lazy, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { boolean, object, string } from 'yup';
@@ -22,6 +22,8 @@ import { PageListContainer } from '@/components/elements/pages/PageList';
import { SocketEvent } from '@/components/server/events';
import { httpErrorToHuman } from '@/api/http';
+import deleteAllServerBackups from '@/api/server/backups/deleteAllServerBackups';
+import { getGlobalDaemonType } from '@/api/server/getServer';
import { Context as ServerBackupContext } from '@/api/swr/getServerBackups';
import getServerBackups from '@/api/swr/getServerBackups';
@@ -31,9 +33,12 @@ import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
-import BackupItem from './BackupItem';
import { useUnifiedBackups } from './useUnifiedBackups';
+const BackupItemElytra = lazy(() => import('./elytra/BackupItem'));
+const BackupItemWings = lazy(() => import('./wings/BackupItem'));
+
+
// Context to share live backup progress across components
export const LiveProgressContext = createContext<
Record<
@@ -136,6 +141,7 @@ const BackupContainer = () => {
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [bulkDeletePassword, setBulkDeletePassword] = useState('');
const [bulkDeleteTotpCode, setBulkDeleteTotpCode] = useState('');
+ const daemonType = getGlobalDaemonType();
const hasTwoFactor = useStoreState((state: ApplicationStore) => state.user.data?.useTotp || false);
@@ -185,14 +191,7 @@ const BackupContainer = () => {
setIsDeleting(true);
try {
- const http = (await import('@/api/http')).default;
- await http.delete(`/api/client/servers/${uuid}/backups/delete-all`, {
- data: {
- password: deleteAllPassword,
- ...(hasTwoFactor ? { totp_code: deleteAllTotpCode } : {}),
- },
- });
-
+ await deleteAllServerBackups(uuid, deleteAllPassword, hasTwoFactor, deleteAllTotpCode);
toast.success('All backups and repositories are being deleted. This may take a few minutes.');
setDeleteAllModalVisible(false);
@@ -290,7 +289,6 @@ const BackupContainer = () => {
clearFlashes('backups');
return;
}
-
clearAndAddHttpError({ error, key: 'backups' });
}, [error]);
@@ -720,16 +718,20 @@ const BackupContainer = () => {
)}
- {backups.map((backup) => (
- toggleBackupSelection(backup.uuid)}
- isSelectable={selectableBackups.some((b) => b.uuid === backup.uuid)}
- retryBackup={retryBackup}
- />
- ))}
+ {backups.map((backup) =>
+ daemonType === 'elytra' ? (
+ toggleBackupSelection(backup.uuid)}
+ isSelectable={selectableBackups.some((b) => b.uuid === backup.uuid)}
+ retryBackup={retryBackup}
+ />
+ ) : (
+
+ ),
+ )}
{pagination && pagination.currentPage && pagination.totalPages && pagination.totalPages > 1 && (
diff --git a/resources/scripts/components/server/backups/elytra/BackupContextMenu.tsx b/resources/scripts/components/server/backups/elytra/BackupContextMenu.tsx
new file mode 100644
index 000000000..d9d56d11f
--- /dev/null
+++ b/resources/scripts/components/server/backups/elytra/BackupContextMenu.tsx
@@ -0,0 +1,513 @@
+import {
+ ArrowDownToLine,
+ Bars,
+ CloudArrowUpIn,
+ Pencil,
+ Shield,
+ TrashBin,
+ TriangleExclamation,
+} from '@gravity-ui/icons';
+import { useStoreState } from 'easy-peasy';
+import { useEffect, useState } from 'react';
+
+import FlashMessageRender from '@/components/FlashMessageRender';
+import ActionButton from '@/components/elements/ActionButton';
+import Can from '@/components/elements/Can';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/elements/DropdownMenu';
+import Spinner from '@/components/elements/Spinner';
+import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
+import { Dialog } from '@/components/elements/dialog';
+
+import http, { httpErrorToHuman } from '@/api/http';
+import { getServerBackupDownloadUrl } from '@/api/server/backups';
+import { ServerBackup } from '@/api/server/types';
+
+import { ApplicationStore } from '@/state';
+import { ServerContext } from '@/state/server';
+
+import useFlash from '@/plugins/useFlash';
+
+import { useUnifiedBackups } from '../useUnifiedBackups';
+import { getGlobalDaemonType } from '@/api/server/getServer';
+
+interface Props {
+ backup: ServerBackup;
+}
+
+const BackupContextMenu = ({ backup }: Props) => {
+ const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
+ const daemonType = getGlobalDaemonType();
+ const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
+ const [modal, setModal] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [countdown, setCountdown] = useState(5);
+ const [newName, setNewName] = useState(backup.name);
+ const [deletePassword, setDeletePassword] = useState('');
+ const [deleteTotpCode, setDeleteTotpCode] = useState('');
+ const [restorePassword, setRestorePassword] = useState('');
+ const [restoreTotpCode, setRestoreTotpCode] = useState('');
+ const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash();
+ const { deleteBackup, restoreBackup, renameBackup, toggleBackupLock, refresh } = useUnifiedBackups();
+ const hasTwoFactor = useStoreState((state: ApplicationStore) => state.user.data?.useTotp || false);
+
+ const doDownload = () => {
+ setLoading(true);
+ clearFlashes('backups');
+ getServerBackupDownloadUrl(uuid, backup.uuid)
+ .then((url) => {
+ // @ts-expect-error this is valid
+ window.location = url;
+ })
+ .catch((error) => {
+ clearAndAddHttpError({ key: 'backups', error });
+ })
+ .then(() => setLoading(false));
+ };
+
+ const doDeletion = async () => {
+ if (!deletePassword) {
+ addFlash({
+ key: 'backup:delete',
+ type: 'error',
+ message: 'Password is required to delete this backup.',
+ });
+ return;
+ }
+
+ if (hasTwoFactor && !deleteTotpCode) {
+ addFlash({
+ key: 'backup:delete',
+ type: 'error',
+ message: 'Two-factor authentication code is required.',
+ });
+ return;
+ }
+
+ setLoading(true);
+ clearFlashes('backup:delete');
+
+ try {
+ await http.delete(`/api/client/servers/${daemonType}/${uuid}/backups/${backup.uuid}`, {
+ data: {
+ password: deletePassword,
+ ...(hasTwoFactor ? { totp_code: deleteTotpCode } : {}),
+ },
+ });
+
+ setLoading(false);
+ setModal('');
+ setDeletePassword('');
+ setDeleteTotpCode('');
+
+ // Refresh the backup list to reflect the deletion
+ await refresh();
+ } catch (error) {
+ clearAndAddHttpError({ key: 'backup:delete', error });
+ setLoading(false);
+ }
+ };
+
+ const doRestorationAction = async () => {
+ if (!restorePassword) {
+ addFlash({
+ key: 'backup:restore',
+ type: 'error',
+ message: 'Password is required to restore this backup.',
+ });
+ return;
+ }
+
+ if (hasTwoFactor && !restoreTotpCode) {
+ addFlash({
+ key: 'backup:restore',
+ type: 'error',
+ message: 'Two-factor authentication code is required.',
+ });
+ return;
+ }
+
+ setLoading(true);
+ clearFlashes('backup:restore');
+
+ try {
+ await http.post(`/api/client/servers/${daemonType}/${uuid}/backups/${backup.uuid}/restore`, {
+ password: restorePassword,
+ ...(hasTwoFactor ? { totp_code: restoreTotpCode } : {}),
+ });
+
+ // Set server status to restoring
+ setServerFromState((s) => ({
+ ...s,
+ status: 'restoring_backup',
+ }));
+
+ setLoading(false);
+ setModal('');
+ setRestorePassword('');
+ setRestoreTotpCode('');
+ } catch (error) {
+ clearAndAddHttpError({ key: 'backup:restore', error });
+ setLoading(false);
+ }
+ };
+
+ const onLockToggle = async () => {
+ if (backup.isLocked && modal !== 'unlock') {
+ return setModal('unlock');
+ }
+
+ try {
+ await toggleBackupLock(backup.uuid);
+ setModal('');
+ } catch (error) {
+ alert(httpErrorToHuman(error));
+ }
+ };
+
+ const doRename = async () => {
+ setLoading(true);
+ clearFlashes('backups');
+
+ try {
+ await renameBackup(backup.uuid, newName.trim());
+ setLoading(false);
+ setModal('');
+ } catch (error) {
+ clearAndAddHttpError({ key: 'backups', error });
+ setLoading(false);
+ setModal('');
+ }
+ };
+
+ useEffect(() => {
+ let interval: NodeJS.Timeout;
+ if (modal === 'restore' && countdown > 0) {
+ interval = setInterval(() => {
+ setCountdown((prev) => prev - 1);
+ }, 1000);
+ }
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [modal, countdown]);
+
+ useEffect(() => {
+ if (modal === 'restore') {
+ setCountdown(5);
+ }
+ }, [modal]);
+
+ useEffect(() => {
+ if (modal === 'rename') {
+ setNewName(backup.name);
+ }
+ }, [modal, backup.name]);
+
+ return (
+ <>
+
setModal('')} title='Rename Backup'>
+
+
+ Backup Name
+ setNewName(e.target.value)}
+ className='w-full px-3 py-2 bg-zinc-800 border border-zinc-600 rounded-lg text-zinc-100 placeholder-zinc-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
+ placeholder='Enter backup name...'
+ maxLength={191}
+ />
+
+
+
+
+ setModal('')} variant='secondary'>
+ Cancel
+
+
+ Rename Backup
+
+
+
+
setModal('')}
+ title={`Unlock "${backup.name}"`}
+ onConfirmed={onLockToggle}
+ >
+ This backup will no longer be protected from automated or accidental deletions.
+
+
{
+ setModal('');
+ setRestorePassword('');
+ setRestoreTotpCode('');
+ }}
+ title='Restore Backup'
+ >
+
+
+
+
"{backup.name}"
+
+ Your server will be stopped during the restoration process. You will not be able to control
+ the power state, access the file manager, or create additional backups until completed.
+
+
+
+
+
+
+
+
+ Destructive Action - Complete Server Restore
+
+
+ All current files and server configuration will be deleted and replaced with the
+ backup data. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+ {
+ setModal('');
+ setRestorePassword('');
+ setRestoreTotpCode('');
+ }}
+ variant='secondary'
+ disabled={loading}
+ >
+ Cancel
+
+ doRestorationAction()}
+ variant='danger'
+ disabled={countdown > 0 || loading}
+ >
+ {loading && }
+ {loading
+ ? 'Restoring...'
+ : countdown > 0
+ ? `Delete All & Restore (${countdown}s)`
+ : 'Delete All & Restore Backup'}
+
+
+
+
{
+ setModal('');
+ setDeletePassword('');
+ setDeleteTotpCode('');
+ }}
+ title={`Delete "${backup.name}"`}
+ >
+
+
+
+ This is a permanent operation. The backup cannot be recovered once deleted.
+
+
+
+
+
+
+
+
+
Warning
+
+ The backup file and its snapshot will be permanently deleted.
+
+
+
+
+
+
+
+
+
+ {
+ setModal('');
+ setDeletePassword('');
+ setDeleteTotpCode('');
+ }}
+ disabled={loading}
+ >
+ Cancel
+
+
+ {loading && }
+ {loading ? 'Deleting...' : 'Delete Backup'}
+
+
+
+
+ {backup.isSuccessful ? (
+
+
+
+
+
+
+
+
+
+
+
+
+ Download
+
+
+
+ setModal('restore')} className='cursor-pointer'>
+
+ Restore
+
+
+
+
+ setModal('rename')} className='cursor-pointer'>
+
+ Rename
+
+
+
+ {backup.isLocked ? 'Unlock' : 'Lock'}
+
+ {!backup.isLocked && (
+ <>
+
+ setModal('delete')}
+ className='cursor-pointer text-red-400 focus:text-red-300'
+ >
+
+ Delete
+
+ >
+ )}
+
+
+
+ ) : (
+
setModal('delete')}
+ disabled={loading}
+ className='flex items-center gap-2'
+ >
+
+ Delete
+
+ )}
+ >
+ );
+};
+
+export default BackupContextMenu;
diff --git a/resources/scripts/components/server/backups/BackupItem.tsx b/resources/scripts/components/server/backups/elytra/BackupItem.tsx
similarity index 98%
rename from resources/scripts/components/server/backups/BackupItem.tsx
rename to resources/scripts/components/server/backups/elytra/BackupItem.tsx
index 9c2831510..ca8a3e29a 100644
--- a/resources/scripts/components/server/backups/BackupItem.tsx
+++ b/resources/scripts/components/server/backups/elytra/BackupItem.tsx
@@ -169,9 +169,8 @@ const BackupItem = ({ backup, isSelected = false, onToggleSelect, isSelectable =
diff --git a/resources/scripts/components/server/backups/useUnifiedBackups.ts b/resources/scripts/components/server/backups/useUnifiedBackups.ts
index 4978f2b59..ff9108878 100644
--- a/resources/scripts/components/server/backups/useUnifiedBackups.ts
+++ b/resources/scripts/components/server/backups/useUnifiedBackups.ts
@@ -6,10 +6,12 @@ import { ServerContext } from '@/state/server';
import { LiveProgressContext } from './BackupContainer';
import { UnifiedBackup } from './BackupItem';
+import { getGlobalDaemonType } from '@/api/server/getServer';
export const useUnifiedBackups = () => {
const { data: backups, error, isValidating, mutate } = getServerBackups();
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
+ const daemonType = getGlobalDaemonType();
const liveProgress = useContext(LiveProgressContext);
@@ -55,7 +57,7 @@ export const useUnifiedBackups = () => {
const renameBackup = useCallback(
async (backupUuid: string, newName: string) => {
const http = (await import('@/api/http')).default;
- await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/rename`, { name: newName });
+ await http.post(`/api/client/servers/${daemonType}/${uuid}/backups/${backupUuid}/rename`, { name: newName });
mutate();
},
[uuid, mutate],
@@ -64,7 +66,7 @@ export const useUnifiedBackups = () => {
const toggleBackupLock = useCallback(
async (backupUuid: string) => {
const http = (await import('@/api/http')).default;
- await http.post(`/api/client/servers/${uuid}/backups/${backupUuid}/lock`);
+ await http.post(`/api/client/servers/${daemonType}/${uuid}/backups/${backupUuid}/lock`);
mutate();
},
[uuid, mutate],
diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/wings/BackupContextMenu.tsx
similarity index 97%
rename from resources/scripts/components/server/backups/BackupContextMenu.tsx
rename to resources/scripts/components/server/backups/wings/BackupContextMenu.tsx
index aaa9e095c..d8a59be94 100644
--- a/resources/scripts/components/server/backups/BackupContextMenu.tsx
+++ b/resources/scripts/components/server/backups/wings/BackupContextMenu.tsx
@@ -33,7 +33,8 @@ import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
-import { useUnifiedBackups } from './useUnifiedBackups';
+import { useUnifiedBackups } from '../useUnifiedBackups';
+import { getGlobalDaemonType } from '@/api/server/getServer';
interface Props {
backup: ServerBackup;
@@ -41,6 +42,7 @@ interface Props {
const BackupContextMenu = ({ backup }: Props) => {
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
+ const daemonType = getGlobalDaemonType();
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
const [modal, setModal] = useState('');
const [loading, setLoading] = useState(false);
@@ -91,7 +93,7 @@ const BackupContextMenu = ({ backup }: Props) => {
clearFlashes('backup:delete');
try {
- await http.delete(`/api/client/servers/${uuid}/backups/${backup.uuid}`, {
+ await http.delete(`/api/client/servers/${daemonType}/${uuid}/backups/${backup.uuid}`, {
data: {
password: deletePassword,
...(hasTwoFactor ? { totp_code: deleteTotpCode } : {}),
@@ -134,7 +136,7 @@ const BackupContextMenu = ({ backup }: Props) => {
clearFlashes('backup:restore');
try {
- await http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/restore`, {
+ await http.post(`/api/client/servers/${daemonType}/backups/${backup.uuid}/restore`, {
password: restorePassword,
...(hasTwoFactor ? { totp_code: restoreTotpCode } : {}),
});
@@ -341,8 +343,8 @@ const BackupContextMenu = ({ backup }: Props) => {
{loading
? 'Restoring...'
: countdown > 0
- ? `Delete All & Restore (${countdown}s)`
- : 'Delete All & Restore Backup'}
+ ? `Delete All & Restore (${countdown}s)`
+ : 'Delete All & Restore Backup'}
diff --git a/resources/scripts/components/server/backups/wings/BackupItem.tsx b/resources/scripts/components/server/backups/wings/BackupItem.tsx
new file mode 100644
index 000000000..c0d21d887
--- /dev/null
+++ b/resources/scripts/components/server/backups/wings/BackupItem.tsx
@@ -0,0 +1,134 @@
+import { Cloud, CloudArrowUpIn, Lock, File } from '@gravity-ui/icons';
+
+import { format, formatDistanceToNow } from 'date-fns';
+
+import Can from '@/components/elements/Can';
+import { ContextMenu, ContextMenuTrigger } from '@/components/elements/ContextMenu';
+import Spinner from '@/components/elements/Spinner';
+import { PageListItem } from '@/components/elements/pages/PageList';
+import { SocketEvent } from '@/components/server/events';
+
+import { bytesToString } from '@/lib/formatters';
+
+import { ServerBackup } from '@/api/server/types';
+import getServerBackups from '@/api/swr/getServerBackups';
+
+// import Can from '@/components/elements/Can';
+import useWebsocketEvent from '@/plugins/useWebsocketEvent';
+
+import BackupContextMenu from './BackupContextMenu';
+
+interface Props {
+ backup: ServerBackup;
+}
+
+const BackupItem = ({ backup }: Props) => {
+ const { mutate } = getServerBackups();
+
+ useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async (data) => {
+ try {
+ const parsed = JSON.parse(data);
+
+ await mutate(
+ (data) => ({
+ ...data!,
+ items: data!.items.map((b) =>
+ b.uuid !== backup.uuid
+ ? b
+ : {
+ ...b,
+ isSuccessful: parsed.is_successful || true,
+ checksum: (parsed.checksum_type || '') + ':' + (parsed.checksum || ''),
+ bytes: parsed.file_size || 0,
+ completedAt: new Date(),
+ },
+ ),
+ }),
+ false,
+ );
+ } catch (e) {
+ console.warn(e);
+ }
+ });
+
+ const getStatusIcon = () => {
+ const isActive = backup.isInProgress === true || backup.isInProgress === false;
+ if (backup.completedAt === null) {
+ return
;
+ }
+ if (isActive) {
+ return
;
+ } else if (backup.isLocked) {
+ return
;
+ } else if (backup.isInProgress === true || backup.isSuccessful) {
+ return
;
+ } else {
+ return
;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {getStatusIcon()}
+
+
+
+
{backup.name}
+ {backup.isAutomatic && (
+
+ Automatic
+
+ )}
+ {backup.isLocked && (
+
+ Locked
+
+ )}
+
+ {backup.checksum &&
{backup.checksum}
}
+
+
+
+
+
+ {backup.completedAt && backup.bytes ? (
+ <>
+
Size
+
{bytesToString(backup.bytes)}
+ >
+ ) : (
+ <>
+
Size
+
-
+ >
+ )}
+
+
+
+
Created
+
+ {formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
+
+
+
+
+
+
+
+ {!backup.completedAt ? <>> : }
+
+
+
+
+ );
+};
+
+export default BackupItem;
diff --git a/resources/scripts/components/server/console/StatBlock.tsx b/resources/scripts/components/server/console/StatBlock.tsx
index 50123d7fe..341657d86 100644
--- a/resources/scripts/components/server/console/StatBlock.tsx
+++ b/resources/scripts/components/server/console/StatBlock.tsx
@@ -16,11 +16,11 @@ const StatBlock = ({ title, copyOnClick, className, children }: StatBlockProps)
-
+
{title}
diff --git a/resources/scripts/components/server/features/HytaleOauthRequireFeature.tsx b/resources/scripts/components/server/features/HytaleOauthRequireFeature.tsx
new file mode 100644
index 000000000..9ce32570a
--- /dev/null
+++ b/resources/scripts/components/server/features/HytaleOauthRequireFeature.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from 'react';
+
+import FlashMessageRender from '@/components/FlashMessageRender';
+import Button from '@/components/elements/ActionButton';
+// assuming this is your styled button
+import Modal from '@/components/elements/Modal';
+import { SocketEvent } from '@/components/server/events';
+
+import { ServerContext } from '@/state/server';
+
+import useFlash from '@/plugins/useFlash';
+
+const HytaleOauthRequireFeature = () => {
+ const [visible, setVisible] = useState(false);
+ const [userCode, setUserCode] = useState('');
+ const [verificationUri, setVerificationUri] = useState('');
+
+ const status = ServerContext.useStoreState((state) => state.status.value);
+ const { clearFlashes } = useFlash();
+ const { connected, instance } = ServerContext.useStoreState((state) => state.socket);
+
+ useEffect(() => {
+ if (!connected || !instance || status === 'running') return;
+
+ const listener = (line: string) => {
+ const urlMatch = line.match(
+ /https:\/\/oauth\.accounts\.hytale\.com\/oauth2\/device\/verify\?user_code=([a-zA-Z0-9\s]+)/i,
+ );
+ if (urlMatch) {
+ const code = urlMatch[1]?.trim() || '';
+ setUserCode(code);
+ setVerificationUri(urlMatch[0] || '');
+ setVisible(true);
+ return;
+ }
+ };
+
+ instance.addListener(SocketEvent.CONSOLE_OUTPUT, listener);
+ return () => {
+ instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
+ };
+ }, [connected, instance, status]);
+
+ useEffect(() => {
+ clearFlashes('feature:hytaleOauth');
+ }, []);
+
+ const handleAuthenticate = () => {
+ if (verificationUri) {
+ window.open(verificationUri, '_blank', 'noopener,noreferrer');
+ setVisible(false);
+ }
+ };
+
+ return (
+
{
+ setVisible(false);
+ setUserCode('');
+ setVerificationUri('');
+ }}
+ closeOnBackground={false}
+ showSpinnerOverlay={false}
+ title='Hytale Authentication'
+ >
+
+
+
+
+ Server requires authentication to start. Click below to verify this device.
+
+
+
+
+ Authenticate Server
+
+
+
+
+
+ OR ENTER CODE MANUALLY
+
+
+
+
+
DEVICE CODE
+ {userCode ? (
+
navigator.clipboard.writeText(userCode)}
+ >
+ {userCode}
+
+ ) : (
+
•••• ••••
+ )}
+
+
+
Only required once per server
+
+
+ );
+};
+
+export default HytaleOauthRequireFeature;
diff --git a/resources/scripts/components/server/features/index.ts b/resources/scripts/components/server/features/index.ts
index af1303fee..d9bdf0774 100644
--- a/resources/scripts/components/server/features/index.ts
+++ b/resources/scripts/components/server/features/index.ts
@@ -10,10 +10,9 @@ const features: Record
= {
eula: lazy(() => import('@feature/eula/EulaModalFeature')),
java_version: lazy(() => import('@feature/JavaVersionModalFeature')),
gsl_token: lazy(() => import('@feature/GSLTokenModalFeature')),
- // Why are you broken?
- // Not anymore, there's a fix!
pid_limit: lazy(() => import('@feature/PIDLimitModalFeature')),
steam_disk_space: lazy(() => import('@feature/SteamDiskSpaceFeature')),
+ hytale_oauth: lazy(() => import('@feature/HytaleOauthRequireFeature')),
};
export default features;
diff --git a/resources/scripts/components/server/operations/WingsOperationProgressModal.tsx b/resources/scripts/components/server/operations/WingsOperationProgressModal.tsx
new file mode 100644
index 000000000..c1029247d
--- /dev/null
+++ b/resources/scripts/components/server/operations/WingsOperationProgressModal.tsx
@@ -0,0 +1,260 @@
+import { TriangleExclamation } from '@gravity-ui/icons';
+import React, { useEffect, useState } from 'react';
+
+import ActionButton from '@/components/elements/ActionButton';
+import Spinner from '@/components/elements/Spinner';
+import { Dialog } from '@/components/elements/dialog';
+
+import {
+ UI_CONFIG,
+ canCloseOperation,
+ formatOperationId,
+ getStatusIconType,
+ getStatusStyling,
+ isActiveStatus,
+ isCompletedStatus,
+ isFailedStatus,
+} from '@/lib/server-operations';
+
+import { ServerOperation, useOperationPolling } from '@/api/server/serverOperations';
+
+import { ServerContext } from '@/state/server';
+
+interface Props {
+ visible: boolean;
+ operationId: string | null;
+ operationType: string;
+ onClose: () => void;
+ onComplete?: (operation: ServerOperation) => void;
+ onError?: (error: Error) => void;
+}
+
+/**
+ * Modal component for displaying server operation progress in real-time.
+ * Handles polling, auto-close, and status updates for long-running operations.
+ */
+const WingsOperationProgressModal: React.FC = ({
+ visible,
+ operationId,
+ operationType,
+ onClose,
+ onComplete,
+ onError,
+}) => {
+ const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
+ const [operation, setOperation] = useState(null);
+ const [error, setError] = useState(null);
+ const [autoCloseTimer, setAutoCloseTimer] = useState(null);
+ const { startPolling, stopPolling } = useOperationPolling();
+
+ useEffect(() => {
+ if (!visible || !operationId) {
+ stopPolling(operationId || '');
+ setOperation(null);
+ setError(null);
+ if (autoCloseTimer) {
+ clearTimeout(autoCloseTimer);
+ setAutoCloseTimer(null);
+ }
+ return;
+ }
+
+ const handleUpdate = (op: ServerOperation) => {
+ setOperation(op);
+ };
+
+ const handleComplete = (op: ServerOperation) => {
+ setOperation(op);
+ stopPolling(operationId);
+
+ if (onComplete) {
+ onComplete(op);
+ }
+
+ if (op.is_completed) {
+ const timer = setTimeout(() => {
+ onClose();
+ }, UI_CONFIG.AUTO_CLOSE_DELAY);
+ setAutoCloseTimer(timer);
+ }
+ };
+
+ const handleError = (err: Error) => {
+ setError(err.message);
+ stopPolling(operationId);
+
+ if (onError) {
+ onError(err);
+ }
+ };
+
+ startPolling(uuid, operationId, handleUpdate, handleComplete, handleError);
+
+ return () => {
+ stopPolling(operationId);
+ if (autoCloseTimer) {
+ clearTimeout(autoCloseTimer);
+ }
+ };
+ }, [visible, operationId, uuid, startPolling, stopPolling, onComplete, onError, onClose, autoCloseTimer]);
+
+ const renderStatusIcon = (status: string) => {
+ const iconType = getStatusIconType(status as any);
+
+ switch (iconType) {
+ case 'spinner':
+ return ;
+ case 'success':
+ return (
+
+ );
+ case 'error':
+ return (
+
+ );
+ default:
+ return ;
+ }
+ };
+
+ const canClose = canCloseOperation(operation, error);
+ const statusStyling = operation ? getStatusStyling(operation.status) : null;
+
+ const handleClose = () => {
+ if (autoCloseTimer) {
+ clearTimeout(autoCloseTimer);
+ setAutoCloseTimer(null);
+ }
+ onClose();
+ };
+
+ return (
+ { }}
+ preventExternalClose={!canClose}
+ hideCloseIcon={!canClose}
+ title={operationType}
+ >
+
+ {/* Operation ID */}
+ {operationId && (
+
+
+
ID: {formatOperationId(operationId)}
+
+
+ )}
+
+ {/* Error State */}
+ {error ? (
+
+ ) : operation ? (
+ /* Operation State */
+
+ {/* Status Header */}
+
+ {renderStatusIcon(operation.status)}
+
+ {operation.status}
+
+
+
+ {/* Message Box */}
+
+
{operation.message || 'Processing...'}
+
+
+ {/* Progress Bar for Active Operations */}
+ {isActiveStatus(operation.status) && (
+
+
+
+ This window will close automatically when complete
+
+
+ )}
+
+ {/* Success State */}
+ {isCompletedStatus(operation.status) && (
+
+
+
+
+ Operation completed successfully
+
+
+ {autoCloseTimer && (
+
+ Closing automatically in 3 seconds
+
+ )}
+
+ )}
+
+ {/* Failed State */}
+ {isFailedStatus(operation.status) && (
+
+
+ {operation.message && (
+
{operation.message}
+ )}
+
+ )}
+
+ ) : (
+ /* Loading State */
+
+
+ Initializing...
+
+ )}
+
+
+ {canClose && (
+
+
+ Cancel
+
+
+ {operation?.is_completed ? 'Done' : 'Close'}
+
+
+ )}
+
+ );
+};
+
+export default WingsOperationProgressModal;
diff --git a/resources/scripts/components/server/shell/ShellContainer.tsx b/resources/scripts/components/server/shell/ShellContainer.tsx
index a1b08916e..77ca6d149 100644
--- a/resources/scripts/components/server/shell/ShellContainer.tsx
+++ b/resources/scripts/components/server/shell/ShellContainer.tsx
@@ -18,10 +18,13 @@ import Spinner from '@/components/elements/Spinner';
import { Switch } from '@/components/elements/SwitchV2';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import OperationProgressModal from '@/components/server/operations/OperationProgressModal';
+import WingsOperationProgressModal from '@/components/server/operations/WingsOperationProgressModal';
import { httpErrorToHuman } from '@/api/http';
import getNests from '@/api/nests/getNests';
import applyEggChange from '@/api/server/applyEggChange';
+import applyEggChangeSync from '@/api/server/applyEggChangeSync';
+import { getGlobalDaemonType } from '@/api/server/getServer';
import previewEggChange, { EggPreview } from '@/api/server/previewEggChange';
import { ServerOperation } from '@/api/server/serverOperations';
import getServerBackups from '@/api/swr/getServerBackups';
@@ -248,6 +251,7 @@ const validateEnvironmentVariables = (variables: any[], pendingVariables: Record
const SoftwareContainer = () => {
const serverData = ServerContext.useStoreState((state) => state.server.data);
+ const daemonType = getGlobalDaemonType();
const uuid = serverData?.uuid;
const [nests, setNests] = useState();
//const eggs = nests?.reduce(
@@ -270,6 +274,7 @@ const SoftwareContainer = () => {
?.attributes?.name;
}, [nests, currentEgg]);
const backupLimit = serverData?.featureLimits.backups;
+
const { data: backups } = getServerBackups();
const setServerFromState = ServerContext.useStoreActions((actions) => actions.server.setServerFromState);
@@ -507,8 +512,8 @@ const SoftwareContainer = () => {
selectedDockerImage && eggPreview.docker_images
? eggPreview.docker_images[selectedDockerImage]
: eggPreview.default_docker_image && eggPreview.docker_images
- ? eggPreview.docker_images[eggPreview.default_docker_image]
- : '';
+ ? eggPreview.docker_images[eggPreview.default_docker_image]
+ : '';
// Filter out empty environment variables to prevent validation issues
const filteredEnvironment: Record = {};
@@ -518,24 +523,34 @@ const SoftwareContainer = () => {
}
});
- // Start the async operation
- const response = await applyEggChange(uuid, {
- egg_id: selectedEgg.attributes.id,
- nest_id: selectedNest.attributes.id,
- docker_image: actualDockerImage,
- startup_command: customStartup,
- environment: filteredEnvironment,
- should_backup: shouldBackup,
- should_wipe: shouldWipe,
- });
+ if (daemonType?.toLowerCase() == 'elytra') {
+ const response = await applyEggChange(uuid, {
+ egg_id: selectedEgg.attributes.id,
+ nest_id: selectedNest.attributes.id,
+ docker_image: actualDockerImage,
+ startup_command: customStartup,
+ environment: filteredEnvironment,
+ should_backup: shouldBackup,
+ should_wipe: shouldWipe,
+ });
- // Operation started successfully - show progress modal
- setCurrentOperationId(response.operation_id);
- setShowOperationModal(true);
+ setCurrentOperationId(response.operation_id);
+
+ setShowOperationModal(true);
+ } else if (daemonType?.toLowerCase() == 'wings') {
+ await applyEggChangeSync(uuid, {
+ egg_id: selectedEgg.attributes.id,
+ nest_id: selectedNest.attributes.id,
+ docker_image: actualDockerImage,
+ startup_command: customStartup,
+ environment: filteredEnvironment,
+ should_backup: shouldBackup,
+ should_wipe: shouldWipe,
+ });
+ }
toast.success('Software change operation started successfully');
- // Reset the configuration flow but keep the modal open
resetFlow();
} catch (error) {
console.error('Failed to start egg change operation:', error);
@@ -888,11 +903,10 @@ const SoftwareContainer = () => {
handleVariableChange(variable.env_variable, e.target.value)
}
placeholder={variable.default_value || 'Enter value...'}
- className={`w-full px-3 py-2 bg-[#ffffff08] border rounded-lg text-sm text-neutral-200 placeholder:text-neutral-500 focus:outline-none transition-colors ${
- variableErrors[variable.env_variable]
- ? 'border-red-500 focus:border-red-500'
- : 'border-[#ffffff12] focus:border-brand'
- }`}
+ className={`w-full px-3 py-2 bg-[#ffffff08] border rounded-lg text-sm text-neutral-200 placeholder:text-neutral-500 focus:outline-none transition-colors ${variableErrors[variable.env_variable]
+ ? 'border-red-500 focus:border-red-500'
+ : 'border-[#ffffff12] focus:border-brand'
+ }`}
/>
{variableErrors[variable.env_variable] && (
@@ -933,11 +947,11 @@ const SoftwareContainer = () => {
{backupLimit !== 0 &&
- (backupLimit === null || (backups?.backupCount || 0) < backupLimit)
+ (backupLimit === null || (backups?.backupCount || 0) < backupLimit)
? 'Automatically create a backup before applying changes'
: backupLimit === 0
- ? 'Backups are disabled for this server'
- : 'Backup limit reached'}
+ ? 'Backups are disabled for this server'
+ : 'Backup limit reached'}
@@ -1095,26 +1109,23 @@ const SoftwareContainer = () => {
{eggPreview.warnings.map((warning, index) => (
{warning.type === 'subdomain_incompatible'
? 'Subdomain Will Be Deleted'
@@ -1188,7 +1199,33 @@ const SoftwareContainer = () => {
);
}
-
+ function RenderOperationModal() {
+ if (daemonType == 'elytra') {
+ return (
+
+ );
+ }
+ if (daemonType == 'wings') {
+ return (
+
+ );
+ }
+ return Could not find Operation Modal for this daemon: Using ${daemonType}
;
+ }
return (
@@ -1277,14 +1314,7 @@ const SoftwareContainer = () => {
{/* Operation Progress Modal */}
-
+ {RenderOperationModal()}
);
};
diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx
index e68902f1f..9ab5c5aab 100644
--- a/resources/scripts/routers/ServerRouter.tsx
+++ b/resources/scripts/routers/ServerRouter.tsx
@@ -361,7 +361,7 @@ const ServerRouter = () => {
className='relative inset-[1px] w-full h-full overflow-y-auto overflow-x-hidden rounded-md bg-[#08080875]'
>
{inConflictState &&
- (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`))) ? (
+ (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`))) ? (
) : (
diff --git a/resources/views/admin/eggs/new.blade.php b/resources/views/admin/eggs/new.blade.php
index 7f77fec9a..df216c6d6 100644
--- a/resources/views/admin/eggs/new.blade.php
+++ b/resources/views/admin/eggs/new.blade.php
@@ -150,7 +150,7 @@
});
$('#pNestId').on('change', function (event) {
$('#pConfigFrom').html('None ').select2({
- data: $.map(_.get(Pterodactyl.nests, $(this).val() + '.eggs', []), function (item) {
+ data: $.map(_.get(Pyrodactyl.nests, $(this).val() + '.eggs', []), function (item) {
return {
id: item.id,
text: item.name + ' <' + item.author + '>',
diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php
index 2db778ba9..884d188ca 100644
--- a/resources/views/admin/index.blade.php
+++ b/resources/views/admin/index.blade.php
@@ -23,6 +23,7 @@
You are running Pyrodactyl panel version {{ config('app.version') }}.
+
- @php
- $stats = app('Pterodactyl\Repositories\Eloquent\NodeRepository')->getUsageStatsRaw($node);
- $memoryPercent = ($stats['memory']['value'] / $stats['memory']['base_limit']) * 100;
- $diskPercent = ($stats['disk']['value'] / $stats['disk']['base_limit']) * 100;
-
- $memoryColor = $memoryPercent < 50 ? '#50af51' : ($memoryPercent < 70 ? '#e0a800' : '#d9534f');
- $diskColor = $diskPercent < 50 ? '#50af51' : ($diskPercent < 70 ? '#e0a800' : '#d9534f');
-
- $allocatedMemory = humanizeSize($stats['memory']['value'] * 1024 * 1024);
- $totalMemory = humanizeSize($stats['memory']['max'] * 1024 * 1024);
- $allocatedDisk = humanizeSize($stats['disk']['value'] * 1024 * 1024);
- $totalDisk = humanizeSize($stats['disk']['max'] * 1024 * 1024);
- @endphp
- {{ round($memoryPercent) }}%
- {{ $allocatedMemory }}
- {{ $totalMemory }}
- {{ round($diskPercent) }}%
- {{ $allocatedDisk }}
- {{ $totalDisk }}
+ {{ $node->memory_percent }}%
+ {{ $node->allocated_memory }}
+ {{ $node->total_memory }}
+ {{ $node->disk_percent }}%
+ {{ $node->allocated_disk }}
+ {{ $node->total_disk }}
{{ $node->servers_count }}
+ {{ $node->daemonType }}
@endforeach
diff --git a/resources/views/admin/nodes/new.blade.php b/resources/views/admin/nodes/new.blade.php
index c90cfedf6..9bd33d0ad 100644
--- a/resources/views/admin/nodes/new.blade.php
+++ b/resources/views/admin/nodes/new.blade.php
@@ -39,6 +39,26 @@
@endforeach
+
+ Daemon
+
+ @foreach($daemonTypes as $daemon => $label)
+
+ {{ $label }}
+
+ @endforeach
+
+
+
+
+
@@ -190,5 +210,39 @@
@parent
@endsection
diff --git a/resources/views/admin/nodes/view/configuration.blade.php b/resources/views/admin/nodes/view/configuration.blade.php
index b8f0dc023..124e5bfbe 100644
--- a/resources/views/admin/nodes/view/configuration.blade.php
+++ b/resources/views/admin/nodes/view/configuration.blade.php
@@ -70,11 +70,14 @@
url: '{{ route('admin.nodes.view.configuration.token', $node->id) }}',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
}).done(function (data) {
+
+ var commandTemplate = "{!! addslashes($node->getAutoDeploy("PLACEHOLDER_TOKEN")) !!}";
+ var command = commandTemplate.replace('PLACEHOLDER_TOKEN', data.token);
swal({
type: 'success',
title: 'Token created.',
- text: 'To auto-configure your node run the following command:cd /etc/elytra && sudo elytra configure --panel-url {{ config('app.url') }} --token ' + data.token + ' --node ' + data.node + '{{ config('app.debug') ? ' --allow-insecure' : '' }}
',
- html: true
+ text: "To auto-configure your node run the following command:" + command + "
",
+ html: true,
})
}).fail(function () {
swal({
diff --git a/resources/views/admin/nodes/view/settings.blade.php b/resources/views/admin/nodes/view/settings.blade.php
index 81696b997..3c536ef1d 100644
--- a/resources/views/admin/nodes/view/settings.blade.php
+++ b/resources/views/admin/nodes/view/settings.blade.php
@@ -57,12 +57,18 @@
@foreach($locations as $location)
- location_id) === $location->id) ? 'selected' : '' }}>{{ $location->long }} ({{ $location->short }})
- @endforeach
+ location_id) === $location->id) ? 'selected' : '' }}>{{ $location->long }} ({{ $location->short }})
+ @endforeach
+ Daemon
+
+
+ @foreach($daemonTypes as $daemon)
+ daemonType) ? 'selected' : '' }}>{{ $daemon }}
+ @endforeach
+
-
+
+
+