1use anyhow::{Context, Result};
2use futu_core::diagnostic::DiagnosticDecision;
3use serde::Serialize;
4use serde_json::Value;
5use tabled::Tabled;
6
7use crate::output::OutputFormat;
8
9#[derive(Debug, Clone, Serialize)]
10pub(crate) struct StaticStatusReport {
11 pub(crate) ready: bool,
12 pub(crate) reason: String,
13 pub(crate) action: String,
14 pub(crate) security_count: u64,
15 pub(crate) stock_list_readiness: String,
16 pub(crate) stock_list_readiness_state: String,
17 pub(crate) stock_list_first_sync_done: bool,
18 pub(crate) stock_list_converged: bool,
19 pub(crate) stock_list_sync_started_total: u64,
20 pub(crate) stock_list_sync_finished_total: u64,
21 pub(crate) stock_list_sync_failed_total: u64,
22 pub(crate) stock_list_sync_recoverable_retry_total: u64,
23 pub(crate) stock_list_zero_delta_total: u64,
24 pub(crate) stock_list_last_version: u64,
25 pub(crate) stock_list_last_total_stocks: u64,
26 pub(crate) stock_list_last_cached_count: u64,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub(crate) stock_list_last_finished_ms: Option<u64>,
29 pub(crate) decisions: Vec<DiagnosticDecision>,
30 pub(crate) findings: Vec<StaticStatusFinding>,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub(crate) struct StaticStatusFinding {
35 pub(crate) level: &'static str,
36 pub(crate) message: String,
37}
38
39#[derive(Tabled)]
40struct StaticStatusRow {
41 field: String,
42 value: String,
43}
44
45pub async fn run_static_status(
46 rest_url: Option<&str>,
47 api_key: Option<&str>,
48 output: OutputFormat,
49) -> Result<()> {
50 let body = super::daemon::request(
51 reqwest::Method::GET,
52 "/api/admin/status",
53 rest_url,
54 api_key,
55 5,
56 )
57 .await?;
58 let value: Value = serde_json::from_str(&body).context("parse /api/admin/status JSON")?;
59 let report = summarize_static_status(&value)?;
60 print_static_status(&report, output)
61}
62
63pub(crate) fn summarize_static_status(status: &Value) -> Result<StaticStatusReport> {
64 let static_data = status
65 .get("cache")
66 .and_then(|v| v.get("static_data"))
67 .ok_or_else(|| anyhow::anyhow!("missing cache.static_data in /api/admin/status"))?;
68 let security_count = u64_field(static_data, "security_count")?;
69 let first_sync_done = bool_field(static_data, "stock_list_first_sync_done")?;
70 let converged = bool_field(static_data, "stock_list_converged")?;
71 let started_total = u64_field(static_data, "stock_list_sync_started_total")?;
72 let finished_total = u64_field(static_data, "stock_list_sync_finished_total")?;
73 let failed_total = u64_field(static_data, "stock_list_sync_failed_total")?;
74 let recoverable_retry_total =
75 optional_u64_field(static_data, "stock_list_sync_recoverable_retry_total");
76 let zero_delta_total = optional_u64_field(static_data, "stock_list_zero_delta_total");
77 let last_version = u64_field(static_data, "stock_list_last_version")?;
78 let last_total_stocks = u64_field(static_data, "stock_list_last_total_stocks")?;
79 let last_cached_count = u64_field(static_data, "stock_list_last_cached_count")?;
80 let last_finished_ms = static_data
81 .get("stock_list_last_finished_ms")
82 .and_then(Value::as_u64);
83 let readiness = static_data
84 .get("stock_list_readiness")
85 .and_then(Value::as_str)
86 .map(str::to_string)
87 .unwrap_or_else(|| {
88 derive_stock_list_readiness(
89 security_count,
90 first_sync_done,
91 converged,
92 started_total,
93 finished_total,
94 failed_total,
95 recoverable_retry_total,
96 )
97 .to_string()
98 });
99
100 let mut findings = Vec::new();
101 if readiness == "bootstrap_only" {
102 findings.push(StaticStatusFinding {
103 level: "INFO",
104 message: "static cache only has SQLite bootstrap rows; wait for backend stock-list sync before parity checks".to_string(),
105 });
106 }
107 if !first_sync_done {
108 findings.push(StaticStatusFinding {
109 level: "WARN",
110 message: "stock-list first sync not finished yet".to_string(),
111 });
112 } else if !converged {
113 findings.push(StaticStatusFinding {
114 level: "INFO",
115 message: "stock-list first sync finished but not converged yet; wait for a zero-delta sync before static parity checks".to_string(),
116 });
117 }
118 if started_total > finished_total {
119 findings.push(StaticStatusFinding {
120 level: "INFO",
121 message: format!(
122 "stock-list sync in progress or pending: started_total={started_total}, finished_total={finished_total}"
123 ),
124 });
125 }
126 if failed_total > 0 {
127 findings.push(StaticStatusFinding {
128 level: "WARN",
129 message: format!("stock-list sync has failed_total={failed_total}"),
130 });
131 }
132 if recoverable_retry_total > 0 {
133 findings.push(StaticStatusFinding {
134 level: "INFO",
135 message: format!(
136 "stock-list sync had recoverable checksum rollback/retry total={recoverable_retry_total}"
137 ),
138 });
139 }
140 if security_count == 0 {
141 findings.push(StaticStatusFinding {
142 level: "WARN",
143 message: "static security cache is empty".to_string(),
144 });
145 }
146 let (reason, action) = static_status_reason_action(
147 &readiness,
148 security_count,
149 first_sync_done,
150 converged,
151 started_total,
152 finished_total,
153 failed_total,
154 recoverable_retry_total,
155 );
156 let readiness_state = stock_list_readiness_state(&readiness).to_string();
157 let ready = first_sync_done && converged && security_count > 0;
158 let decision = if ready {
159 DiagnosticDecision::allowed(
160 "stock_list_sync",
161 reason.clone(),
162 "cache.static_data",
163 readiness_state.clone(),
164 )
165 } else {
166 DiagnosticDecision::blocked(
167 "stock_list_sync",
168 reason.clone(),
169 action.clone(),
170 "cache.static_data",
171 readiness_state.clone(),
172 )
173 };
174
175 if findings.is_empty() {
176 findings.push(StaticStatusFinding {
177 level: "OK",
178 message: "static cache is ready".to_string(),
179 });
180 }
181
182 Ok(StaticStatusReport {
183 ready,
184 reason,
185 action,
186 security_count,
187 stock_list_readiness: readiness,
188 stock_list_readiness_state: readiness_state,
189 stock_list_first_sync_done: first_sync_done,
190 stock_list_converged: converged,
191 stock_list_sync_started_total: started_total,
192 stock_list_sync_finished_total: finished_total,
193 stock_list_sync_failed_total: failed_total,
194 stock_list_sync_recoverable_retry_total: recoverable_retry_total,
195 stock_list_zero_delta_total: zero_delta_total,
196 stock_list_last_version: last_version,
197 stock_list_last_total_stocks: last_total_stocks,
198 stock_list_last_cached_count: last_cached_count,
199 stock_list_last_finished_ms: last_finished_ms,
200 decisions: vec![decision],
201 findings,
202 })
203}
204
205fn stock_list_readiness_state(readiness: &str) -> &'static str {
206 match readiness {
207 "converged" => "ready",
208 "converged_after_recoverable_retry" | "converged_with_failures" => "degraded_ready",
209 "first_sync_ready" => "waiting_convergence",
210 "recoverable_retrying" | "sync_in_progress" => "retrying",
211 "bootstrap_only" => "waiting_first_sync",
212 "failed_before_first_sync" => "failed",
213 "empty" => "empty",
214 _ => "unknown",
215 }
216}
217
218fn static_status_reason_action(
219 readiness: &str,
220 security_count: u64,
221 first_sync_done: bool,
222 converged: bool,
223 started_total: u64,
224 finished_total: u64,
225 failed_total: u64,
226 recoverable_retry_total: u64,
227) -> (String, String) {
228 if security_count == 0 {
229 return (
230 "static security cache is empty; no stock-list rows are currently available"
231 .to_string(),
232 "check daemon startup logs, then run `futucli static-warmup` and rerun `futucli static-status`".to_string(),
233 );
234 }
235
236 if !first_sync_done {
237 let reason = match readiness {
238 "bootstrap_only" => {
239 "SQLite bootstrap rows are loaded, but backend stock-list first sync has not completed"
240 }
241 "recoverable_retrying" => {
242 "stock-list first sync is retrying after recoverable checksum rollback"
243 }
244 "sync_in_progress" => "stock-list first sync is still in progress",
245 "failed_before_first_sync" => {
246 "stock-list first sync has not completed after a backend/local sync failure"
247 }
248 _ if started_total > finished_total => "stock-list first sync is still in progress",
249 _ if failed_total > 0 => {
250 "stock-list first sync has not completed after a backend/local sync failure"
251 }
252 _ => "stock-list first sync has not completed",
253 };
254 return (
255 reason.to_string(),
256 "wait for backend stock-list first sync before C++ parity checks; rerun `futucli static-status` or trigger `futucli static-warmup` if it stays unchanged".to_string(),
257 );
258 }
259
260 if !converged {
261 return (
262 "stock-list first sync finished, but zero-delta convergence has not been observed"
263 .to_string(),
264 "basic lookup may work, but wait for zero-delta before static parity checks; rerun `futucli static-status` after the next sync".to_string(),
265 );
266 }
267
268 if failed_total > 0 || recoverable_retry_total > 0 {
269 return (
270 format!(
271 "stock-list is converged after earlier retry/failure counters: failed_total={failed_total}, recoverable_retry_total={recoverable_retry_total}"
272 ),
273 "current static cache is usable; keep the counters in bug reports if symbol mapping later looks wrong".to_string(),
274 );
275 }
276
277 (
278 "stock-list first sync and zero-delta convergence are complete".to_string(),
279 "static cache is ready for smoke and parity checks".to_string(),
280 )
281}
282
283fn derive_stock_list_readiness(
284 security_count: u64,
285 first_sync_done: bool,
286 converged: bool,
287 started_total: u64,
288 finished_total: u64,
289 failed_total: u64,
290 recoverable_retry_total: u64,
291) -> &'static str {
292 if !first_sync_done {
293 if started_total > finished_total {
294 if recoverable_retry_total > 0 {
295 return "recoverable_retrying";
296 }
297 return "sync_in_progress";
298 }
299 if security_count > 0 {
300 return "bootstrap_only";
301 }
302 if failed_total > 0 {
303 return "failed_before_first_sync";
304 }
305 return "empty";
306 }
307
308 if converged {
309 if failed_total > 0 {
310 return "converged_with_failures";
311 }
312 if recoverable_retry_total > 0 {
313 return "converged_after_recoverable_retry";
314 }
315 return "converged";
316 }
317
318 if started_total > finished_total {
319 if recoverable_retry_total > 0 {
320 return "recoverable_retrying";
321 }
322 return "sync_in_progress";
323 }
324
325 "first_sync_ready"
326}
327
328fn u64_field(value: &Value, key: &str) -> Result<u64> {
329 value
330 .get(key)
331 .and_then(Value::as_u64)
332 .ok_or_else(|| anyhow::anyhow!("cache.static_data.{key} missing or not u64"))
333}
334
335fn optional_u64_field(value: &Value, key: &str) -> u64 {
336 value.get(key).and_then(Value::as_u64).unwrap_or(0)
337}
338
339fn bool_field(value: &Value, key: &str) -> Result<bool> {
340 value
341 .get(key)
342 .and_then(Value::as_bool)
343 .ok_or_else(|| anyhow::anyhow!("cache.static_data.{key} missing or not bool"))
344}
345
346fn print_static_status(report: &StaticStatusReport, output: OutputFormat) -> Result<()> {
347 match output {
348 OutputFormat::Table | OutputFormat::Markdown => {
349 let rows = vec![
350 row("ready", report.ready),
351 row("reason", &report.reason),
352 row("action", &report.action),
353 row("security_count", report.security_count),
354 row("stock_list_readiness", &report.stock_list_readiness),
355 row(
356 "stock_list_readiness_state",
357 &report.stock_list_readiness_state,
358 ),
359 row(
360 "stock_list_first_sync_done",
361 report.stock_list_first_sync_done,
362 ),
363 row("stock_list_converged", report.stock_list_converged),
364 row(
365 "stock_list_sync_started_total",
366 report.stock_list_sync_started_total,
367 ),
368 row(
369 "stock_list_sync_finished_total",
370 report.stock_list_sync_finished_total,
371 ),
372 row(
373 "stock_list_sync_failed_total",
374 report.stock_list_sync_failed_total,
375 ),
376 row(
377 "stock_list_sync_recoverable_retry_total",
378 report.stock_list_sync_recoverable_retry_total,
379 ),
380 row(
381 "stock_list_zero_delta_total",
382 report.stock_list_zero_delta_total,
383 ),
384 row("stock_list_last_version", report.stock_list_last_version),
385 row(
386 "stock_list_last_total_stocks",
387 report.stock_list_last_total_stocks,
388 ),
389 row(
390 "stock_list_last_cached_count",
391 report.stock_list_last_cached_count,
392 ),
393 row(
394 "stock_list_last_finished_ms",
395 report
396 .stock_list_last_finished_ms
397 .map(|v| v.to_string())
398 .unwrap_or_else(|| "-".to_string()),
399 ),
400 ];
401 output.print_rows(&rows, std::slice::from_ref(report))?;
402 println!();
403 println!("Findings:");
404 for finding in &report.findings {
405 println!("- [{}] {}", finding.level, finding.message);
406 }
407 }
408 OutputFormat::Json => {
409 println!("{}", serde_json::to_string_pretty(report)?);
410 }
411 OutputFormat::Jsonl => {
412 println!("{}", serde_json::to_string(report)?);
413 }
414 }
415 Ok(())
416}
417
418fn row(field: impl Into<String>, value: impl ToString) -> StaticStatusRow {
419 StaticStatusRow {
420 field: field.into(),
421 value: value.to_string(),
422 }
423}
424
425#[cfg(test)]
426#[path = "static_diag_tests.rs"]
427mod static_diag_tests;