the plugin, start to finish: connection, pass-through, panels, skill
OpenSEO ships its own MCP server and its own web UI, so this plugin holds the address, holds the credential, and is the door. There are no curated routes in front of it and there should not be: proxy-manager's router already records where that leads — a curated surface cannot keep up, and the plugin becomes a worse version of the thing it wraps. The door IS the security model, not a convenience. Upstream self-hosts with AUTH_MODE=local_noauth, so every request reaching the instance is trusted by design and their docs say to put it behind auth. Officer's gate is that auth. Two things follow and neither is optional: nothing here may publish OpenSEO's own port, and /_config is owner-only, because the address alone is enough to bypass this plugin entirely. The iframe therefore points at /api/openseo/ — this Officer's origin — and never at the instance, since an iframe src is a URL anybody can read out of the page and open directly. The probe asks /mcp for tools/list rather than fetching the home page. A web root answers 200 for a great many things that are not this; a JSON-RPC reply carrying tool names is the instance identifying itself. Saving with an empty key field KEEPS the stored one — absent means keep, empty string means clear. Without that distinction, changing the address from a form that cannot display the credential would silently erase it, and it would surface days later as an instance that had stopped answering. One table, deliberately: every project, keyword and crawl lives in the instance, so uninstalling this cannot destroy anybody's research. The skill says so too, because the temptation to cache a project list is exactly what would break it. The skill leads with cost. This is free software over DataForSEO, metered and billed to the owner per lookup, so it tells the agent not to sweep and to say what a thorough answer would spend before spending it. Tags are `Development` and `Homelab`. `Marketing` and `Research` would both be more accurate and neither exists in the union — extending it is a platform change rather than a plugin's to make.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CircleAlert, CircleCheck, CircleDashed, Loader2, PlugZap, Save, Trash2 } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { readableError } from './errors';
|
||||
import { useOpenSeoChanged } from './channel';
|
||||
|
||||
/**
|
||||
* Where the OpenSEO instance is, and the DataForSEO key.
|
||||
*
|
||||
* ── The key field is never filled in ──
|
||||
*
|
||||
* The server reports whether one is stored and never what it is, so this shows a placeholder rather
|
||||
* than a value. Blank therefore has to mean "leave it alone" — the API treats absent as keep and an
|
||||
* explicit empty string as clear, because otherwise saving a changed ADDRESS from a form whose key box
|
||||
* is empty would silently erase a working credential, and it would surface days later as an instance
|
||||
* that had stopped answering.
|
||||
*
|
||||
* ── Test does not save ──
|
||||
*
|
||||
* So a wrong address never replaces a working one. It tests what is in the box, because testing what is
|
||||
* stored while somebody looks at something different is worse than not testing.
|
||||
*/
|
||||
|
||||
type Connection = {
|
||||
url: string | null;
|
||||
hasKey: boolean;
|
||||
lastProbeAt: string | null;
|
||||
lastProbeOk: boolean | null;
|
||||
};
|
||||
|
||||
type Probe = { ok: boolean; detail: string };
|
||||
|
||||
export const ConnectionPanel = () => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const [connection, setConnection] = useState<Connection | null>(null);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const [busy, setBusy] = useState<'save' | 'test' | 'forget' | null>(null);
|
||||
const [result, setResult] = useState<Probe | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, changed] = useOpenSeoChanged();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const current = await clientRef.current.get<Connection>('/openseo/_config');
|
||||
setConnection(current);
|
||||
setUrl((typed) => typed || (current.url ?? ''));
|
||||
} catch (failure) {
|
||||
// A member looking at an owner-only setting is not a fault and needs a different sentence.
|
||||
if ((failure as { status?: number })?.status === 403) setForbidden(true);
|
||||
else setError(readableError(failure));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy('save');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const saved = await clientRef.current.put<Connection & { probe: Probe }>('/openseo/_config', {
|
||||
url,
|
||||
// Absent, not empty — see the note above. Only sent when somebody typed one.
|
||||
...(key ? { key } : {}),
|
||||
});
|
||||
setConnection(saved);
|
||||
setResult(saved.probe);
|
||||
setKey('');
|
||||
changed();
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
setBusy('test');
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
setResult(await clientRef.current.post<Probe>('/openseo/_config/test', { url }));
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const forget = async () => {
|
||||
setBusy('forget');
|
||||
setError(null);
|
||||
try {
|
||||
setConnection(await clientRef.current.delete<Connection>('/openseo/_config'));
|
||||
setUrl('');
|
||||
setResult(null);
|
||||
changed();
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (forbidden) {
|
||||
return (
|
||||
<div className="p-5 text-xs text-muted-foreground">
|
||||
Only the owner can change where OpenSEO is.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = result ? (result.ok ? CircleCheck : CircleAlert) : connection?.lastProbeOk ? CircleCheck : CircleDashed;
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
|
||||
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">OpenSEO instance</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Where it is running. It is reached only through Officer — never published on its own port.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="http://127.0.0.1:3001"
|
||||
className="font-mono text-xs"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">
|
||||
DataForSEO key <span className="font-normal text-muted-foreground/70">base64 of login:password</span>
|
||||
</p>
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
type="password"
|
||||
placeholder={connection?.hasKey ? 'stored — leave blank to keep it' : 'not set'}
|
||||
className="font-mono text-xs"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* Said plainly, once, where somebody is about to enter it. */}
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Billed pay-as-you-go by DataForSEO directly. Only needed if this Officer provisions the
|
||||
instance — one started elsewhere already has its own.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(result || connection?.lastProbeAt) && (
|
||||
<p className="flex items-start gap-1.5 text-xs">
|
||||
<Icon
|
||||
className={`mt-px size-3.5 shrink-0 ${result?.ok || (!result && connection?.lastProbeOk) ? 'text-success' : result ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className={result && !result.ok ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{result?.detail ?? (connection?.lastProbeOk ? 'Answered when last checked.' : 'Did not answer when last checked.')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
||||
<Button size="sm" onClick={() => void save()} disabled={busy !== null || !url.trim()}>
|
||||
{busy === 'save' ? <Loader2 className="size-3.5 animate-spin" /> : <Save className="size-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => void test()} disabled={busy !== null || !url.trim()}>
|
||||
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : <PlugZap className="size-3.5" />}
|
||||
Test
|
||||
</Button>
|
||||
{connection?.url && (
|
||||
<Button variant="ghost" size="sm" onClick={() => void forget()} disabled={busy !== null}>
|
||||
<Trash2 className="size-3.5" />
|
||||
Forget
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user