refactor(decnet_web/DeckyFleet): move IntervalEditor out
Verbatim move of the per-decky mutation-interval modal (~60 LOC) into its own file. Saves null when the toggle is off, minutes otherwise. - New DeckyFleet/IntervalEditor.tsx - IntervalEditor.test.tsx covers null-current disabled path, numeric-current enabled path, and CANCEL not firing onSave. - src/test/fixtures/decky.ts now derives DeckyFixture from the canonical Decky type (the fixture's loose swarm shape was missing host_address/host_status; aligning to Decky catches that statically).
This commit is contained in:
@@ -1004,67 +1004,7 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Interval editor modal ───────────────────────────────────────────────
|
import { IntervalEditor } from './DeckyFleet/IntervalEditor';
|
||||||
|
|
||||||
interface IntervalEditorProps {
|
|
||||||
open: boolean;
|
|
||||||
deckyName: string;
|
|
||||||
current: number | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (minutes: number | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IntervalEditor: React.FC<IntervalEditorProps> = ({ open, deckyName, current, onClose, onSave }) => {
|
|
||||||
const [enabled, setEnabled] = useState<boolean>(current !== null);
|
|
||||||
const [minutes, setMinutes] = useState<number>(current ?? 30);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title={`MUTATION INTERVAL · ${deckyName}`}
|
|
||||||
icon={RefreshCw}
|
|
||||||
accent="violet"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<button className="btn ghost" onClick={onClose}>CANCEL</button>
|
|
||||||
<button className="btn violet" onClick={() => onSave(enabled ? minutes : null)}>SAVE</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="modal-body">
|
|
||||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', padding: 14, border: '1px solid var(--border)' }}>
|
|
||||||
<input
|
|
||||||
id="interval-enable"
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
onChange={(e) => setEnabled(e.target.checked)}
|
|
||||||
style={{ accentColor: 'var(--matrix)' }}
|
|
||||||
/>
|
|
||||||
<label htmlFor="interval-enable" style={{ fontSize: '0.8rem', letterSpacing: 1 }}>
|
|
||||||
ENABLE PERIODIC MUTATION
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{enabled && (
|
|
||||||
<div className="tweak-group">
|
|
||||||
<label>INTERVAL ({minutes} minutes)</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={5}
|
|
||||||
max={240}
|
|
||||||
step={5}
|
|
||||||
value={minutes}
|
|
||||||
onChange={(e) => setMinutes(parseInt(e.target.value, 10))}
|
|
||||||
/>
|
|
||||||
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1 }}>
|
|
||||||
Applied on the next mutation cycle.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Fleet page ──────────────────────────────────────────────────────────
|
// ─── Fleet page ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
62
decnet_web/src/components/DeckyFleet/IntervalEditor.test.tsx
Normal file
62
decnet_web/src/components/DeckyFleet/IntervalEditor.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { IntervalEditor } from './IntervalEditor';
|
||||||
|
|
||||||
|
describe('IntervalEditor', () => {
|
||||||
|
it('starts disabled when current is null and saves null', async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<IntervalEditor
|
||||||
|
open
|
||||||
|
deckyName="decoy-01"
|
||||||
|
current={null}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const checkbox = screen.getByLabelText('ENABLE PERIODIC MUTATION') as HTMLInputElement;
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
expect(screen.queryByText(/INTERVAL \(/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText('SAVE'));
|
||||||
|
expect(onSave).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts enabled when current is a number and saves the slider value', async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<IntervalEditor
|
||||||
|
open
|
||||||
|
deckyName="decoy-02"
|
||||||
|
current={45}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/INTERVAL \(45 minutes\)/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText('SAVE'));
|
||||||
|
expect(onSave).toHaveBeenCalledWith(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CANCEL invokes onClose without onSave', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<IntervalEditor
|
||||||
|
open
|
||||||
|
deckyName="decoy-03"
|
||||||
|
current={30}
|
||||||
|
onClose={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText('CANCEL'));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
71
decnet_web/src/components/DeckyFleet/IntervalEditor.tsx
Normal file
71
decnet_web/src/components/DeckyFleet/IntervalEditor.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { RefreshCw } from '../../icons';
|
||||||
|
import Modal from '../Modal/Modal';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
deckyName: string;
|
||||||
|
current: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (minutes: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modal that toggles + slider-edits per-decky mutation intervals.
|
||||||
|
* Saves null when disabled, minutes otherwise. */
|
||||||
|
export const IntervalEditor: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
deckyName,
|
||||||
|
current,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const [enabled, setEnabled] = useState<boolean>(current !== null);
|
||||||
|
const [minutes, setMinutes] = useState<number>(current ?? 30);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`MUTATION INTERVAL · ${deckyName}`}
|
||||||
|
icon={RefreshCw}
|
||||||
|
accent="violet"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn ghost" onClick={onClose}>CANCEL</button>
|
||||||
|
<button className="btn violet" onClick={() => onSave(enabled ? minutes : null)}>SAVE</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', padding: 14, border: '1px solid var(--border)' }}>
|
||||||
|
<input
|
||||||
|
id="interval-enable"
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
style={{ accentColor: 'var(--matrix)' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="interval-enable" style={{ fontSize: '0.8rem', letterSpacing: 1 }}>
|
||||||
|
ENABLE PERIODIC MUTATION
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{enabled && (
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>INTERVAL ({minutes} minutes)</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={5}
|
||||||
|
max={240}
|
||||||
|
step={5}
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => setMinutes(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1 }}>
|
||||||
|
Applied on the next mutation cycle.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
decnet_web/src/test/fixtures/decky.ts
vendored
21
decnet_web/src/test/fixtures/decky.ts
vendored
@@ -1,21 +1,6 @@
|
|||||||
export interface DeckyFixture {
|
import type { Decky } from '../../components/DeckyFleet/types';
|
||||||
name: string;
|
|
||||||
ip: string;
|
export type DeckyFixture = Decky;
|
||||||
services: string[];
|
|
||||||
distro: string;
|
|
||||||
hostname: string;
|
|
||||||
archetype: string | null;
|
|
||||||
service_config: Record<string, Record<string, unknown>>;
|
|
||||||
mutate_interval: number | null;
|
|
||||||
last_mutated: number;
|
|
||||||
swarm?: {
|
|
||||||
host_uuid: string;
|
|
||||||
host_name: string;
|
|
||||||
state: string;
|
|
||||||
last_error: string | null;
|
|
||||||
last_seen: string | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeDecky = (overrides: Partial<DeckyFixture> = {}): DeckyFixture => ({
|
export const makeDecky = (overrides: Partial<DeckyFixture> = {}): DeckyFixture => ({
|
||||||
name: 'decoy-01',
|
name: 'decoy-01',
|
||||||
|
|||||||
Reference in New Issue
Block a user