import type { ServiceConnection } from 'officerdev';
import type { FormEvent } from 'react';
import { useEffect, useState } from 'react';
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
serviceErrorMessage,
useServiceConnection,
useServiceConnectionActions,
useServiceHealth,
} from 'officerdev';
// Connecting a Transmission daemon to Officer, from the app.
//
// This screen is BOTH the setup wizard and the permanent settings page: TransmissionView renders it in place
// of whatever section the URL asks for while nothing is connected, and /transmission/connection renders it
// for good. One component, so re-pointing at a different daemon later goes through exactly the code path
// that stored the first one.
//
// ONE connection, not a registry — nobody runs two Transmission daemons. And usually one FIELD: most
// daemons run with no RPC auth at all, which is why username/password sit behind a disclosure rather than
// in the owner's way. An empty username means "no auth", not "empty credentials"; the sidecar is careful
// about that distinction because Transmission rejects a request carrying an empty Basic header.
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
/** Transmission's own default RPC port. Officer dials it from the server, not from this browser. */
const DEFAULT_URL = 'http://localhost:9091';
const URL_HINT =
"The daemon's base URL, without the RPC path. Officer reaches it from the server, not from this browser " +
'— so localhost here means the machine Officer runs on.';
const AUTH_HINT =
'Only if the daemon has rpc-authentication-required set. Leave the username empty for the usual case: an ' +
'empty username means no authentication, and sending a blank one anyway makes Transmission refuse the call.';
const PATH_HINT = 'Only differs behind a reverse proxy that mounts the RPC endpoint somewhere else.';
type FieldProps = {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
autoFocus?: boolean;
};
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
);
type SaveInput = Record & { url: string };
export const ConnectionView = () => {
const { data, isLoading } = useServiceConnection('transmission');
const { data: health } = useServiceHealth('transmission');
const { save, forget } = useServiceConnectionActions('transmission');
const connection = data?.connection ?? null;
const [url, setUrl] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rpcPath, setRpcPath] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState('');
// Seed the form from the stored row once it arrives. Keyed on the row's id so a save (which returns the
// same id) does not stomp what the owner is still typing, but forgetting and re-adding does reset it.
useEffect(() => {
setUrl(connection?.url ?? '');
setUsername(connection?.username ?? '');
setRpcPath(connection?.path ?? '');
setPassword('');
setShowAdvanced(!!connection?.username);
}, [connection?.id]);
const submit = async (ev?: FormEvent) => {
ev?.preventDefault();
setError('');
try {
await save.mutateAsync({ url: url.trim(), username: username.trim(), password, rpcPath: rpcPath.trim() });
setPassword('');
} catch (err) {
setError(serviceErrorMessage(err));
}
};
const remove = async () => {
setError('');
try {
await forget.mutateAsync();
} catch (err) {
setError(serviceErrorMessage(err));
}
};
if (isLoading) {
return (
Loading…
);
}
return (
Transmission daemon
{connection
? 'Where Officer talks to Transmission. Saving re-checks the daemon before storing anything.'
: 'Point Officer at your Transmission daemon. Usually the URL is all it needs.'}