plugin settings: the client already adds /api, and a Test button

Save returned 404 and the form silently showed what was typed rather than what
was stored. Both from one mistake: useClient prefixes /api itself, so
`/api${prefix}/config` reached /api/api/transcribe/config. Every other caller in
the tree passes a bare path; I copied the shape of a curl instead.

The GET failing was the worse half, because it looked like it worked — an empty
field populated by typing is indistinguishable from one populated by the server.

Adds Test, which asks the plugin's /_health whether its connection works NOW. The
plugin validates on save, so a stored value has been reached once — but once,
whenever that was, is not the same as now, and the server it points at is a
separate machine that can stop answering. Offered only once something is stored,
since there is nothing to test before that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 04:07:16 +00:00
co-authored by Claude Opus 5
parent c427cb5b61
commit 50e96e0d77
7 changed files with 56 additions and 9 deletions
@@ -55,7 +55,7 @@ export const PluginSettings = ({ appName, prefix, settings, enabled }: PluginSet
const { data: current, isLoading } = useQuery({ const { data: current, isLoading } = useQuery({
queryKey: ['plugin-config', appName], queryKey: ['plugin-config', appName],
queryFn: () => client.get<Record<string, unknown>>(`/api${prefix}/config`), queryFn: () => client.get<Record<string, unknown>>(`${prefix}/config`),
enabled, enabled,
// A plugin that has no /config yet, or is mid-restart, is not an error worth a red toast on a screen // A plugin that has no /config yet, or is mid-restart, is not an error worth a red toast on a screen
// the owner opened to read about the plugin. // the owner opened to read about the plugin.
@@ -80,6 +80,23 @@ export const PluginSettings = ({ appName, prefix, settings, enabled }: PluginSet
setValues((prev) => ({ ...next, ...prev })); setValues((prev) => ({ ...next, ...prev }));
}, [current, settings]); }, [current, settings]);
/*
Test what is STORED, not what is typed.
The plugin validates on save, so a stored value has already been reached once — but "once, whenever
that was" is not the same as "now", and the server it points at is a separate machine that can stop
answering. This asks the plugin whether its connection works at this moment, which is the question
somebody staring at a form actually has.
*/
const test = useMutation({
mutationFn: () => client.get<{ ok?: boolean; error?: string }>(`${prefix}/_health`),
onSuccess: (r) =>
r?.ok === false
? toast.error(r.error || t('plugins.settingsTestFailed'))
: toast.success(t('plugins.settingsTestOk')),
onError: (err) => toast.error(err instanceof Error ? err.message : t('plugins.settingsTestFailed')),
});
const save = useMutation({ const save = useMutation({
mutationFn: () => { mutationFn: () => {
// Only what was typed. An untouched secret must not be sent as an empty string, which the plugin // Only what was typed. An untouched secret must not be sent as an empty string, which the plugin
@@ -89,7 +106,7 @@ export const PluginSettings = ({ appName, prefix, settings, enabled }: PluginSet
const v = values[f.key]; const v = values[f.key];
if (v !== undefined && v !== '') body[f.key] = v; if (v !== undefined && v !== '') body[f.key] = v;
} }
return client.put(`/api${prefix}/config`, body); return client.put(`${prefix}/config`, body);
}, },
onSuccess: () => { onSuccess: () => {
toast.success(t('plugins.settingsSaved')); toast.success(t('plugins.settingsSaved'));
@@ -146,7 +163,13 @@ export const PluginSettings = ({ appName, prefix, settings, enabled }: PluginSet
onClick={() => save.mutate()} onClick={() => save.mutate()}
> >
{save.isPending ? t('plugins.settingsSaving') : t('plugins.settingsSave')} {save.isPending ? t('plugins.settingsSaving') : t('plugins.settingsSave')}
</Button> </Button>{' '}
{/* Offered only once something is stored — there is nothing to test before that. */}
{current ? (
<Button disabled={test.isPending || save.isPending} onClick={() => test.mutate()}>
{test.isPending ? t('plugins.settingsTesting') : t('plugins.settingsTest')}
</Button>
) : null}
</div> </div>
</div> </div>
)} )}
@@ -206,7 +206,11 @@
"settingsSaving": "Wird gespeichert…", "settingsSaving": "Wird gespeichert…",
"settingsSaved": "Einstellungen gespeichert", "settingsSaved": "Einstellungen gespeichert",
"settingsFailed": "Einstellungen konnten nicht gespeichert werden", "settingsFailed": "Einstellungen konnten nicht gespeichert werden",
"settingsSecretSet": "Gesetzt — leer lassen, um es zu behalten" "settingsSecretSet": "Gesetzt — leer lassen, um es zu behalten",
"settingsTest": "Testen",
"settingsTesting": "Wird getestet…",
"settingsTestOk": "Verbindung funktioniert",
"settingsTestFailed": "Nicht erreichbar"
}, },
"commandHq": { "commandHq": {
"selectOne": "Wählen Sie einen Commander aus oder starten Sie einen neuen.", "selectOne": "Wählen Sie einen Commander aus oder starten Sie einen neuen.",
@@ -206,7 +206,11 @@
"settingsSaving": "Saving…", "settingsSaving": "Saving…",
"settingsSaved": "Settings saved", "settingsSaved": "Settings saved",
"settingsFailed": "Could not save the settings", "settingsFailed": "Could not save the settings",
"settingsSecretSet": "Set — leave blank to keep" "settingsSecretSet": "Set — leave blank to keep",
"settingsTest": "Test",
"settingsTesting": "Testing…",
"settingsTestOk": "Connection works",
"settingsTestFailed": "Could not reach it"
}, },
"commandHq": { "commandHq": {
"selectOne": "Select a commander, or start a new one.", "selectOne": "Select a commander, or start a new one.",
@@ -206,7 +206,11 @@
"settingsSaving": "Guardando…", "settingsSaving": "Guardando…",
"settingsSaved": "Ajustes guardados", "settingsSaved": "Ajustes guardados",
"settingsFailed": "No se han podido guardar los ajustes", "settingsFailed": "No se han podido guardar los ajustes",
"settingsSecretSet": "Definido — déjalo vacío para mantenerlo" "settingsSecretSet": "Definido — déjalo vacío para mantenerlo",
"settingsTest": "Probar",
"settingsTesting": "Probando…",
"settingsTestOk": "La conexión funciona",
"settingsTestFailed": "No se ha podido alcanzar"
}, },
"commandHq": { "commandHq": {
"selectOne": "Selecciona un commander, o inicia uno nuevo.", "selectOne": "Selecciona un commander, o inicia uno nuevo.",
@@ -206,7 +206,11 @@
"settingsSaving": "Enregistrement…", "settingsSaving": "Enregistrement…",
"settingsSaved": "Paramètres enregistrés", "settingsSaved": "Paramètres enregistrés",
"settingsFailed": "Impossible d'enregistrer les paramètres", "settingsFailed": "Impossible d'enregistrer les paramètres",
"settingsSecretSet": "Défini — laissez vide pour le conserver" "settingsSecretSet": "Défini — laissez vide pour le conserver",
"settingsTest": "Tester",
"settingsTesting": "Test en cours…",
"settingsTestOk": "La connexion fonctionne",
"settingsTestFailed": "Impossible de le joindre"
}, },
"commandHq": { "commandHq": {
"selectOne": "Sélectionnez un commander, ou démarrez-en un nouveau.", "selectOne": "Sélectionnez un commander, ou démarrez-en un nouveau.",
@@ -206,7 +206,11 @@
"settingsSaving": "A guardar…", "settingsSaving": "A guardar…",
"settingsSaved": "Definições guardadas", "settingsSaved": "Definições guardadas",
"settingsFailed": "Não foi possível guardar as definições", "settingsFailed": "Não foi possível guardar as definições",
"settingsSecretSet": "Definido — deixe em branco para manter" "settingsSecretSet": "Definido — deixe em branco para manter",
"settingsTest": "Testar",
"settingsTesting": "A testar…",
"settingsTestOk": "A ligação funciona",
"settingsTestFailed": "Não foi possível alcançar"
}, },
"commandHq": { "commandHq": {
"selectOne": "Selecione um comandante ou inicie um novo.", "selectOne": "Selecione um comandante ou inicie um novo.",
@@ -206,7 +206,11 @@
"settingsSaving": "Sparar…", "settingsSaving": "Sparar…",
"settingsSaved": "Inställningarna sparades", "settingsSaved": "Inställningarna sparades",
"settingsFailed": "Det gick inte att spara inställningarna", "settingsFailed": "Det gick inte att spara inställningarna",
"settingsSecretSet": "Angivet — lämna tomt för att behålla" "settingsSecretSet": "Angivet — lämna tomt för att behålla",
"settingsTest": "Testa",
"settingsTesting": "Testar…",
"settingsTestOk": "Anslutningen fungerar",
"settingsTestFailed": "Det gick inte att nå den"
}, },
"commandHq": { "commandHq": {
"selectOne": "Välj en commander, eller starta en ny.", "selectOne": "Välj en commander, eller starta en ny.",