Compare commits

..

28 Commits

Author SHA1 Message Date
b602b579eb 更新 Speed/path.txt 2026-03-14 23:30:35 +08:00
7ba588497b 更新 Speed/ServerController.php 2026-03-14 23:29:34 +08:00
75fc311a11 更新 Speed/path.txt 2026-03-14 23:24:46 +08:00
6518831a26 更新 Speed/Server.php 2026-03-14 23:23:01 +08:00
b9d50fa717 更新 Speed/ServerUsagesSyncService.php 2026-03-14 23:15:32 +08:00
4bcb891f24 添加 Speed/ServerUsagesSyncService.php 2026-03-14 23:15:14 +08:00
8aab3ff282 更新 Speed/updateBuild.ts 2026-03-14 23:03:59 +08:00
0bd1398085 添加 Speed/updateBuild.ts 2026-03-14 23:03:37 +08:00
734869eb26 更新 Speed/ServerController.php 2026-03-14 22:59:59 +08:00
7a39e67a53 更新 Speed/ServerController.php 2026-03-14 22:42:12 +08:00
11c127c430 更新 Speed/path.txt 2026-03-14 22:33:15 +08:00
661b45ec82 更新 Speed/Server.php 2026-03-14 22:32:15 +08:00
40b9d1802b 添加 Speed/NetworkService.php 2026-03-14 16:45:24 +08:00
0e7cef6334 更新 Speed/ServerController.php 2026-03-14 16:44:13 +08:00
599a9e178a 更新 Speed/path.txt 2026-03-14 16:43:50 +08:00
d8c4425456 删除 ServerController.php 2026-03-14 16:43:27 +08:00
9a9868a964 上传文件至 Speed 2026-03-14 16:43:19 +08:00
dc05d524bb 添加 ServerController.php 2026-03-14 16:42:45 +08:00
f37b1e8952 更新 Speed/StoreServerRequest.php 2026-03-14 16:41:29 +08:00
90fda36588 更新 Speed/UpdateBuildRequest.php 2026-03-14 16:40:24 +08:00
4d5f1d1955 更新 Speed/path.txt 2026-03-14 16:21:45 +08:00
9bd09c8b18 更新 ServerBuildSettingsCard.tsx 2026-03-14 16:18:56 +08:00
afdbd1d535 添加 ServerBuildSettingsCard.tsx 2026-03-14 16:18:43 +08:00
72016cee81 更新 Speed/CreateServerModal.tsx 2026-03-14 16:16:49 +08:00
c453288a58 添加 Speed/ServerBuildSettingsCard.tsx 2026-03-14 16:12:15 +08:00
61520d9e2b 更新 Speed/CreateServerModal.tsx 2026-03-14 16:10:55 +08:00
2b452d817b 添加 Speed/CreateServerModal.tsx 2026-03-14 16:08:15 +08:00
a6a2c0d4f7 更新 Speed/path.txt 2026-03-14 15:33:31 +08:00
11 changed files with 1231 additions and 6 deletions

191
ServerBuildSettingsCard.tsx Normal file
View File

@ -0,0 +1,191 @@
import { AdminServerContext } from '@/state/admin/server'
import { useFlashKey } from '@/util/useFlash'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import updateBuild from '@/api/admin/servers/updateBuild'
import Button from '@/components/elements/Button'
import FlashMessageRender from '@/components/elements/FlashMessageRenderer'
import FormCard from '@/components/elements/FormCard'
import TextInputForm from '@/components/elements/forms/TextInputForm'
import AddressesMultiSelectForm from '@/components/admin/servers/AddressesMultiSelectForm'
const ServerBuildSettingsCard = () => {
const server = AdminServerContext.useStoreState(state => state.server.data!)
const setServer = AdminServerContext.useStoreActions(
actions => actions.server.setServer
)
const { clearFlashes, clearAndAddHttpError } = useFlashKey(
`admin.servers.${server.uuid}.settings.hardware.build`
)
const { t: tStrings } = useTranslation('strings')
const { t } = useTranslation('admin.servers.settings')
const { t: tIndex } = useTranslation('admin.servers.index')
const pluckedAddressIds = [
...server.limits.addresses.ipv4.map(address => address.id.toString()),
...server.limits.addresses.ipv6.map(address => address.id.toString()),
]
const schema = z.object({
cpu: z.preprocess(Number, z.number().min(1)),
memory: z.preprocess(Number, z.number().min(16)),
disk: z.preprocess(Number, z.number().min(1)),
addressIds: z.array(z.preprocess(Number, z.number())),
snapshotLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
backupLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
bandwidthLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
rateLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
bandwidthUsage: z.preprocess(Number, z.number().min(0)),
})
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
cpu: server.limits.cpu.toString(),
memory: (server.limits.memory / 1048576).toString(),
disk: (server.limits.disk / 1048576).toString(),
addressIds: pluckedAddressIds,
snapshotLimit: server.limits.snapshots?.toString() ?? '',
backupLimit: server.limits.backups?.toString() ?? '',
bandwidthLimit: server.limits.bandwidth
? (server.limits.bandwidth / 1048576).toString()
: '',
rateLimit: server.limits.rateLimit?.toString() ?? '',
bandwidthUsage: (server.usages.bandwidth / 1048576).toString(),
},
})
const submit = async (_data: any) => {
const {
memory,
disk,
snapshotLimit,
backupLimit,
bandwidthLimit,
rateLimit,
bandwidthUsage,
...data
} = _data as z.infer<typeof schema>
clearFlashes()
try {
const newServer = await updateBuild(server.uuid, {
memory: memory * 1048576,
disk: disk * 1048576,
snapshotLimit: snapshotLimit !== '' ? snapshotLimit : null,
backupLimit: backupLimit !== '' ? backupLimit : null,
bandwidthLimit:
bandwidthLimit !== '' ? bandwidthLimit * 1048576 : null,
rateLimit: rateLimit !== '' ? Number(rateLimit) : null,
bandwidthUsage: bandwidthUsage * 1048576,
...data,
})
setServer(newServer)
form.reset({
cpu: data.cpu.toString(),
memory: memory.toString(),
disk: disk.toString(),
addressIds: data.addressIds.map(id => id.toString()),
snapshotLimit: snapshotLimit.toString() ?? '',
backupLimit: backupLimit.toString() ?? '',
bandwidthLimit:
bandwidthLimit !== '' ? bandwidthLimit.toString() : '',
rateLimit: rateLimit !== '' ? rateLimit.toString() : '',
bandwidthUsage: bandwidthUsage.toString(),
})
} catch (error) {
clearAndAddHttpError(error as any)
}
}
return (
<FormCard className='w-full'>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<FormCard.Body>
<FormCard.Title>{t('build.title')}</FormCard.Title>
<div className='space-y-3 mt-3'>
<FlashMessageRender
byKey={`admin.servers.${server.uuid}.settings.hardware.build`}
/>
<TextInputForm name='cpu' label={tStrings('cpu')} />
<TextInputForm
name='memory'
label={`${tStrings('memory')} (MiB)`}
/>
<TextInputForm
name='disk'
label={`${tStrings('disk')} (MiB)`}
/>
<AddressesMultiSelectForm nodeId={server.nodeId} />
<TextInputForm
name='snapshotLimit'
label={tIndex('snapshot_limit')}
placeholder={'Leave blank for no limit'}
/>
<TextInputForm
name='backupLimit'
label={tIndex('backup_limit')}
placeholder={'Leave blank for no limit'}
/>
<TextInputForm
name='bandwidthLimit'
label={`${tIndex('bandwidth_limit')} (MiB)`}
placeholder={
tIndex('limit_placeholder') ??
'Leave blank for no limit'
}
/>
<TextInputForm
name='rateLimit'
label={`${tIndex('rate_limit')} (MiB/s)`}
placeholder={
tIndex('limit_placeholder') ??
'Leave blank for no limit'
}
/>
<TextInputForm
name='bandwidthUsage'
label={`${tIndex('bandwidth_usage')} (MiB)`}
/>
</div>
</FormCard.Body>
<FormCard.Footer>
<Button
loading={form.formState.isSubmitting}
disabled={!form.formState.isDirty}
type='submit'
variant='filled'
color='success'
size='sm'
>
{tStrings('save')}
</Button>
</FormCard.Footer>
</form>
</FormProvider>
</FormCard>
)
}
export default ServerBuildSettingsCard

313
Speed/CreateServerModal.tsx Normal file
View File

@ -0,0 +1,313 @@
import { useFlashKey } from '@/util/useFlash'
import usePagination from '@/util/usePagination'
import { hostname, password, usKeyboardCharacters } from '@/util/validation'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import createServer from '@/api/admin/servers/createServer'
import { ServerResponse } from '@/api/admin/servers/getServers'
import useServersSWR from '@/api/admin/servers/useServersSWR'
import FlashMessageRender from '@/components/elements/FlashMessageRenderer'
import Modal from '@/components/elements/Modal'
import CheckboxForm from '@/components/elements/forms/CheckboxForm'
import TextInputForm from '@/components/elements/forms/TextInputForm'
import AddressesMultiSelectForm from '@/components/admin/servers/AddressesMultiSelectForm'
import NodesSelectForm from '@/components/admin/servers/NodesSelectForm'
import TemplatesSelectForm from '@/components/admin/servers/TemplatesSelectForm'
import UsersSelectForm from '@/components/admin/servers/UsersSelectForm'
interface Props {
nodeId?: number
userId?: number
open: boolean
onClose: () => void
}
const CreateServerModal = ({ nodeId, userId, open, onClose }: Props) => {
const [page] = usePagination()
const { mutate } = useServersSWR({
nodeId,
userId,
page,
query: '',
include: ['node', 'user'],
})
const { clearFlashes, clearAndAddHttpError } = useFlashKey(
'admin.servers.create'
)
const { t } = useTranslation('admin.servers.index')
const { t: tStrings } = useTranslation('strings')
const schemaWithCreateVm = z.object({
name: z.string().max(40).nonempty(),
nodeId: z.preprocess(Number, z.number()),
userId: z.preprocess(Number, z.number()),
vmid: z.union([
z.preprocess(Number, z.number().int().min(100).max(999999999)),
z.literal(''),
]),
hostname: hostname().max(191).nonempty(),
addressIds: z.array(z.preprocess(Number, z.number())),
cpu: z.preprocess(Number, z.number().min(1)),
memory: z.preprocess(Number, z.number().min(16)),
disk: z.preprocess(Number, z.number().min(1)),
snapshotLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
backupLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
bandwidthLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
rateLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
accountPassword: password(usKeyboardCharacters()).nonempty(),
shouldCreateServer: z.literal(true),
startOnCompletion: z.boolean(),
templateUuid: z.string().nonempty(),
})
const schemaWithoutCreatingVm = z.object({
name: z.string().max(40).nonempty(),
nodeId: z.preprocess(Number, z.number()),
userId: z.preprocess(Number, z.number()),
vmid: z.union([
z.preprocess(Number, z.number().int().min(100).max(999999999)),
z.literal(''),
]),
hostname: hostname().max(191).nonempty(),
addressIds: z.array(z.preprocess(Number, z.number())),
cpu: z.preprocess(Number, z.number().min(1)),
memory: z.preprocess(Number, z.number().min(16)),
disk: z.preprocess(Number, z.number().min(1)),
snapshotLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
backupLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
bandwidthLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
rateLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
accountPassword: password(usKeyboardCharacters()).optional(),
shouldCreateServer: z.literal(false),
startOnCompletion: z.boolean(),
templateUuid: z.string(),
})
const schema = z.discriminatedUnion('shouldCreateServer', [
schemaWithCreateVm,
schemaWithoutCreatingVm,
])
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
name: '',
nodeId: nodeId?.toString() ?? '',
userId: userId?.toString() ?? '',
vmid: '',
hostname: '',
addressIds: [],
cpu: '',
memory: '',
disk: '',
snapshotLimit: '0',
backupLimit: '',
bandwidthLimit: '',
rateLimit: '',
accountPassword: '',
shouldCreateServer: true,
startOnCompletion: false,
templateUuid: '',
},
})
const watchShouldCreateServer = form.watch('shouldCreateServer')
const watchNodeId = form.watch('nodeId')
const submit = async (_data: any) => {
const {
vmid,
cpu,
memory,
disk,
snapshotLimit,
backupLimit,
bandwidthLimit,
rateLimit,
addressIds,
accountPassword,
...data
} = _data as z.infer<typeof schema>
clearFlashes()
try {
const server = await createServer({
...data,
vmid: vmid !== '' ? vmid : null,
limits: {
cpu,
memory: memory * 1048576,
disk: disk * 1048576,
snapshots: snapshotLimit !== '' ? snapshotLimit : null,
backups: backupLimit !== '' ? backupLimit : null,
bandwidth:
bandwidthLimit !== '' ? bandwidthLimit * 1048576 : null,
rateLimit: rateLimit !== '' ? Number(rateLimit) : null,
addressIds,
},
accountPassword: accountPassword ? accountPassword : null,
})
mutate(data => {
if (!data) return data
return {
...data,
items: [server, ...data.items],
} as ServerResponse
}, false)
handleClose()
} catch (e) {
clearAndAddHttpError(e as Error)
}
}
const handleClose = () => {
clearFlashes()
form.reset()
onClose()
}
return (
<Modal open={open} onClose={handleClose}>
<Modal.Header>
<Modal.Title>{t('create_modal.title')}</Modal.Title>
</Modal.Header>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<Modal.Body>
<FlashMessageRender
className='mb-5'
byKey={'admin.servers.create'}
/>
<TextInputForm
name={'name'}
label={tStrings('display_name')}
/>
{nodeId ? null : <NodesSelectForm />}
{userId ? null : <UsersSelectForm />}
<TextInputForm
name={'vmid'}
label={'VMID'}
placeholder={
t('vmid_placeholder') ??
'Leave blank for random VMID'
}
/>
<TextInputForm
name={'hostname'}
label={tStrings('hostname')}
/>
<AddressesMultiSelectForm
disabled={watchNodeId === ''}
/>
<div className={'grid grid-cols-2 gap-3'}>
<TextInputForm
name={'cpu'}
label={tStrings('cpu')}
/>
<TextInputForm
name={'memory'}
label={`${tStrings('memory')} (MiB)`}
/>
</div>
<TextInputForm
name={'disk'}
label={`${tStrings('disk')} (MiB)`}
/>
<div className={'grid grid-cols-2 gap-3'}>
<TextInputForm
name={'backupLimit'}
label={t('backup_limit')}
placeholder={
t('limit_placeholder') ??
'Leave blank for no limit'
}
/>
<TextInputForm
name={'bandwidthLimit'}
label={`${t('bandwidth_limit')} (MiB)`}
placeholder={
t('limit_placeholder') ??
'Leave blank for no limit'
}
/>
</div>
<TextInputForm
name={'rateLimit'}
label={`${t('rate_limit')} (MiB/s)`}
placeholder={
t('limit_placeholder') ??
'Leave blank for no limit'
}
/>
<TextInputForm
name={'accountPassword'}
label={tStrings('system_os_password')}
type={'password'}
/>
<CheckboxForm
name={'shouldCreateServer'}
label={t('should_create_vm')}
className={'mt-3 relative'}
/>
<TemplatesSelectForm
disabled={
!watchShouldCreateServer || watchNodeId === ''
}
/>
<CheckboxForm
name={'startOnCompletion'}
label={t('start_server_after_installing')}
className={'mt-3 relative'}
/>
</Modal.Body>
<Modal.Actions>
<Modal.Action type='button' onClick={handleClose}>
{tStrings('cancel')}
</Modal.Action>
<Modal.Action
type='submit'
loading={form.formState.isSubmitting}
>
{tStrings('create')}
</Modal.Action>
</Modal.Actions>
</form>
</FormProvider>
</Modal>
)
}
export default CreateServerModal

260
Speed/NetworkService.php Normal file
View File

@ -0,0 +1,260 @@
<?php
namespace Convoy\Services\Servers;
use Convoy\Data\Server\Deployments\CloudinitAddressConfigData;
use Convoy\Data\Server\Eloquent\ServerAddressesData;
use Convoy\Data\Server\MacAddressData;
use Convoy\Enums\Network\AddressType;
use Convoy\Models\Address;
use Convoy\Models\Server;
use Convoy\Repositories\Eloquent\AddressRepository;
use Convoy\Repositories\Proxmox\Server\ProxmoxCloudinitRepository;
use Convoy\Repositories\Proxmox\Server\ProxmoxConfigRepository;
use Convoy\Repositories\Proxmox\Server\ProxmoxFirewallRepository;
use Illuminate\Support\Arr;
use function collect;
use function is_null;
class NetworkService
{
public function __construct(
private AddressRepository $repository,
private ProxmoxFirewallRepository $firewallRepository,
private CloudinitService $cloudinitService,
private ProxmoxCloudinitRepository $cloudinitRepository,
private ProxmoxConfigRepository $allocationRepository,
) {
}
public function deleteIpset(Server $server, string $name)
{
$this->firewallRepository->setServer($server);
$addresses = array_column($this->firewallRepository->getLockedIps($name), 'cidr');
foreach ($addresses as $address) {
$this->firewallRepository->unlockIp($name, $address);
}
return $this->firewallRepository->deleteIpset($name);
}
public function clearIpsets(Server $server): void
{
$this->firewallRepository->setServer($server);
$ipSets = array_column($this->firewallRepository->getIpsets(), 'name');
foreach ($ipSets as $ipSet) {
$this->deleteIpset($server, $ipSet);
}
}
public function lockIps(Server $server, array $addresses, string $ipsetName): void
{
$this->firewallRepository->setServer($server);
$this->firewallRepository->createIpset($ipsetName);
foreach ($addresses as $address) {
$this->firewallRepository->lockIp($ipsetName, $address);
}
}
public function getMacAddresses(Server $server, bool $eloquent = true, bool $proxmox = false): MacAddressData
{
if ($eloquent) {
$addresses = $this->getAddresses($server);
$eloquentMacAddress = $addresses->ipv4->first(
)?->mac_address ?? $addresses->ipv6->first()?->mac_address;
}
if ($proxmox) {
$config = $this->cloudinitRepository->setServer($server)->getConfig();
$proxmoxMacAddress = null;
if (preg_match(
"/\b[[:xdigit:]]{2}:[[:xdigit:]]{2}:[[:xdigit:]]{2}:[[:xdigit:]]{2}:[[:xdigit:]]{2}:[[:xdigit:]]{2}\b/su",
Arr::get($config, 'net0', ''),
$matches,
)) {
$proxmoxMacAddress = $matches[0];
}
}
return MacAddressData::from([
'eloquent' => $eloquentMacAddress ?? null,
'proxmox' => $proxmoxMacAddress ?? null,
]);
}
public function getAddresses(Server $server): ServerAddressesData
{
return ServerAddressesData::from([
'ipv4' => array_values(
$server->addresses->where('type', AddressType::IPV4->value)->toArray(),
),
'ipv6' => array_values(
$server->addresses->where('type', AddressType::IPV6->value)->toArray(),
),
]);
}
public function syncSettings(Server $server): void
{
$macAddresses = $this->getMacAddresses($server, true, true);
$addresses = $this->getAddresses($server);
$this->clearIpsets($server);
$this->cloudinitService->updateIpConfig($server, CloudinitAddressConfigData::from([
'ipv4' => $addresses->ipv4->first()?->toArray(),
'ipv6' => $addresses->ipv6->first()?->toArray(),
]));
$this->lockIps(
$server,
array_unique(Arr::flatten($server->addresses()->get(['address'])->toArray())),
'ipfilter-net0',
);
$this->firewallRepository->setServer($server)->updateOptions([
'enable' => true,
'ipfilter' => true,
'policy_in' => 'ACCEPT',
'policy_out' => 'ACCEPT',
]);
$macAddress = $macAddresses->eloquent ?? $macAddresses->proxmox;
$this->allocationRepository->setServer($server)->update(
['net0' => "virtio={$macAddress},bridge={$server->node->network},firewall=1"],
);
}
public function updateRateLimit(Server $server, ?float $mebibytes = null): void
{
$macAddresses = $this->getMacAddresses($server, true, true);
$macAddress = $macAddresses->eloquent ?? $macAddresses->proxmox;
$rawConfig = $this->allocationRepository->setServer($server)->getConfig();
$networkConfig = collect($rawConfig)->where('key', '=', 'net0')->first();
if (is_null($networkConfig)) {
return;
}
$parsedConfig = $this->parseConfig($networkConfig['value']);
// List of possible models
$models = ['e1000', 'e1000-82540em', 'e1000-82544gc', 'e1000-82545em', 'e1000e', 'i82551', 'i82557b', 'i82559er', 'ne2k_isa', 'ne2k_pci', 'pcnet', 'rtl8139', 'virtio', 'vmxnet3'];
// Update the model with the new MAC address
$modelFound = false;
foreach ($parsedConfig as $item) {
if (in_array($item->key, $models)) {
$item->value = $macAddress;
$modelFound = true;
break;
}
}
// If no model key exists, add the default model with the MAC address
if (!$modelFound) {
$parsedConfig[] = (object) ['key' => 'virtio', 'value' => $macAddress];
}
// Update or create the bridge value
$bridgeFound = false;
foreach ($parsedConfig as $item) {
if ($item->key === 'bridge') {
$item->value = $server->node->network;
$bridgeFound = true;
break;
}
}
if (!$bridgeFound) {
$parsedConfig[] = (object) ['key' => 'bridge', 'value' => $server->node->network];
}
// Update or create the firewall key
$firewallFound = false;
foreach ($parsedConfig as $item) {
if ($item->key === 'firewall') {
$item->value = 1;
$firewallFound = true;
break;
}
}
if (!$firewallFound) {
$parsedConfig[] = (object) ['key' => 'firewall', 'value' => 1];
}
// Handle the rate limit
if (is_null($mebibytes)) {
// Remove the 'rate' key if $mebibytes is null
$parsedConfig = array_filter($parsedConfig, fn ($item) => $item->key !== 'rate');
} else {
// Add or update the 'rate' key
$rateUpdated = false;
foreach ($parsedConfig as $item) {
if ($item->key === 'rate') {
$item->value = $mebibytes;
$rateUpdated = true;
break;
}
}
if (!$rateUpdated) {
$parsedConfig[] = (object) ['key' => 'rate', 'value' => $mebibytes];
}
}
// Rebuild the configuration string
$newConfig = implode(',', array_map(fn ($item) => "{$item->key}={$item->value}", $parsedConfig));
// Update the Proxmox configuration
$this->allocationRepository->setServer($server)->update(['net0' => $newConfig]);
}
private function parseConfig(string $config): array
{
// Split components by commas
$components = explode(',', $config);
// Array to hold the parsed objects
$parsedObjects = [];
foreach ($components as $component) {
// Split each component into key and value
[$key, $value] = explode('=', $component);
// Create an associative array (or object) for key-value pairs
$parsedObjects[] = (object) ['key' => $key, 'value' => $value];
}
return $parsedObjects;
}
public function updateAddresses(Server $server, array $addressIds): void
{
$currentAddresses = $server->addresses()->get()->pluck('id')->toArray();
$addressesToAdd = array_diff($addressIds, $currentAddresses);
$addressesToRemove = array_filter(
$currentAddresses,
fn ($id) => !in_array($id, $addressIds),
);
if (!empty($addressesToAdd)) {
$this->repository->attachAddresses($server, $addressesToAdd);
}
if (!empty($addressesToRemove)) {
Address::query()
->where('server_id', $server->id)
->whereIn('id', $addressesToRemove)
->update(['server_id' => null]);
}
}
}

View File

@ -38,11 +38,11 @@ class Server extends Model
'cpu' => 'required|numeric|min:1',
'memory' => 'required|numeric|min:16777216',
'disk' => 'required|numeric|min:1',
'bandwidth_usage' => 'sometimes|numeric|min:0',
'snapshot_limit' => 'present|nullable|integer|min:0',
'backup_limit' => 'present|nullable|integer|min:0',
'bandwidth_limit' => 'present|nullable|integer|min:0',
'rate_limit' => 'sometimes|nullable|numeric|min:0',
'bandwidth_usage' => 'nullable|numeric|min:0',
'snapshot_limit' => 'nullable|integer|min:0',
'backup_limit' => 'nullable|integer|min:0',
'bandwidth_limit' => 'nullable|integer|min:0',
'rate_limit' => 'nullable|numeric|min:0',
'hydrated_at' => 'nullable|date',
];

View File

@ -0,0 +1,175 @@
import { AdminServerContext } from '@/state/admin/server'
import { useFlashKey } from '@/util/useFlash'
import { zodResolver } from '@hookform/resolvers/zod'
import { FormProvider, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import updateBuild from '@/api/admin/servers/updateBuild'
import Button from '@/components/elements/Button'
import FlashMessageRender from '@/components/elements/FlashMessageRenderer'
import FormCard from '@/components/elements/FormCard'
import TextInputForm from '@/components/elements/forms/TextInputForm'
import AddressesMultiSelectForm from '@/components/admin/servers/AddressesMultiSelectForm'
const ServerBuildSettingsCard = () => {
const server = AdminServerContext.useStoreState(state => state.server.data!)
const setServer = AdminServerContext.useStoreActions(
actions => actions.server.setServer
)
const { clearFlashes, clearAndAddHttpError } = useFlashKey(
`admin.servers.${server.uuid}.settings.hardware.build`
)
const { t: tStrings } = useTranslation('strings')
const { t } = useTranslation('admin.servers.settings')
const { t: tIndex } = useTranslation('admin.servers.index')
const pluckedAddressIds = [
...server.limits.addresses.ipv4.map(address => address.id.toString()),
...server.limits.addresses.ipv6.map(address => address.id.toString()),
]
const schema = z.object({
cpu: z.preprocess(Number, z.number().min(1)),
memory: z.preprocess(Number, z.number().min(16)),
disk: z.preprocess(Number, z.number().min(1)),
addressIds: z.array(z.preprocess(Number, z.number())),
snapshotLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
backupLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
bandwidthLimit: z.union([
z.literal(''),
z.preprocess(Number, z.number().min(0)),
]),
bandwidthUsage: z.preprocess(Number, z.number().min(0)),
})
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
cpu: server.limits.cpu.toString(),
memory: (server.limits.memory / 1048576).toString(),
disk: (server.limits.disk / 1048576).toString(),
addressIds: pluckedAddressIds,
snapshotLimit: server.limits.snapshots?.toString() ?? '',
backupLimit: server.limits.backups?.toString() ?? '',
bandwidthLimit: server.limits.bandwidth
? (server.limits.bandwidth / 1048576).toString()
: '',
bandwidthUsage: (server.usages.bandwidth / 1048576).toString(),
},
})
const submit = async (_data: any) => {
const {
memory,
disk,
snapshotLimit,
backupLimit,
bandwidthLimit,
bandwidthUsage,
...data
} = _data as z.infer<typeof schema>
clearFlashes()
try {
const newServer = await updateBuild(server.uuid, {
memory: memory * 1048576,
disk: disk * 1048576,
snapshotLimit: snapshotLimit !== '' ? snapshotLimit : null,
backupLimit: backupLimit !== '' ? backupLimit : null,
bandwidthLimit:
bandwidthLimit !== '' ? bandwidthLimit * 1048576 : null,
bandwidthUsage: bandwidthUsage * 1048576,
...data,
})
setServer(newServer)
form.reset({
cpu: data.cpu.toString(),
memory: memory.toString(),
disk: disk.toString(),
addressIds: data.addressIds.map(id => id.toString()),
snapshotLimit: snapshotLimit.toString() ?? '',
backupLimit: backupLimit.toString() ?? '',
bandwidthLimit:
bandwidthLimit !== '' ? bandwidthLimit.toString() : '',
bandwidthUsage: bandwidthUsage.toString(),
})
} catch (error) {
clearAndAddHttpError(error as any)
}
}
return (
<FormCard className='w-full'>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<FormCard.Body>
<FormCard.Title>{t('build.title')}</FormCard.Title>
<div className='space-y-3 mt-3'>
<FlashMessageRender
byKey={`admin.servers.${server.uuid}.settings.hardware.build`}
/>
<TextInputForm name='cpu' label={tStrings('cpu')} />
<TextInputForm
name='memory'
label={`${tStrings('memory')} (MiB)`}
/>
<TextInputForm
name='disk'
label={`${tStrings('disk')} (MiB)`}
/>
<AddressesMultiSelectForm nodeId={server.nodeId} />
<TextInputForm
name='snapshotLimit'
label={tIndex('snapshot_limit')}
placeholder={'Leave blank for no limit'}
/>
<TextInputForm
name='backupLimit'
label={tIndex('backup_limit')}
placeholder={'Leave blank for no limit'}
/>
<TextInputForm
name='bandwidthLimit'
label={`${tIndex('bandwidth_limit')} (MiB)`}
placeholder={
tIndex('limit_placeholder') ??
'Leave blank for no limit'
}
/>
<TextInputForm
name='bandwidthUsage'
label={`${tIndex('bandwidth_usage')} (MiB)`}
/>
</div>
</FormCard.Body>
<FormCard.Footer>
<Button
loading={form.formState.isSubmitting}
disabled={!form.formState.isDirty}
type='submit'
variant='filled'
color='success'
size='sm'
>
{tStrings('save')}
</Button>
</FormCard.Footer>
</form>
</FormProvider>
</FormCard>
)
}
export default ServerBuildSettingsCard

163
Speed/ServerController.php Normal file
View File

@ -0,0 +1,163 @@
<?php
namespace Convoy\Http\Controllers\Admin;
use Convoy\Enums\Server\Status;
use Convoy\Enums\Server\SuspensionAction;
use Convoy\Exceptions\Repository\Proxmox\ProxmoxConnectionException;
use Convoy\Http\Controllers\ApiController;
use Convoy\Http\Requests\Admin\Servers\Settings\UpdateBuildRequest;
use Convoy\Http\Requests\Admin\Servers\Settings\UpdateGeneralInfoRequest;
use Convoy\Http\Requests\Admin\Servers\StoreServerRequest;
use Convoy\Models\Filters\FiltersServerByAddressPoolId;
use Convoy\Models\Filters\FiltersServerWildcard;
use Convoy\Models\Server;
use Convoy\Services\Servers\CloudinitService;
use Convoy\Services\Servers\NetworkService;
use Convoy\Services\Servers\ServerCreationService;
use Convoy\Services\Servers\ServerDeletionService;
use Convoy\Services\Servers\ServerSuspensionService;
use Convoy\Services\Servers\SyncBuildService;
use Convoy\Transformers\Admin\ServerBuildTransformer;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\Request;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
class ServerController extends ApiController
{
public function __construct(
private ConnectionInterface $connection,
private ServerDeletionService $deletionService,
private NetworkService $networkService,
private ServerSuspensionService $suspensionService,
private ServerCreationService $creationService,
private CloudinitService $cloudinitService,
private SyncBuildService $buildModificationService,
)
{
}
public function index(Request $request)
{
$servers = QueryBuilder::for(Server::query())
->with(['addresses', 'user', 'node'])
->defaultSort('-id')
->allowedFilters(
[
AllowedFilter::custom(
'*', new FiltersServerWildcard(),
),
AllowedFilter::custom(
'address_pool_id',
new FiltersServerByAddressPoolId(),
),
AllowedFilter::exact('node_id'),
AllowedFilter::exact('user_id'),
'name',
],
)
->paginate(min($request->query('per_page', 50), 100))->appends(
$request->query(),
);
return fractal($servers, new ServerBuildTransformer())->parseIncludes($request->include)
->respond();
}
public function show(Request $request, Server $server)
{
$server->load(['addresses', 'user', 'node']);
return fractal($server, new ServerBuildTransformer())->parseIncludes($request->include)
->respond();
}
public function store(StoreServerRequest $request)
{
$server = $this->creationService->handle($request->validated());
$server->load(['addresses', 'user', 'node']);
return fractal($server, new ServerBuildTransformer())->parseIncludes(['user', 'node'])
->respond();
}
public function update(UpdateGeneralInfoRequest $request, Server $server)
{
$this->connection->transaction(function () use ($request, $server) {
if ($request->hostname !== $server->hostname && !empty($request->hostname)) {
try {
$this->cloudinitService->updateHostname($server, $request->hostname);
} catch (ProxmoxConnectionException) {
throw new ServiceUnavailableHttpException(
message: "Server {$server->uuid} failed to sync hostname.",
);
}
}
$server->update($request->validated());
});
$server->load(['addresses', 'user', 'node']);
return fractal($server, new ServerBuildTransformer())->parseIncludes(['user', 'node'])
->respond();
}
public function updateBuild(UpdateBuildRequest $request, Server $server)
{
$validated = $request->validated();
// Handle address_ids separately
$addressIds = $validated['address_ids'] ?? null;
unset($validated['address_ids']);
// Ensure rate_limit is always present in update data to avoid validation errors
if (!array_key_exists('rate_limit', $validated)) {
$validated['rate_limit'] = $server->rate_limit ?? null;
}
// Update server with validated data, skip model validation as request validation is already done
$server->skipValidation()->update($validated);
$this->networkService->updateAddresses($server, $addressIds ?? []);
try {
$this->buildModificationService->handle($server);
} catch (ProxmoxConnectionException $e) {
// do nothing
}
$server->load(['addresses', 'user', 'node']);
return fractal($server, new ServerBuildTransformer())->parseIncludes(['user', 'node'])
->respond();
}
public function suspend(Server $server)
{
$this->suspensionService->toggle($server);
return $this->returnNoContent();
}
public function unsuspend(Server $server)
{
$this->suspensionService->toggle($server, SuspensionAction::UNSUSPEND);
return $this->returnNoContent();
}
public function destroy(Request $request, Server $server)
{
$this->connection->transaction(function () use ($server, $request) {
$server->update(['status' => Status::DELETING->value]);
$this->deletionService->handle($server, $request->input('no_purge', false));
});
return $this->returnNoContent();
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Convoy\Services\Nodes;
use Carbon\Carbon;
use Convoy\Models\Node;
use Convoy\Models\Server;
use Convoy\Enums\Server\MetricTimeframe;
use Convoy\Repositories\Proxmox\Server\ProxmoxMetricsRepository;
use Convoy\Exceptions\Repository\Proxmox\ProxmoxConnectionException;
class ServerUsagesSyncService
{
public function __construct(private ProxmoxMetricsRepository $repository)
{
}
public function handle(Node $node)
{
$servers = $node->servers;
$servers->each(function (Server $server) {
try {
$metrics = $this->repository->setServer($server)->getMetrics(MetricTimeframe::HOUR);
$bandwidth = $server->bandwidth_usage;
$endingDate = $server->hydrated_at ? Carbon::parse($server->hydrated_at) : Carbon::now()->firstOfMonth();
foreach ($metrics as $metric) {
if (Carbon::createFromTimestamp($metric['time'])->gt($endingDate)) {
// we multiply it by 60 seconds because each metric is
// recorded every 1 minute but the values like netin and
// netout are in bytes/sec
$bandwidth += (int) $metric['netin'] * 60 + (int) $metric['netout'] * 60;
}
}
if ($bandwidth > 0) {
$server->skipValidation()->update([
'bandwidth_usage' => $bandwidth,
'hydrated_at' => now(),
]);
}
} catch (ProxmoxConnectionException $e) {
// do nothing
}
});
}
}

View File

@ -15,6 +15,16 @@ use Illuminate\Validation\Validator;
*/
class StoreServerRequest extends BaseApiRequest
{
protected function prepareForValidation()
{
// Map camelCase to snake_case for rateLimit
$this->merge([
'limits' => [
'rate_limit' => $this->input('limits.rateLimit'),
],
]);
}
public function rules(): array
{
$rules = Server::getRules();

View File

@ -10,6 +10,14 @@ use Convoy\Http\Requests\BaseApiRequest;
class UpdateBuildRequest extends BaseApiRequest
{
protected function prepareForValidation()
{
// Map camelCase to snake_case for rateLimit
$this->merge([
'rate_limit' => $this->input('rateLimit'),
]);
}
public function rules(): array
{
$server = $this->parameter('server', Server::class);

View File

@ -9,11 +9,24 @@ convoy/app/Services/Servers/ServerCreationService.php
convoy/app/Services/Servers/SyncBuildService.php
convoy/app/Http/Controllers/Admin/ServerController.php
convoy/app/Services/Nodes/ServerUsagesSyncService.php
#FRONT END REBUILD
npm install
npm run build
#DATA BASE Migration (Convoy)
php artisan migrate
#USE DOCKER (Convoy)
#DATA BASE Migration USE DOCKER (Convoy)
docker compose exec workspace bash -c "php artisan migrate"
docker compose exec workspace bash -c "php artisan cache:clear"
docker compose exec workspace bash -c "php artisan config:clear"
docker compose exec workspace bash -c "php artisan route:clear"
docker compose exec workspace bash -c "php artisan horizon:terminate"
docker compose exec workspace bash -c "php artisan queue:restart"
#RESTART (Convoy)
docker compose down

43
Speed/updateBuild.ts Normal file
View File

@ -0,0 +1,43 @@
import { rawDataToAdminServer } from '@/api/admin/servers/getServer'
import http from '@/api/http'
interface UpdateServerBuildParameters {
cpu: number
memory: number
disk: number
addressIds: number[]
snapshotLimit: number | null
backupLimit: number | null
bandwidthLimit: number | null
rateLimit: number | null
bandwidthUsage: number
}
const updateBuild = async (
serverUuid: string,
{
addressIds,
snapshotLimit,
backupLimit,
bandwidthLimit,
rateLimit,
bandwidthUsage,
...params
}: UpdateServerBuildParameters
) => {
const {
data: { data },
} = await http.patch(`/api/admin/servers/${serverUuid}/settings/build`, {
address_ids: addressIds,
snapshot_limit: snapshotLimit,
backup_limit: backupLimit,
bandwidth_limit: bandwidthLimit,
rate_limit: rateLimit,
bandwidth_usage: bandwidthUsage,
...params,
})
return rawDataToAdminServer(data)
}
export default updateBuild