fix(web): pick decky from a select instead of a free-text input

Fetches GET /deckies on page load and feeds the running fleet into
the create modal as a <select>. Falls back to an empty-state hint
('No deckies running. Deploy a fleet first.') when the list is
empty so the operator isn't staring at an unusable form. Default
selection is the first decky returned.
This commit is contained in:
2026-04-27 13:32:51 -04:00
parent fcdb32908d
commit af15e68a3d

View File

@@ -60,18 +60,24 @@ const STATE_COLOR = {
// ─── CREATE MODAL ────────────────────────────────────────────────────────── // ─── CREATE MODAL ──────────────────────────────────────────────────────────
interface DeckyOption {
name: string;
ip?: string;
}
interface CreateModalProps { interface CreateModalProps {
blobs: BlobRow[]; blobs: BlobRow[];
deckies: DeckyOption[];
onClose: () => void; onClose: () => void;
onCreated: (token: CanaryTokenRow) => void; onCreated: (token: CanaryTokenRow) => void;
} }
const CreateModal: React.FC<CreateModalProps> = ({ blobs, onClose, onCreated }) => { const CreateModal: React.FC<CreateModalProps> = ({ blobs, deckies, onClose, onCreated }) => {
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true); useEscapeKey(onClose, true);
useFocusTrap(panelRef, true); useFocusTrap(panelRef, true);
const [decky, setDecky] = useState(''); const [decky, setDecky] = useState(deckies[0]?.name ?? '');
const [kind, setKind] = useState<'http' | 'dns' | 'aws_passive'>('http'); const [kind, setKind] = useState<'http' | 'dns' | 'aws_passive'>('http');
const [path, setPath] = useState('/home/admin/.aws/credentials'); const [path, setPath] = useState('/home/admin/.aws/credentials');
const [source, setSource] = useState<'generator' | 'blob'>('generator'); const [source, setSource] = useState<'generator' | 'blob'>('generator');
@@ -82,7 +88,7 @@ const CreateModal: React.FC<CreateModalProps> = ({ blobs, onClose, onCreated })
const handleSubmit = async () => { const handleSubmit = async () => {
setError(null); setError(null);
if (!decky.trim()) return setError('decky_name required.'); if (!decky.trim()) return setError('Pick a decky.');
if (!path.trim().startsWith('/')) return setError('placement_path must be absolute.'); if (!path.trim().startsWith('/')) return setError('placement_path must be absolute.');
if (source === 'blob' && !blobUuid) return setError('Pick a blob or switch to Generator.'); if (source === 'blob' && !blobUuid) return setError('Pick a blob or switch to Generator.');
setSubmitting(true); setSubmitting(true);
@@ -131,14 +137,25 @@ const CreateModal: React.FC<CreateModalProps> = ({ blobs, onClose, onCreated })
</button> </button>
</div> </div>
<Field label="Decky name"> <Field label="Decky">
<input {deckies.length === 0 ? (
value={decky} <div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
onChange={(e) => setDecky(e.target.value)} No deckies running. Deploy a fleet first.
placeholder="web1" </div>
autoFocus ) : (
style={INPUT_STYLE} <select
/> value={decky}
onChange={(e) => setDecky(e.target.value)}
autoFocus
style={INPUT_STYLE}
>
{deckies.map((d) => (
<option key={d.name} value={d.name}>
{d.name}{d.ip ? ` (${d.ip})` : ''}
</option>
))}
</select>
)}
</Field> </Field>
<Field label="Kind"> <Field label="Kind">
@@ -372,6 +389,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ onClose, onUploaded }) => {
const CanaryTokens: React.FC = () => { const CanaryTokens: React.FC = () => {
const [tokens, setTokens] = useState<CanaryTokenRow[]>([]); const [tokens, setTokens] = useState<CanaryTokenRow[]>([]);
const [blobs, setBlobs] = useState<BlobRow[]>([]); const [blobs, setBlobs] = useState<BlobRow[]>([]);
const [deckies, setDeckies] = useState<DeckyOption[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<'tokens' | 'blobs'>('tokens'); const [tab, setTab] = useState<'tokens' | 'blobs'>('tokens');
@@ -386,12 +404,14 @@ const CanaryTokens: React.FC = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [t, b] = await Promise.all([ const [t, b, d] = await Promise.all([
api.get('/canary/tokens'), api.get('/canary/tokens'),
api.get('/canary/blobs').catch(() => ({ data: { blobs: [] } })), // viewers can't list blobs api.get('/canary/blobs').catch(() => ({ data: { blobs: [] } })), // viewers can't list blobs
api.get<DeckyOption[]>('/deckies').catch(() => ({ data: [] })),
]); ]);
setTokens(t.data.tokens || []); setTokens(t.data.tokens || []);
setBlobs(b.data.blobs || []); setBlobs(b.data.blobs || []);
setDeckies(Array.isArray(d.data) ? d.data : []);
} catch (err) { } catch (err) {
setError(extractError(err, 'Failed to load canary tokens.')); setError(extractError(err, 'Failed to load canary tokens.'));
} finally { } finally {
@@ -618,6 +638,7 @@ const CanaryTokens: React.FC = () => {
{showCreate && ( {showCreate && (
<CreateModal <CreateModal
blobs={blobs} blobs={blobs}
deckies={deckies}
onClose={() => setShowCreate(false)} onClose={() => setShowCreate(false)}
onCreated={(t) => { onCreated={(t) => {
setTokens((prev) => [t, ...prev]); setTokens((prev) => [t, ...prev]);