fix: autosave, cell name overflow, length validation, apply-and-verify tests
Autosave on Apply (was broken):
- App.jsx called useDraftConfig() in the same component that rendered
DraftConfigProvider — a component cannot consume context it provides.
Fixed by splitting into AppCore (consumes context, all logic) and App
(thin shell that wraps AppCore in DraftConfigProvider). The hook now
runs inside the provider and hasDirty()/flushAll() work correctly.
Cell name / domain length validation (255-char DNS standard):
- api/app.py: reject cell_name or domain > 255 chars or empty with 400
- api/app.py: reject ip_range without CIDR prefix (bare IPs shift all VIPs)
- webui/src/pages/Settings.jsx: cellNameError + domainError computed values
block saveIdentity and show inline error; maxLength={255} on inputs
- tests/test_identity_validation.py: 8 unit tests for the new validation
Cell name overflow on all pages:
- Dashboard.jsx: add min-w-0 to flex child div + truncate + title on cell_name
- CellNetwork.jsx: min-w-0 + truncate + title on cell_name, domain, endpoint,
vpn_subnet in invite cards and connected-cells list
Apply-and-verify integration tests:
- tests/integration/test_apply_propagation.py: TestPendingState (no restarts)
and TestApplyAndVerify (triggers real container restart + health poll)
covering the full save → apply → wait → verify propagation lifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+10
-2
@@ -129,7 +129,9 @@ function PendingRestartBanner({ pending, onApply, onCancel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
// AppCore is the real application — it consumes DraftConfigContext and must
|
||||
// be rendered inside DraftConfigProvider (see App below).
|
||||
function AppCore() {
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [pending, setPending] = useState({ needs_restart: false, changes: [] });
|
||||
@@ -243,7 +245,6 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<DraftConfigProvider>
|
||||
<Router>
|
||||
<ConfigProvider>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@@ -326,6 +327,13 @@ function App() {
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DraftConfigProvider>
|
||||
<AppCore />
|
||||
</DraftConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,21 +175,21 @@ export default function CellNetwork() {
|
||||
) : invite ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Cell</span>
|
||||
<span className="font-mono font-medium">{invite.cell_name}</span>
|
||||
<div className="flex justify-between gap-2 min-w-0">
|
||||
<span className="text-gray-500 shrink-0">Cell</span>
|
||||
<span className="font-mono font-medium truncate" title={invite.cell_name}>{invite.cell_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Domain</span>
|
||||
<span className="font-mono font-medium">{invite.domain}</span>
|
||||
<div className="flex justify-between gap-2 min-w-0">
|
||||
<span className="text-gray-500 shrink-0">Domain</span>
|
||||
<span className="font-mono font-medium truncate" title={invite.domain}>{invite.domain}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Endpoint</span>
|
||||
<span className="font-mono font-medium">{invite.endpoint || '(no external IP)'}</span>
|
||||
<div className="flex justify-between gap-2 min-w-0">
|
||||
<span className="text-gray-500 shrink-0">Endpoint</span>
|
||||
<span className="font-mono font-medium truncate" title={invite.endpoint || '(no external IP)'}>{invite.endpoint || '(no external IP)'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">VPN subnet</span>
|
||||
<span className="font-mono font-medium">{invite.vpn_subnet}</span>
|
||||
<div className="flex justify-between gap-2 min-w-0">
|
||||
<span className="text-gray-500 shrink-0">VPN subnet</span>
|
||||
<span className="font-mono font-medium truncate" title={invite.vpn_subnet}>{invite.vpn_subnet}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -288,14 +288,14 @@ export default function CellNetwork() {
|
||||
{connections.map(conn => (
|
||||
<div key={conn.cell_name}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<StatusDot online={conn.online} />
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-gray-900">{conn.cell_name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">.{conn.domain}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-medium text-gray-900 truncate" title={conn.cell_name}>{conn.cell_name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono shrink-0">.{conn.domain}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-x-3 mt-0.5">
|
||||
<div className="text-xs text-gray-500 space-x-3 mt-0.5 truncate">
|
||||
<span>Subnet: <span className="font-mono">{conn.vpn_subnet}</span></span>
|
||||
<span>Endpoint: <span className="font-mono">{conn.endpoint || '—'}</span></span>
|
||||
{conn.last_handshake && (
|
||||
|
||||
@@ -244,9 +244,9 @@ function Dashboard({ isOnline }) {
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<div className="ml-4 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-500">Cell Name</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{cellStatus.cell_name}</p>
|
||||
<p className="text-lg font-semibold text-gray-900 truncate" title={cellStatus.cell_name}>{cellStatus.cell_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -457,8 +457,16 @@ function Settings() {
|
||||
? 'Must be within an RFC-1918 range: 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16'
|
||||
: null;
|
||||
|
||||
const cellNameError = identity.cell_name && identity.cell_name.length > 255
|
||||
? 'Cell name must be 255 characters or fewer'
|
||||
: (!identity.cell_name ? 'Cell name is required' : null);
|
||||
|
||||
const domainError = identity.domain && identity.domain.length > 255
|
||||
? 'Domain must be 255 characters or fewer'
|
||||
: (!identity.domain ? 'Domain is required' : null);
|
||||
|
||||
const saveIdentity = async () => {
|
||||
if (ipRangeError) return;
|
||||
if (ipRangeError || cellNameError || domainError) return;
|
||||
setIdentitySaving(true);
|
||||
try {
|
||||
const res = await cellAPI.updateConfig(identity);
|
||||
@@ -622,18 +630,20 @@ function Settings() {
|
||||
{/* Cell Identity */}
|
||||
<Section icon={Server} title="Cell Identity">
|
||||
<div className="space-y-3">
|
||||
<Field label="Cell Name">
|
||||
<Field label="Cell Name" error={cellNameError}>
|
||||
<TextInput
|
||||
value={identity.cell_name}
|
||||
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
|
||||
placeholder="mycell"
|
||||
maxLength={255}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Domain">
|
||||
<Field label="Domain" error={domainError}>
|
||||
<TextInput
|
||||
value={identity.domain}
|
||||
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
|
||||
placeholder="cell.local"
|
||||
maxLength={255}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
|
||||
@@ -647,7 +657,7 @@ function Settings() {
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={saveIdentity}
|
||||
disabled={!identityDirty || identitySaving || !!ipRangeError}
|
||||
disabled={!identityDirty || identitySaving || !!ipRangeError || !!cellNameError || !!domainError}
|
||||
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{identitySaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
|
||||
Reference in New Issue
Block a user