feat(hosts): add one-click Categraf install dialog and templated install docs - #2202
Conversation
📝 WalkthroughWalkthroughAdds a Categraf one-click installation flow with generated commands, localized modal UI, installation metadata discovery, target-arrival polling, runtime documentation variables, and revised multilingual deployment instructions. ChangesCategraf installation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant HostsList
participant InstallCategraf
participant CategrafServices
HostsList->>CategrafServices: fetch installation metadata
HostsList->>InstallCategraf: open installation modal
InstallCategraf->>CategrafServices: poll target arrival
CategrafServices-->>InstallCategraf: return target totals and ungrouped idents
InstallCategraf-->>HostsList: report detection result
HostsList->>HostsList: refresh target list when detected
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/components/DocumentDrawer/Document.tsx (1)
45-56: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winKeep runtime documentation variables synchronized with the fetch effect. Both components now consume
variablesbut do not rerun their loading effect when it changes.
src/components/DocumentDrawer/Document.tsx#L45-L56: add stablevariablesdependencies or memoize the variables map.src/components/DocumentDrawer/index.tsx#L45-L67: replace the empty dependency list with stable dependencies includingvariables.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/DocumentDrawer/Document.tsx` around lines 45 - 56, The document-loading effects must rerun when their rendered variables change. In src/components/DocumentDrawer/Document.tsx lines 45-56, add a stable variables dependency or memoize the variables map before including it in the effect dependencies; in src/components/DocumentDrawer/index.tsx lines 45-67, replace the empty dependency list with stable dependencies that include variables, preserving the existing fetch and fallback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/DocumentDrawer/Document.tsx`:
- Around line 47-53: Handle rejection from the en_US fallback fetch by adding
terminal error handling after the fallback promise chain in Document.tsx (lines
47-53) and index.tsx (lines 58-64). Apply the existing request-error pattern or
set an explicit error state in both fallback paths so every asynchronous request
is handled.
In `@src/pages/hosts/pages/List/InstallCategraf/useTargetArrival.ts`:
- Around line 24-65: Prevent overlapping baseline probes in captureBaseline by
adding an in-flight guard or shared promise, so start and the useInterval
recovery path reuse the same capture attempt rather than launching concurrent
calls. Ensure the guard is cleared when the attempt settles, while
baselineRef.current is updated only from the active capture result and existing
success/failure status behavior is preserved.
In `@src/pages/hosts/services.ts`:
- Around line 72-110: Update getCategrafInstallMeta’s response handling so only
a deterministic missing-route response such as 404 remains cached as null; for
other non-2xx responses, clear categrafMetaPromise before returning null,
matching the retry behavior in the catch block. Preserve the existing successful
JSON validation and network-error handling.
---
Outside diff comments:
In `@src/components/DocumentDrawer/Document.tsx`:
- Around line 45-56: The document-loading effects must rerun when their rendered
variables change. In src/components/DocumentDrawer/Document.tsx lines 45-56, add
a stable variables dependency or memoize the variables map before including it
in the effect dependencies; in src/components/DocumentDrawer/index.tsx lines
45-67, replace the empty dependency list with stable dependencies that include
variables, preserving the existing fetch and fallback behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7286e4f7-3438-48be-9457-abca5a4d438f
📒 Files selected for processing (17)
public/n9e-docs/categraf/en_US.mdpublic/n9e-docs/categraf/ru_RU.mdpublic/n9e-docs/categraf/zh_CN.mdpublic/n9e-docs/categraf/zh_HK.mdsrc/components/DocumentDrawer/Document.tsxsrc/components/DocumentDrawer/index.tsxsrc/components/DocumentDrawer/renderVariables.tssrc/pages/hosts/locale/en_US.tssrc/pages/hosts/locale/ja_JP.tssrc/pages/hosts/locale/ru_RU.tssrc/pages/hosts/locale/zh_CN.tssrc/pages/hosts/locale/zh_HK.tssrc/pages/hosts/pages/List/InstallCategraf/buildCommand.tssrc/pages/hosts/pages/List/InstallCategraf/index.tsxsrc/pages/hosts/pages/List/InstallCategraf/useTargetArrival.tssrc/pages/hosts/pages/List/List.tsxsrc/pages/hosts/services.ts
| .catch(() => { | ||
| // 如果获取文档失败,使用 en_US 作为默认语言 | ||
| return fetch(`${documentPath}/en_US.md`) | ||
| .then((res) => res.text()) | ||
| .then((res) => { | ||
| setDocument(res); | ||
| setDocument(renderVariables(res, variables)); | ||
| }); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Handle rejection of every fallback document request. In both components, the fallback fetch is returned from a catch handler without a final rejection handler.
src/components/DocumentDrawer/Document.tsx#L47-L53: add a terminal catch or explicit error state after theen_USfallback request.src/components/DocumentDrawer/index.tsx#L58-L64: add a terminal catch or explicit error state after theen_USfallback request.
As per coding guidelines, all asynchronous requests must include error handling consistent with existing patterns.
📍 Affects 2 files
src/components/DocumentDrawer/Document.tsx#L47-L53(this comment)src/components/DocumentDrawer/index.tsx#L58-L64
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/DocumentDrawer/Document.tsx` around lines 47 - 53, Handle
rejection from the en_US fallback fetch by adding terminal error handling after
the fallback promise chain in Document.tsx (lines 47-53) and index.tsx (lines
58-64). Apply the existing request-error pattern or set an explicit error state
in both fallback paths so every asynchronous request is handled.
Source: Coding guidelines
| /** | ||
| * 建立基线。探测失败返回 false 且不写入基线 —— 失败绝不能当成「0 台」, | ||
| * 否则下一轮会把存量机器全部误判成新上报,给用户一个假的成功信号。 | ||
| */ | ||
| const captureBaseline = useCallback(async () => { | ||
| const [all, ungrouped] = await Promise.all([probeTargets(), probeTargets({ gids: '0', limit: 100 })]); | ||
| if (!all) return false; | ||
| baselineRef.current = { | ||
| total: all.total, | ||
| ungrouped: new Set(_.map(ungrouped?.list ?? [], 'ident')), | ||
| }; | ||
| return true; | ||
| }, []); | ||
|
|
||
| const start = useCallback(() => { | ||
| baselineRef.current = null; | ||
| setNewIdents([]); | ||
| setStatus('baselining'); | ||
| startedAtRef.current = Date.now(); | ||
| captureBaseline().then((ok) => { | ||
| if (ok) setStatus('waiting'); | ||
| }); | ||
| }, [captureBaseline]); | ||
|
|
||
| useEffect(() => { | ||
| start(); | ||
| }, [start]); | ||
|
|
||
| useInterval( | ||
| () => { | ||
| if (Date.now() - startedAtRef.current > POLL_TIMEOUT) { | ||
| setStatus('timeout'); | ||
| return; | ||
| } | ||
| const baseline = baselineRef.current; | ||
| if (!baseline) { | ||
| // 首次探测失败,先把基线补建起来,期间不做任何「新机器」判断 | ||
| captureBaseline().then((ok) => { | ||
| if (ok) setStatus('waiting'); | ||
| }); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Concurrent captureBaseline() calls can clobber the baseline with stale data.
captureBaseline has no guard against re-entrancy. While status is 'baselining' (or whenever baselineRef.current is still null), the useInterval tick (Line 59-64) fires every POLL_INTERVAL and calls captureBaseline() again — even if the original call from start() (Line 43) is merely slow, not failed. If the first (now-stale) call resolves after the second, it overwrites baselineRef.current with older data, which can produce a spurious "new host detected" or mask a real one on the next tick. This will reproduce deterministically whenever probeTargets takes ≥5s to resolve.
🛠️ Suggested fix — guard against overlapping captures
+ const capturingRef = useRef(false);
+
const captureBaseline = useCallback(async () => {
- const [all, ungrouped] = await Promise.all([probeTargets(), probeTargets({ gids: '0', limit: 100 })]);
- if (!all) return false;
- baselineRef.current = {
- total: all.total,
- ungrouped: new Set(_.map(ungrouped?.list ?? [], 'ident')),
- };
- return true;
+ if (capturingRef.current) return false;
+ capturingRef.current = true;
+ try {
+ const [all, ungrouped] = await Promise.all([probeTargets(), probeTargets({ gids: '0', limit: 100 })]);
+ if (!all) return false;
+ baselineRef.current = {
+ total: all.total,
+ ungrouped: new Set(_.map(ungrouped?.list ?? [], 'ident')),
+ };
+ return true;
+ } finally {
+ capturingRef.current = false;
+ }
}, []);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** | |
| * 建立基线。探测失败返回 false 且不写入基线 —— 失败绝不能当成「0 台」, | |
| * 否则下一轮会把存量机器全部误判成新上报,给用户一个假的成功信号。 | |
| */ | |
| const captureBaseline = useCallback(async () => { | |
| const [all, ungrouped] = await Promise.all([probeTargets(), probeTargets({ gids: '0', limit: 100 })]); | |
| if (!all) return false; | |
| baselineRef.current = { | |
| total: all.total, | |
| ungrouped: new Set(_.map(ungrouped?.list ?? [], 'ident')), | |
| }; | |
| return true; | |
| }, []); | |
| const start = useCallback(() => { | |
| baselineRef.current = null; | |
| setNewIdents([]); | |
| setStatus('baselining'); | |
| startedAtRef.current = Date.now(); | |
| captureBaseline().then((ok) => { | |
| if (ok) setStatus('waiting'); | |
| }); | |
| }, [captureBaseline]); | |
| useEffect(() => { | |
| start(); | |
| }, [start]); | |
| useInterval( | |
| () => { | |
| if (Date.now() - startedAtRef.current > POLL_TIMEOUT) { | |
| setStatus('timeout'); | |
| return; | |
| } | |
| const baseline = baselineRef.current; | |
| if (!baseline) { | |
| // 首次探测失败,先把基线补建起来,期间不做任何「新机器」判断 | |
| captureBaseline().then((ok) => { | |
| if (ok) setStatus('waiting'); | |
| }); | |
| return; | |
| } | |
| const capturingRef = useRef(false); | |
| /** | |
| * 建立基线。探测失败返回 false 且不写入基线 —— 失败绝不能当成「0 台」, | |
| * 否则下一轮会把存量机器全部误判成新上报,给用户一个假的成功信号。 | |
| */ | |
| const captureBaseline = useCallback(async () => { | |
| if (capturingRef.current) return false; | |
| capturingRef.current = true; | |
| try { | |
| const [all, ungrouped] = await Promise.all([probeTargets(), probeTargets({ gids: '0', limit: 100 })]); | |
| if (!all) return false; | |
| baselineRef.current = { | |
| total: all.total, | |
| ungrouped: new Set(_.map(ungrouped?.list ?? [], 'ident')), | |
| }; | |
| return true; | |
| } finally { | |
| capturingRef.current = false; | |
| } | |
| }, []); | |
| const start = useCallback(() => { | |
| baselineRef.current = null; | |
| setNewIdents([]); | |
| setStatus('baselining'); | |
| startedAtRef.current = Date.now(); | |
| captureBaseline().then((ok) => { | |
| if (ok) setStatus('waiting'); | |
| }); | |
| }, [captureBaseline]); | |
| useEffect(() => { | |
| start(); | |
| }, [start]); | |
| useInterval( | |
| () => { | |
| if (Date.now() - startedAtRef.current > POLL_TIMEOUT) { | |
| setStatus('timeout'); | |
| return; | |
| } | |
| const baseline = baselineRef.current; | |
| if (!baseline) { | |
| // 首次探测失败,先把基线补建起来,期间不做任何「新机器」判断 | |
| captureBaseline().then((ok) => { | |
| if (ok) setStatus('waiting'); | |
| }); | |
| return; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/pages/hosts/pages/List/InstallCategraf/useTargetArrival.ts` around lines
24 - 65, Prevent overlapping baseline probes in captureBaseline by adding an
in-flight guard or shared promise, so start and the useInterval recovery path
reuse the same capture attempt rather than launching concurrent calls. Ensure
the guard is cleared when the attempt settles, while baselineRef.current is
updated only from the active capture result and existing success/failure status
behavior is preserved.
| export function getCategrafInstallMeta(): Promise<CategrafInstallMeta | null> { | ||
| if (IS_PLUS) return Promise.resolve(null); // 企业版有自己的采集器接入体系 | ||
| if (!categrafMetaPromise) { | ||
| categrafMetaPromise = fetch(`${basePrefix}/api/n9e/agents/categraf/meta`, { | ||
| method: 'GET', | ||
| credentials: 'include', | ||
| headers: { | ||
| Accept: 'application/json', | ||
| Authorization: `Bearer ${localStorage.getItem(AccessTokenKey) || ''}`, | ||
| 'X-Language': i18next.language, | ||
| }, | ||
| }) | ||
| .then(async (res) => { | ||
| if (!res.ok) return null; | ||
| // SPA 兜底返回的是 HTML,content-type 与首字符两道判断 | ||
| if (!(res.headers.get('content-type') || '').includes('json')) return null; | ||
| const text = await res.text(); | ||
| if (/^\s*</.test(text)) return null; | ||
| const dat = JSON.parse(text)?.dat; | ||
| if (!dat || typeof dat.version !== 'string') return null; | ||
| return { | ||
| bundled: !!dat.bundled, | ||
| version: dat.version, | ||
| arches: Array.isArray(dat.arches) ? dat.arches : [], | ||
| basic_auth: !!dat.basic_auth, | ||
| base_url: dat.base_url || '', | ||
| script_url: dat.script_url || '', | ||
| } as CategrafInstallMeta; | ||
| }) | ||
| .catch((err) => { | ||
| // 按「不支持」降级,但必须留痕:这个探测决定入口是否出现, | ||
| // 静默失败会让一键安装按钮无声消失且毫无排查线索 | ||
| console.error('categraf install meta probe failed', err); | ||
| categrafMetaPromise = undefined; | ||
| return null; | ||
| }); // 这个 promise 永不 reject | ||
| } | ||
| return categrafMetaPromise; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Non-2xx responses are cached as "unsupported" forever, defeating the stated retry intent.
The docstring says network-layer failures aren't cached so a "next mount" retry is possible, but that guarantee only covers fetch rejecting. If the response resolves with !res.ok (Line 85) — e.g. a transient 401 because the access token was mid-refresh at initial page load, or a 500 — categrafMetaPromise is never reset, so null is memoized for the rest of the SPA session and the install entry silently disappears until a full page reload, exactly the failure mode the comment says it's trying to avoid.
Consider only treating clearly deterministic "route doesn't exist" signals (e.g. 404) as permanently cacheable, and resetting the cache (like the catch block already does) for other non-2xx statuses so a later mount can retry.
🛠️ Suggested fix
.then(async (res) => {
- if (!res.ok) return null;
+ if (!res.ok) {
+ // 404 之外的状态码(如登录态刷新期间的 401、临时的 5xx)不代表「不支持」,
+ // 不能永久缓存,否则一次瞬断会让入口整个会话消失
+ if (res.status !== 404) categrafMetaPromise = undefined;
+ return null;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function getCategrafInstallMeta(): Promise<CategrafInstallMeta | null> { | |
| if (IS_PLUS) return Promise.resolve(null); // 企业版有自己的采集器接入体系 | |
| if (!categrafMetaPromise) { | |
| categrafMetaPromise = fetch(`${basePrefix}/api/n9e/agents/categraf/meta`, { | |
| method: 'GET', | |
| credentials: 'include', | |
| headers: { | |
| Accept: 'application/json', | |
| Authorization: `Bearer ${localStorage.getItem(AccessTokenKey) || ''}`, | |
| 'X-Language': i18next.language, | |
| }, | |
| }) | |
| .then(async (res) => { | |
| if (!res.ok) return null; | |
| // SPA 兜底返回的是 HTML,content-type 与首字符两道判断 | |
| if (!(res.headers.get('content-type') || '').includes('json')) return null; | |
| const text = await res.text(); | |
| if (/^\s*</.test(text)) return null; | |
| const dat = JSON.parse(text)?.dat; | |
| if (!dat || typeof dat.version !== 'string') return null; | |
| return { | |
| bundled: !!dat.bundled, | |
| version: dat.version, | |
| arches: Array.isArray(dat.arches) ? dat.arches : [], | |
| basic_auth: !!dat.basic_auth, | |
| base_url: dat.base_url || '', | |
| script_url: dat.script_url || '', | |
| } as CategrafInstallMeta; | |
| }) | |
| .catch((err) => { | |
| // 按「不支持」降级,但必须留痕:这个探测决定入口是否出现, | |
| // 静默失败会让一键安装按钮无声消失且毫无排查线索 | |
| console.error('categraf install meta probe failed', err); | |
| categrafMetaPromise = undefined; | |
| return null; | |
| }); // 这个 promise 永不 reject | |
| } | |
| return categrafMetaPromise; | |
| } | |
| .then(async (res) => { | |
| if (!res.ok) { | |
| // 404 之外的状态码(如登录态刷新期间的 401、临时的 5xx)不代表「不支持」, | |
| // 不能永久缓存,否则一次瞬断会让入口整个会话消失 | |
| if (res.status !== 404) categrafMetaPromise = undefined; | |
| return null; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/pages/hosts/services.ts` around lines 72 - 110, Update
getCategrafInstallMeta’s response handling so only a deterministic missing-route
response such as 404 remains cached as null; for other non-2xx responses, clear
categrafMetaPromise before returning null, matching the retry behavior in the
catch block. Preserve the existing successful JSON validation and network-error
handling.
Summary by CodeRabbit
New Features
Documentation