feat(v5): add server clusters and CLI bootstrap

Create v5 cluster/server persistence, expose them on the home page,
and add a bootstrap endpoint backed by the coolify CLI. Add dev Lima
server sync support and update the dev script firewall flow.
This commit is contained in:
Andras Bacsai
2026-06-16 15:45:16 +02:00
parent 586588196d
commit 1e038fecae
16 changed files with 1389 additions and 108 deletions
+233 -16
View File
@@ -1,9 +1,33 @@
import { Head } from '@inertiajs/react';
import { useState } from 'react';
export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
export default function Home({ status, currentTeam, teams, flux, clusters, cooldServers, privateKeys }) {
const firstPrivateKey = privateKeys[0]?.uuid || '';
const [coolify, setCoolify] = useState(null);
const [checkingCoolify, setCheckingCoolify] = useState(false);
const [bootstrapResult, setBootstrapResult] = useState(null);
const [bootstrapping, setBootstrapping] = useState(false);
const [bootstrapForm, setBootstrapForm] = useState({
host: '',
ssh_user: 'root',
ssh_port: '22',
private_key_uuid: firstPrivateKey,
wg_listen_port: '',
wg_endpoint: '',
enable_builder: true,
builder_capacity: '2',
});
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
}
function updateBootstrapForm(field, value) {
setBootstrapForm((current) => ({
...current,
[field]: value,
}));
}
async function checkCoolifyCliVersion() {
setCheckingCoolify(true);
@@ -29,6 +53,49 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
}
}
async function bootstrapCoolifyMesh(event) {
event.preventDefault();
setBootstrapping(true);
setBootstrapResult(null);
const payload = {
host: bootstrapForm.host,
ssh_user: bootstrapForm.ssh_user,
ssh_port: Number(bootstrapForm.ssh_port),
private_key_uuid: bootstrapForm.private_key_uuid,
enable_builder: bootstrapForm.enable_builder,
builder_capacity: bootstrapForm.builder_capacity === '' ? null : Number(bootstrapForm.builder_capacity),
wg_listen_port: bootstrapForm.wg_listen_port === '' ? null : Number(bootstrapForm.wg_listen_port),
wg_endpoint: bootstrapForm.wg_endpoint || null,
};
try {
const response = await fetch('/v5/coolify/bootstrap', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify(payload),
});
const result = await response.json();
setBootstrapResult(result);
} catch (error) {
setBootstrapResult({
successful: false,
label: 'Bootstrap failed',
message: 'Could not start the coolify bootstrap command.',
output: null,
errorOutput: null,
exitCode: null,
});
} finally {
setBootstrapping(false);
}
}
return (
<>
<Head title="V5" />
@@ -55,22 +122,52 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
{flux.socket ? <p>Socket: {flux.socket}</p> : null}
</section>
<section aria-labelledby="coold-host-heading">
<h2 id="coold-host-heading">coold host</h2>
<section aria-labelledby="clusters-heading">
<h2 id="clusters-heading">Clusters</h2>
<ul>
{cooldHosts.map((host) => (
<li key={host.id}>
<strong>{host.id}</strong>
{host.wireguardIp ? ` (${host.wireguardIp})` : ''}:
{' '}
{host.capabilities.join(', ')}; builder{' '}
{host.builderEnabled
? `enabled, capacity ${host.builderCapacity}`
: 'disabled'}
</li>
))}
</ul>
{clusters.length === 0 ? (
<p>No clusters have been added yet.</p>
) : (
<ul>
{clusters.map((cluster) => (
<li key={cluster.id}>
<strong>{cluster.name}</strong> — {cluster.serversCount}{' '}
{cluster.serversCount === 1 ? 'server' : 'servers'}
{cluster.description ? <p>{cluster.description}</p> : null}
{cluster.servers.length > 0 ? (
<ul>
{cluster.servers.map((server) => (
<li key={server.id}>
{server.name} ({server.status}) — {server.host};{' '}
{server.capabilities.join(', ')}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="coold-server-heading">
<h2 id="coold-server-heading">coold servers</h2>
{cooldServers.length === 0 ? (
<p>No coold serverss have been added yet.</p>
) : (
<ul>
{cooldServers.map((host) => (
<li key={host.id}>
<strong>{host.host}</strong> ({host.status}) — SSH {host.sshUser}@{host.host}:{host.sshPort};{' '}
{host.capabilities.join(', ')}; builder{' '}
{host.builderEnabled
? `enabled, capacity ${host.builderCapacity}`
: 'disabled'}
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="coolify-heading">
@@ -90,6 +187,126 @@ export default function Home({ status, currentTeam, teams, flux, cooldHosts }) {
{coolify.binary ? <p>Binary: {coolify.binary}</p> : null}
</div>
) : null}
<form onSubmit={bootstrapCoolifyMesh}>
<h3>Bootstrap server</h3>
<label>
Host/IP
<input
type="text"
value={bootstrapForm.host}
onChange={(event) => updateBootstrapForm('host', event.target.value)}
placeholder="203.0.113.10"
required
/>
</label>
<label>
SSH user
<input
type="text"
value={bootstrapForm.ssh_user}
onChange={(event) => updateBootstrapForm('ssh_user', event.target.value)}
required
/>
</label>
<label>
SSH port
<input
type="number"
min="1"
max="65535"
value={bootstrapForm.ssh_port}
onChange={(event) => updateBootstrapForm('ssh_port', event.target.value)}
required
/>
</label>
<label>
Private key
<select
value={bootstrapForm.private_key_uuid}
onChange={(event) => updateBootstrapForm('private_key_uuid', event.target.value)}
required
>
<option value="" disabled>Select a private key</option>
{privateKeys.map((privateKey) => (
<option key={privateKey.uuid} value={privateKey.uuid}>
{privateKey.name}
</option>
))}
</select>
</label>
<details>
<summary>Advanced mesh options</summary>
<label>
WireGuard listen port override
<input
type="number"
min="1"
max="65535"
value={bootstrapForm.wg_listen_port}
onChange={(event) => updateBootstrapForm('wg_listen_port', event.target.value)}
/>
</label>
<label>
WireGuard endpoint override
<input
type="text"
value={bootstrapForm.wg_endpoint}
onChange={(event) => updateBootstrapForm('wg_endpoint', event.target.value)}
placeholder="host.example:51821"
/>
</label>
<label>
<input
type="checkbox"
checked={bootstrapForm.enable_builder}
onChange={(event) => updateBootstrapForm('enable_builder', event.target.checked)}
/>
Enable builder
</label>
<label>
Builder capacity
<input
type="number"
min="0"
max="100"
value={bootstrapForm.builder_capacity}
onChange={(event) => updateBootstrapForm('builder_capacity', event.target.value)}
/>
</label>
</details>
<button type="submit" disabled={bootstrapping || privateKeys.length === 0}>
{bootstrapping ? 'Bootstrapping server...' : 'Bootstrap server'}
</button>
{privateKeys.length === 0 ? (
<p>Add a private key before bootstrapping a server.</p>
) : null}
</form>
{bootstrapResult ? (
<div>
<p>
<strong>{bootstrapResult.label}</strong>
</p>
<p>{bootstrapResult.message}</p>
{bootstrapResult.exitCode !== null ? (
<p>Exit code: {bootstrapResult.exitCode}</p>
) : null}
{bootstrapResult.output ? <pre>{bootstrapResult.output}</pre> : null}
{bootstrapResult.errorOutput ? <pre>{bootstrapResult.errorOutput}</pre> : null}
</div>
) : null}
</section>
<h2>Current team</h2>