| 1 |
import React, { useState } from "react"; |
| 2 |
import { __, _n, sprintf } from "@wordpress/i18n"; |
| 3 |
import { useMutation, useQuery } from "@tanstack/react-query"; |
| 4 |
|
| 5 |
import { getHealth, runSelfTest } from "../utils/api"; |
| 6 |
import { IconPlay, IconRefresh } from "./Icons"; |
| 7 |
|
| 8 |
// Exported so the master switch can invalidate this query too: the card mounts |
| 9 |
// when the switch goes on (ADR-058), and the report it shows then must describe |
| 10 |
// the server as it is now, not as it was while it refused every connection. |
| 11 |
export const HEALTH_KEY = "bd-mcp-health"; |
| 12 |
|
| 13 |
// Stages that mean "some clients can't connect", not "nothing works". A client |
| 14 |
// holding the connection token is fine through all of these; only clients that |
| 15 |
// connect by URL alone are affected, so they read as a warning rather than a |
| 16 |
// failure. |
| 17 |
const WARN_STAGES = ["tls", "redirect", "no_tools", "rewrite", "discovery", "challenge", "ua_filter"]; |
| 18 |
|
| 19 |
const testVariant = (result) => { |
| 20 |
if (!result) return "info"; |
| 21 |
if (result.ok) return "success"; |
| 22 |
return WARN_STAGES.includes(result.stage) ? "warning" : "error"; |
| 23 |
}; |
| 24 |
|
| 25 |
const Row = ({ label, tone, badge, detail }) => ( |
| 26 |
<div className="bd-mcp-health__row"> |
| 27 |
<div className="bd-mcp-health__text"> |
| 28 |
<span className="bd-mcp-health__label">{label}</span> |
| 29 |
{detail && <span className="bd-mcp-health__detail">{detail}</span>} |
| 30 |
</div> |
| 31 |
<span className={`bda-badge ${tone}`}>{badge}</span> |
| 32 |
</div> |
| 33 |
); |
| 34 |
|
| 35 |
/** |
| 36 |
* The first URL problem worth naming, most damaging first. A site can have more |
| 37 |
* than one of these at once, and telling someone about the REST scheme while |
| 38 |
* they are still on plain HTTP is not help. |
| 39 |
*/ |
| 40 |
const describeUrls = (urls) => { |
| 41 |
if (!urls.is_https) { |
| 42 |
return __("This site is served over HTTP. Most AI clients refuse to connect to anything but HTTPS.", "betterdocs"); |
| 43 |
} |
| 44 |
if (!urls.home_siteurl_match) { |
| 45 |
return __("Site Address and WordPress Address use different schemes, so requests can bounce between HTTP and HTTPS.", "betterdocs"); |
| 46 |
} |
| 47 |
if (!urls.rest_matches_home) { |
| 48 |
return __("The REST API URL uses a different scheme from the site address, which redirects MCP requests.", "betterdocs"); |
| 49 |
} |
| 50 |
return __("HTTPS is on and every WordPress URL agrees.", "betterdocs"); |
| 51 |
}; |
| 52 |
|
| 53 |
/** |
| 54 |
* Who provides the Abilities API, and what that means for the tool catalog. |
| 55 |
*/ |
| 56 |
const describeOwner = (owner, version) => { |
| 57 |
if (owner === "none") { |
| 58 |
return __("No Abilities API is available, so no tools can be offered to an AI client.", "betterdocs"); |
| 59 |
} |
| 60 |
if (owner === "foreign") { |
| 61 |
return __("Another plugin is providing the Abilities API. BetterDocs registers into it, but the version it ships governs.", "betterdocs"); |
| 62 |
} |
| 63 |
if (owner === "bundled") { |
| 64 |
return sprintf( |
| 65 |
/* translators: %s: the bundled abilities-api package version. */ |
| 66 |
__("Provided by the runtime bundled with BetterDocs (%s).", "betterdocs"), |
| 67 |
version || __("unknown version", "betterdocs"), |
| 68 |
); |
| 69 |
} |
| 70 |
return sprintf( |
| 71 |
/* translators: %s: the WordPress version providing the Abilities API. */ |
| 72 |
__("Provided by WordPress %s.", "betterdocs"), |
| 73 |
version || __("(unknown version)", "betterdocs"), |
| 74 |
); |
| 75 |
}; |
| 76 |
|
| 77 |
const ownerTone = (owner) => { |
| 78 |
if (owner === "none") return "red"; |
| 79 |
if (owner === "foreign") return "amber"; |
| 80 |
return "green"; |
| 81 |
}; |
| 82 |
|
| 83 |
const OWNER_LABELS = { |
| 84 |
core: __("WordPress core", "betterdocs"), |
| 85 |
bundled: __("BetterDocs (bundled)", "betterdocs"), |
| 86 |
foreign: __("Another plugin", "betterdocs"), |
| 87 |
none: __("Nobody", "betterdocs"), |
| 88 |
}; |
| 89 |
|
| 90 |
export default function ConnectionHealth() { |
| 91 |
const [result, setResult] = useState(null); |
| 92 |
|
| 93 |
const { data, isLoading, isError, refetch, isFetching } = useQuery({ |
| 94 |
queryKey: [HEALTH_KEY], |
| 95 |
queryFn: getHealth, |
| 96 |
}); |
| 97 |
|
| 98 |
const test = useMutation({ |
| 99 |
mutationFn: runSelfTest, |
| 100 |
onSuccess: (res) => { |
| 101 |
setResult(res); |
| 102 |
refetch(); |
| 103 |
}, |
| 104 |
onError: () => |
| 105 |
setResult({ |
| 106 |
ok: false, |
| 107 |
stage: "unreachable", |
| 108 |
message: __("The test request itself failed. Reload the page and try again.", "betterdocs"), |
| 109 |
checks: [], |
| 110 |
}), |
| 111 |
}); |
| 112 |
|
| 113 |
if (isLoading) { |
| 114 |
return ( |
| 115 |
<div className="bda-card"> |
| 116 |
<div className="bda-card-body"> |
| 117 |
<p className="bd-mcp-muted">{__("Checking connection health…", "betterdocs")}</p> |
| 118 |
</div> |
| 119 |
</div> |
| 120 |
); |
| 121 |
} |
| 122 |
|
| 123 |
if (isError || !data) { |
| 124 |
return ( |
| 125 |
<div className="bda-card"> |
| 126 |
<div className="bda-card-body"> |
| 127 |
<div className="bd-mcp-alert is-error"> |
| 128 |
{__("Couldn't load the health report. Reload the page and try again.", "betterdocs")} |
| 129 |
</div> |
| 130 |
</div> |
| 131 |
</div> |
| 132 |
); |
| 133 |
} |
| 134 |
|
| 135 |
const { urls, abilities, runtime, capabilities, database, plugin, mcp } = data; |
| 136 |
|
| 137 |
const urlsOk = urls.is_https && urls.home_siteurl_match && urls.rest_matches_home; |
| 138 |
const urlsDetail = describeUrls(urls); |
| 139 |
|
| 140 |
const owner = runtime?.abilities_api?.source || "none"; |
| 141 |
const toolCount = abilities?.betterdocs || 0; |
| 142 |
const missing = Array.isArray(capabilities?.missing) ? capabilities.missing : []; |
| 143 |
const dbOk = !!database?.up_to_date && (!database.pro || database.pro.up_to_date); |
| 144 |
const proState = plugin?.pro?.state || "pro_not_installed"; |
| 145 |
const proLabels = { |
| 146 |
ok: __("Pro active", "betterdocs"), |
| 147 |
pro_unlicensed: __("Pro active, unlicensed", "betterdocs"), |
| 148 |
pro_active_setting_off: __("Pro active", "betterdocs"), |
| 149 |
pro_not_active: __("Pro inactive", "betterdocs"), |
| 150 |
pro_not_installed: __("Free only", "betterdocs"), |
| 151 |
}; |
| 152 |
|
| 153 |
return ( |
| 154 |
<div className="bda-card bd-mcp-health"> |
| 155 |
<div className="bda-card-head bd-mcp-cardhead"> |
| 156 |
<div> |
| 157 |
<h3 className="bda-card-title">{__("Connection health", "betterdocs")}</h3> |
| 158 |
<p className="bd-mcp-muted"> |
| 159 |
{__( |
| 160 |
"What an AI client finds when it reaches this site. Test connection makes the real round trip from this server and names the first step that fails.", |
| 161 |
"betterdocs", |
| 162 |
)} |
| 163 |
</p> |
| 164 |
</div> |
| 165 |
{/* This card only renders while the switch is on (ADR-058), so |
| 166 |
both buttons are simply available: there is no longer a state |
| 167 |
in which Test connection could only report "MCP is off". */} |
| 168 |
<div className="bd-mcp-actions"> |
| 169 |
<button type="button" className="bda-btn ghost sm" onClick={() => refetch()} disabled={isFetching}> |
| 170 |
<IconRefresh /> |
| 171 |
{__("Re-check", "betterdocs")} |
| 172 |
</button> |
| 173 |
<button |
| 174 |
type="button" |
| 175 |
className="bda-btn primary sm" |
| 176 |
onClick={() => test.mutate()} |
| 177 |
disabled={test.isLoading} |
| 178 |
> |
| 179 |
<IconPlay /> |
| 180 |
{test.isLoading ? __("Testing…", "betterdocs") : __("Test connection", "betterdocs")} |
| 181 |
</button> |
| 182 |
</div> |
| 183 |
</div> |
| 184 |
|
| 185 |
<div className="bda-card-body"> |
| 186 |
{result && <div className={`bd-mcp-alert is-${testVariant(result)}`}>{result.message}</div>} |
| 187 |
|
| 188 |
{Array.isArray(result?.caveats) && |
| 189 |
result.caveats.map((caveat) => ( |
| 190 |
<div key={caveat.slice(0, 60)} className="bd-mcp-alert is-warning"> |
| 191 |
{caveat} |
| 192 |
</div> |
| 193 |
))} |
| 194 |
|
| 195 |
{Array.isArray(result?.checks) && result.checks.length > 0 && ( |
| 196 |
<ul className="bd-mcp-checks"> |
| 197 |
{result.checks.map((check) => ( |
| 198 |
<li key={check.id} className="bd-mcp-checks__item"> |
| 199 |
<span |
| 200 |
aria-hidden="true" |
| 201 |
className={check.ok ? "bd-mcp-checks__ok" : "bd-mcp-checks__warn"} |
| 202 |
> |
| 203 |
{check.ok ? "✓" : "!"} |
| 204 |
</span> |
| 205 |
<span> |
| 206 |
<strong>{check.label}</strong> |
| 207 |
{" — "} |
| 208 |
{check.detail} |
| 209 |
</span> |
| 210 |
</li> |
| 211 |
))} |
| 212 |
</ul> |
| 213 |
)} |
| 214 |
|
| 215 |
<div className="bd-mcp-health__list"> |
| 216 |
<Row |
| 217 |
label={__("Secure connection", "betterdocs")} |
| 218 |
tone={urlsOk ? "green" : "amber"} |
| 219 |
badge={urlsOk ? __("OK", "betterdocs") : __("Check", "betterdocs")} |
| 220 |
detail={urlsDetail} |
| 221 |
/> |
| 222 |
<Row |
| 223 |
label={__("Abilities API", "betterdocs")} |
| 224 |
tone={ownerTone(owner)} |
| 225 |
badge={OWNER_LABELS[owner] || owner} |
| 226 |
detail={describeOwner(owner, runtime?.abilities_api?.version)} |
| 227 |
/> |
| 228 |
<Row |
| 229 |
label={__("Registered tools", "betterdocs")} |
| 230 |
tone={toolCount > 0 ? "green" : "amber"} |
| 231 |
badge={String(toolCount)} |
| 232 |
detail={ |
| 233 |
toolCount > 0 |
| 234 |
? sprintf( |
| 235 |
/* translators: %d: number of registered BetterDocs tools. */ |
| 236 |
_n( |
| 237 |
"%d BetterDocs tool is registered and offered to connected clients.", |
| 238 |
"%d BetterDocs tools are registered and offered to connected clients.", |
| 239 |
toolCount, |
| 240 |
"betterdocs", |
| 241 |
), |
| 242 |
toolCount, |
| 243 |
) |
| 244 |
: __("No BetterDocs tools are registered, so a client would connect and find nothing to call.", "betterdocs") |
| 245 |
} |
| 246 |
/> |
| 247 |
<Row |
| 248 |
label={__("Your capabilities", "betterdocs")} |
| 249 |
tone={missing.length === 0 ? "green" : "amber"} |
| 250 |
badge={ |
| 251 |
missing.length === 0 |
| 252 |
? __("Complete", "betterdocs") |
| 253 |
: sprintf( |
| 254 |
/* translators: %d: number of missing capabilities. */ |
| 255 |
__("%d missing", "betterdocs"), |
| 256 |
missing.length, |
| 257 |
) |
| 258 |
} |
| 259 |
detail={ |
| 260 |
missing.length === 0 |
| 261 |
? __("You hold every BetterDocs capability, so no tool will refuse you.", "betterdocs") |
| 262 |
: sprintf( |
| 263 |
/* translators: %s: comma-separated capability names. */ |
| 264 |
__("Tools needing these will refuse you: %s.", "betterdocs"), |
| 265 |
missing.join(", "), |
| 266 |
) |
| 267 |
} |
| 268 |
/> |
| 269 |
<Row |
| 270 |
label={__("Database", "betterdocs")} |
| 271 |
tone={dbOk ? "green" : "amber"} |
| 272 |
badge={dbOk ? __("Up to date", "betterdocs") : __("Update due", "betterdocs")} |
| 273 |
detail={ |
| 274 |
dbOk |
| 275 |
? sprintf( |
| 276 |
/* translators: %s: database schema version. */ |
| 277 |
__("Schema v%s installed.", "betterdocs"), |
| 278 |
String(database?.installed_version || "?"), |
| 279 |
) |
| 280 |
: __("A database update is pending. Reload the admin so it can run.", "betterdocs") |
| 281 |
} |
| 282 |
/> |
| 283 |
<Row |
| 284 |
label={__("BetterDocs", "betterdocs")} |
| 285 |
tone="slate" |
| 286 |
badge={String(plugin?.free_version || "")} |
| 287 |
detail={proLabels[proState] || proState} |
| 288 |
/> |
| 289 |
{typeof mcp?.lockouts === "number" && mcp.lockouts > 0 && ( |
| 290 |
<Row |
| 291 |
label={__("Client lockouts", "betterdocs")} |
| 292 |
tone="amber" |
| 293 |
badge={String(mcp.lockouts)} |
| 294 |
detail={__( |
| 295 |
"One or more client addresses are locked out after repeated failed authentications — usually a client still holding a rotated-away token. While a lockout holds, that address is refused even with a valid token. It clears itself within 15 minutes of the retries stopping.", |
| 296 |
"betterdocs", |
| 297 |
)} |
| 298 |
/> |
| 299 |
)} |
| 300 |
</div> |
| 301 |
</div> |
| 302 |
</div> |
| 303 |
); |
| 304 |
} |
| 305 |
|