zilliz 1.4.3

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
use std::time::Instant;

use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};

use crate::config::manager::{ConfigManager, SessionInfo};
use crate::model::loader::Models;

use super::wizard::WizardState;

/// Region derived from the active control-plane endpoint. Used by the home
/// screen and status bar.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Region {
    Global,
    China,
    Dev,
    Other(String),
}

impl Region {
    pub fn slug(&self) -> String {
        match self {
            Region::Global => "global".to_string(),
            Region::China => "china".to_string(),
            Region::Dev => "dev".to_string(),
            Region::Other(s) => s.clone(),
        }
    }

    pub fn from_endpoint(endpoint: Option<&str>) -> Self {
        let ep = endpoint.unwrap_or("https://api.cloud.zilliz.com");
        if ep.contains("api.cloud.zilliz.com.cn") {
            Region::China
        } else if ep.contains("api.cloud-uat3.zilliz.com") {
            Region::Dev
        } else if ep.contains("api.cloud.zilliz.com") {
            Region::Global
        } else {
            // Host portion only, no scheme.
            let host = ep
                .trim_start_matches("https://")
                .trim_start_matches("http://")
                .split('/')
                .next()
                .unwrap_or(ep);
            Region::Other(host.to_string())
        }
    }
}

/// How the active session was acquired. Derived from the on-disk credentials
/// shape (presence of `[user]` section vs API-key-only).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMethod {
    Auth0,
    ApiKey,
}

#[derive(Debug, Clone)]
pub enum AuthState {
    SignedOut,
    SignedIn {
        method: AuthMethod,
        user: Option<String>,
        email: Option<String>,
        org: Option<String>,
        region: Region,
        endpoint: String,
        /// Display-only masked API key (set only for `ApiKey` sessions). Never
        /// contains the raw key — masking happens in `AuthSnapshot::load`.
        masked_api_key: Option<String>,
    },
}

#[derive(Debug, Clone)]
pub struct AuthSnapshot {
    pub state: AuthState,
    pub credentials_path: std::path::PathBuf,
}

impl AuthSnapshot {
    pub fn load(config_mgr: &ConfigManager) -> Self {
        let session: SessionInfo = config_mgr.current_session();
        let state = if session.has_active_credentials {
            let region = Region::from_endpoint(session.endpoint.as_deref());
            let endpoint = session
                .endpoint
                .clone()
                .unwrap_or_else(|| "https://api.cloud.zilliz.com".to_string());
            let method = if session.has_user_section {
                AuthMethod::Auth0
            } else {
                AuthMethod::ApiKey
            };
            // Compute the masked form immediately and drop the raw key by not
            // propagating it past this scope.
            let masked_api_key = match method {
                AuthMethod::ApiKey => session
                    .raw_api_key
                    .as_deref()
                    .map(crate::auth::mask_api_key),
                AuthMethod::Auth0 => None,
            };
            AuthState::SignedIn {
                method,
                user: session.user.clone(),
                email: session.email.clone(),
                org: session.org.clone(),
                region,
                endpoint,
                masked_api_key,
            }
        } else {
            AuthState::SignedOut
        };
        Self {
            state,
            credentials_path: session.credentials_path,
        }
    }

    pub fn is_signed_in(&self) -> bool {
        matches!(self.state, AuthState::SignedIn { .. })
    }
}

/// Which screen the TUI is currently showing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Screen {
    Home,
    SignInRegion,
    SignInMethod,
    SignInBrowser,
    SignInApiKey,
    LogoutConfirm,
    Help,
}

/// Async message sent from background tasks (device-code poller) to the
/// main event loop.
#[derive(Debug)]
pub enum WizardMsg {
    DeviceCodeReady(crate::auth::device_code::DeviceCodeResponse),
    DeviceCodeError(String),
    LoginExchanged(crate::auth::device_code::LoginPayload),
    PollError(String),
    ExchangeError(String),
}

/// Per-row state for the signed-in home account-summary counts.
#[derive(Debug, Clone)]
pub enum CountCell {
    Loading,
    Loaded(u64),
    Failed(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClusterCounts {
    pub total: u64,
    pub running: u64,
    pub suspended: u64,
    pub abnormal: u64,
}

#[derive(Debug, Clone)]
pub enum ClusterCountCell {
    Loading,
    Loaded(ClusterCounts),
    Failed(String),
}

#[derive(Debug, Clone, PartialEq)]
pub struct BillingInfo {
    pub balance: f64,
    pub last_month: f64,
    pub last_month_settled: bool,
}

#[derive(Debug, Clone)]
pub enum BillingCell {
    Loading,
    Loaded(BillingInfo),
    Failed(String),
}

#[derive(Debug, Clone, PartialEq)]
pub struct UsageInfo {
    pub today: f64,
    pub week: f64,
    pub month: f64,
}

#[derive(Debug, Clone)]
pub enum UsageCell {
    Loading,
    Loaded(UsageInfo),
    Failed(String),
}

#[derive(Debug, Clone)]
pub struct HomeCounts {
    pub projects: CountCell,
    pub import_jobs: CountCell,
    pub clusters: ClusterCountCell,
    pub billing: BillingCell,
    pub usage: UsageCell,
    pub volumes: CountCell,
    pub backups: CountCell,
}

impl HomeCounts {
    pub fn loading() -> Self {
        Self {
            projects: CountCell::Loading,
            import_jobs: CountCell::Loading,
            clusters: ClusterCountCell::Loading,
            billing: BillingCell::Loading,
            usage: UsageCell::Loading,
            volumes: CountCell::Loading,
            backups: CountCell::Loading,
        }
    }

    pub fn any_failed(&self) -> bool {
        matches!(self.projects, CountCell::Failed(_))
            || matches!(self.import_jobs, CountCell::Failed(_))
            || matches!(self.clusters, ClusterCountCell::Failed(_))
            || matches!(self.usage, UsageCell::Failed(_))
            || matches!(self.volumes, CountCell::Failed(_))
            || matches!(self.backups, CountCell::Failed(_))
    }
}

/// Async message sent from the home-counts background task to the main loop.
#[derive(Debug)]
pub enum HomeMsg {
    ProjectsLoaded(Result<u64, String>),
    ImportJobsLoaded(Result<u64, String>),
    ClustersLoaded(Result<ClusterCounts, String>),
    BillingLoaded(Result<BillingInfo, String>),
    UsageLoaded(Result<UsageInfo, String>),
    VolumesLoaded(Result<u64, String>),
    BackupsLoaded(Result<u64, String>),
}

/// Main application state.
pub struct App {
    pub models: Models,
    pub config_mgr: ConfigManager,
    pub should_quit: bool,
    pub screen_stack: Vec<Screen>,
    pub auth: AuthSnapshot,
    pub wizard: Option<WizardState>,
    pub msg_tx: UnboundedSender<WizardMsg>,
    pub msg_rx: UnboundedReceiver<WizardMsg>,
    pub home_counts: HomeCounts,
    pub home_tx: UnboundedSender<HomeMsg>,
    pub home_rx: UnboundedReceiver<HomeMsg>,
    pub home_counts_task: Option<tokio::task::JoinHandle<()>>,
    pub home_counts_fetched_at: Option<Instant>,
    /// Number of `HomeMsg` results still expected from the in-flight fetch.
    /// Completion is tracked explicitly here rather than derived from cell
    /// states: on a periodic refresh the cells are already non-loading, so
    /// deriving "done" from them would drop the task handle on the first
    /// message while the fetch is still running.
    pub home_counts_pending: usize,
}

const HOME_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);

/// Total number of `HomeMsg` results one fetch emits, one per card row:
/// projects, import jobs, clusters, billing, usage, volumes, backups.
/// `home_fetch::run` always sends exactly this many across every code path.
const HOME_COUNT_MESSAGES: usize = 7;

impl App {
    pub fn new(models: Models, config_mgr: ConfigManager) -> Self {
        let auth = AuthSnapshot::load(&config_mgr);
        let (tx, rx) = unbounded_channel();
        let (home_tx, home_rx) = unbounded_channel();
        Self {
            models,
            config_mgr,
            should_quit: false,
            screen_stack: vec![Screen::Home],
            auth,
            wizard: None,
            msg_tx: tx,
            msg_rx: rx,
            home_counts: HomeCounts::loading(),
            home_tx,
            home_rx,
            home_counts_task: None,
            home_counts_fetched_at: None,
            home_counts_pending: 0,
        }
    }

    /// Spawn the background task that populates `home_counts`. No-op if a
    /// task is already in flight, or if no Tokio runtime is active on the
    /// current thread (which happens in unit tests that drive the handler
    /// without an async context).
    pub fn spawn_home_counts_fetch(&mut self) {
        if self.home_counts_task.is_some() {
            return;
        }
        if tokio::runtime::Handle::try_current().is_err() {
            return;
        }
        // Discard any results still queued from a previous (aborted) fetch so
        // they can't bleed into this one — e.g. a different account's counts
        // arriving after a sign-out + sign-in.
        while self.home_rx.try_recv().is_ok() {}
        // Only show loading placeholders on the first fetch. On periodic
        // refreshes keep the existing values on screen and replace them in
        // place when the new results arrive, so the cards don't flicker.
        if self.home_counts_fetched_at.is_none() {
            self.home_counts = HomeCounts::loading();
        }
        self.home_counts_pending = HOME_COUNT_MESSAGES;
        self.home_counts_fetched_at = Some(Instant::now());
        self.home_counts_task = Some(super::home_fetch::spawn(
            self.models.clone(),
            self.config_mgr.clone(),
            self.home_tx.clone(),
        ));
    }

    /// Returns true when enough time has elapsed since the last fetch and no
    /// fetch is currently in flight.
    pub fn home_counts_refresh_due(&self) -> bool {
        if self.home_counts_task.is_some() {
            return false;
        }
        match self.home_counts_fetched_at {
            Some(t) => t.elapsed() >= HOME_REFRESH_INTERVAL,
            None => false,
        }
    }

    /// Abort any in-flight home-counts fetch and reset the cells to loading
    /// defaults. Called on sign-out and quit.
    pub fn abort_home_counts_fetch(&mut self) {
        if let Some(handle) = self.home_counts_task.take() {
            handle.abort();
        }
        // Drop any results the aborted task already queued so they can't be
        // applied to a later signed-out / different session.
        while self.home_rx.try_recv().is_ok() {}
        self.home_counts = HomeCounts::loading();
        self.home_counts_fetched_at = None;
        self.home_counts_pending = 0;
    }

    pub fn apply_home_msg(&mut self, msg: HomeMsg) {
        match msg {
            HomeMsg::ProjectsLoaded(Ok(n)) => self.home_counts.projects = CountCell::Loaded(n),
            HomeMsg::ProjectsLoaded(Err(e)) => self.home_counts.projects = CountCell::Failed(e),
            HomeMsg::ImportJobsLoaded(Ok(n)) => self.home_counts.import_jobs = CountCell::Loaded(n),
            HomeMsg::ImportJobsLoaded(Err(e)) => {
                self.home_counts.import_jobs = CountCell::Failed(e)
            }
            HomeMsg::ClustersLoaded(Ok(c)) => {
                self.home_counts.clusters = ClusterCountCell::Loaded(c)
            }
            HomeMsg::ClustersLoaded(Err(e)) => {
                self.home_counts.clusters = ClusterCountCell::Failed(e)
            }
            HomeMsg::BillingLoaded(Ok(info)) => {
                self.home_counts.billing = BillingCell::Loaded(info)
            }
            HomeMsg::BillingLoaded(Err(e)) => self.home_counts.billing = BillingCell::Failed(e),
            HomeMsg::UsageLoaded(Ok(info)) => self.home_counts.usage = UsageCell::Loaded(info),
            HomeMsg::UsageLoaded(Err(e)) => self.home_counts.usage = UsageCell::Failed(e),
            HomeMsg::VolumesLoaded(Ok(n)) => self.home_counts.volumes = CountCell::Loaded(n),
            HomeMsg::VolumesLoaded(Err(e)) => self.home_counts.volumes = CountCell::Failed(e),
            HomeMsg::BackupsLoaded(Ok(n)) => self.home_counts.backups = CountCell::Loaded(n),
            HomeMsg::BackupsLoaded(Err(e)) => self.home_counts.backups = CountCell::Failed(e),
        }
        // Drop the task handle only once every expected result has arrived,
        // tracked by an explicit pending count. Deriving this from the cell
        // states would mis-fire on periodic refreshes, where the cells are
        // already non-loading before the new results land.
        self.home_counts_pending = self.home_counts_pending.saturating_sub(1);
        if self.home_counts_pending == 0 {
            self.home_counts_task = None;
        }
    }

    pub fn current_screen(&self) -> &Screen {
        self.screen_stack.last().unwrap_or(&Screen::Home)
    }

    /// The screen *underneath* the currently-active one, if any. Used by the
    /// help overlay to render the host screen behind the modal.
    pub fn screen_below_top(&self) -> Option<&Screen> {
        if self.screen_stack.len() < 2 {
            return None;
        }
        self.screen_stack.get(self.screen_stack.len() - 2)
    }

    /// Re-read credentials and refresh the in-memory auth snapshot. Called
    /// after a successful in-TUI sign-in.
    pub fn refresh_auth(&mut self) {
        self.auth = AuthSnapshot::load(&self.config_mgr);
    }
}