fix: resolve all Cell Identity banner and cert issues
Unit Tests / test (push) Successful in 7m17s

Four bugs fixed:

1. Banner delay (up to 5 s): DraftConfigContext now exposes isDirty as
   reactive useState so App.jsx re-renders immediately when any section
   marks itself dirty, instead of waiting for the next checkPending() poll.

2. Banner re-triggers after Apply (race): For non-'*' container restarts
   (e.g., cell_name → DNS restart) the background thread took ~300 ms to
   clear _pending_restart. A concurrent checkPending() poll could see
   needs_restart=True and overwrite the frontend's optimistic clear.
   Fix: set needs_restart=False and applying=True synchronously before
   spawning the thread.

3. Apply showed banner during applyPending() when hasDirty()==false:
   setApplyStatus('saving') was skipped for the auto-save-then-apply
   path, leaving applyStatus=null while applyPending() ran and the
   banner stayed visible. Always set 'saving' before applyPending().

4. Cert status always 'unknown' in pic_ngo mode: _check_cert_via_ssl
   connected to cell-caddy:443 but sent SNI='cell-caddy'. Caddy finds no
   matching cert and returns nothing. Fix: pass the effective public
   domain (e.g. pic1.pic.ngo) as SNI so Caddy returns the right cert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 04:17:56 -04:00
parent ec8995d41e
commit 649378b59b
7 changed files with 171 additions and 19 deletions
+9 -10
View File
@@ -188,18 +188,17 @@ function AppCore() {
const [applyStatus, setApplyStatus] = useState(null); // null | 'saving' | 'restarting' | 'done' | 'timeout' | 'error'
const [applyError, setApplyError] = useState('');
const { flushAll, hasDirty, clearAllDirty } = useDraftConfig();
const { flushAll, hasDirty, isDirty, clearAllDirty } = useDraftConfig();
const handleApply = useCallback(async () => {
setApplyError('');
if (hasDirty()) {
setApplyStatus('saving');
try {
await flushAll();
} catch {
// flush errors are shown via Settings toasts; continue with apply
}
setApplyStatus('saving');
try {
if (hasDirty()) await flushAll();
} catch {
// flush errors are shown via Settings toasts; continue with apply
}
clearAllDirty();
try {
await cellAPI.applyPending();
} catch (err) {
@@ -228,7 +227,7 @@ function AppCore() {
setApplyStatus('timeout');
setApplyError('Containers may still be starting — check docker logs if services are unavailable');
setTimeout(() => setApplyStatus(null), 8000);
}, [flushAll, hasDirty]);
}, [flushAll, hasDirty, clearAllDirty]);
const handleCancel = useCallback(async () => {
clearAllDirty();
@@ -328,7 +327,7 @@ function AppCore() {
</div>
)}
{isOnline && (pending.needs_restart || hasDirty()) && !applyStatus && (
{isOnline && (pending.needs_restart || isDirty) && !applyStatus && (
<PendingRestartBanner pending={pending} onApply={handleApply} onCancel={handleCancel} />
)}
@@ -51,7 +51,9 @@ vi.mock('../contexts/DraftConfigContext', () => ({
registerFlusher: vi.fn(() => vi.fn()),
setDirty: (...a) => mockSetDirty(...a),
hasDirty: vi.fn(() => false),
isDirty: false,
flushAll: vi.fn(),
clearAllDirty: vi.fn(),
}),
}));
@@ -230,3 +232,53 @@ describe('Cell Identity — Accept path (saveIdentity called by flusher)', () =>
expect(mockUpdateConfig.mock.calls[0][0]).toMatchObject({ cell_name: 'pic2' });
});
});
describe('Cell Identity — DraftConfig dirty state is set synchronously on change', () => {
/**
* Verifies that draftConfig.setDirty is called in the same synchronous
* event handler as the identity state change, not in a deferred effect.
* This is what allows the banner to appear immediately (the isDirty reactive
* state in DraftConfigContext re-renders App.jsx without waiting for a poll).
*/
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
it('setDirty("identity", true) is called before any timer fires after cell_name change', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
// setDirty must be called synchronously within the event, not in a timer
expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
expect(mockSetDirty).toHaveBeenCalledTimes(1);
});
it('setDirty("identity", true) is called before any timer fires after ip_range change', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
expect(mockSetDirty).toHaveBeenCalledWith('identity', true);
expect(mockSetDirty).toHaveBeenCalledTimes(1);
});
it('setDirty("identity", false) is called after saveIdentity completes', async () => {
mockGetConfig
.mockResolvedValueOnce({ data: makeCfg() })
.mockResolvedValue({ data: makeCfg({ ip_range: '10.0.0.0/8' }) });
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
// auto-save fires after 800 ms
await act(async () => { vi.advanceTimersByTime(900); });
await act(async () => { await Promise.resolve(); });
const calls = mockSetDirty.mock.calls;
expect(calls.some(([k, v]) => k === 'identity' && v === true)).toBe(true);
expect(calls.some(([k, v]) => k === 'identity' && v === false)).toBe(true);
});
});
+8 -4
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useRef, useCallback } from 'react';
import { createContext, useContext, useRef, useCallback, useState } from 'react';
const DraftConfigContext = createContext(null);
@@ -11,9 +11,12 @@ export function DraftConfigProvider({ children }) {
}, []);
const hasDirtyRef = useRef({}); // key → boolean
// isDirty is a reactive mirror of hasDirtyRef so consumers re-render immediately.
const [isDirty, setIsDirty] = useState(false);
const setDirty = useCallback((key, isDirty) => {
hasDirtyRef.current[key] = isDirty;
const setDirty = useCallback((key, dirty) => {
hasDirtyRef.current[key] = dirty;
setIsDirty(Object.values(hasDirtyRef.current).some(Boolean));
}, []);
const hasDirty = useCallback(() => {
@@ -27,10 +30,11 @@ export function DraftConfigProvider({ children }) {
const clearAllDirty = useCallback(() => {
hasDirtyRef.current = {};
setIsDirty(false);
}, []);
return (
<DraftConfigContext.Provider value={{ registerFlusher, setDirty, hasDirty, flushAll, clearAllDirty }}>
<DraftConfigContext.Provider value={{ registerFlusher, setDirty, hasDirty, isDirty, flushAll, clearAllDirty }}>
{children}
</DraftConfigContext.Provider>
);