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
// SPDX-FileCopyrightText: 2026 RAprogramm <andrey.rozanov.vl@gmail.com>
// SPDX-License-Identifier: MIT
//! Drill-in views listing the resources contained in a project.
use super::ResourceTab;
/// A single row inside a drill-in view (a resource contained in a parent).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DrillItem {
/// The resource category, for opening the full details view.
pub tab: ResourceTab,
/// The resource id within its category.
pub id: String,
/// Resource kind label (e.g. "Server", "Database").
pub kind: String,
/// Resource name.
pub name: String,
/// Short secondary detail (status, engine, ...).
pub detail: String
}
/// A drill-in view showing the contents of a selected resource.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DrillView {
/// Title describing what was drilled into.
pub title: String,
/// Contained resources.
pub items: Vec<DrillItem>,
/// Index of the highlighted row.
pub selected: usize
}
impl super::App {
/// Raises the given project's contents in the content pane the moment the
/// sidebar selection lands on it: cached contents show instantly, an
/// uncached project shows its counts preview, and a silent background
/// fetch revalidates either way. Never blocks, never shows a skeleton.
pub fn select_project_drill(&mut self, index: usize) {
let Some(project) = self.projects.get(index) else {
return;
};
self.drill = self.drill_cache.get(&project.id).cloned();
self.drill_fetching_id = Some(project.id);
self.drill_retried = None;
self.drill_request = Some((ResourceTab::Projects, project.id, project.name.clone()));
}
/// Takes the pending drill request for the loop to fetch in the background.
#[must_use]
pub const fn take_drill_request(&mut self) -> Option<(ResourceTab, i32, String)> {
self.drill_request.take()
}
/// Applies drill contents fetched for project `id`: refreshes the cache,
/// and swaps the open pane in place when that project is still the one
/// selected (keeping the highlighted row where possible).
///
/// The project-resources endpoint occasionally flaps, returning a response
/// with whole collections missing. A response that strictly shrinks the
/// cached contents is therefore not applied immediately: it triggers one
/// silent re-fetch, and only a confirming second answer replaces the data
/// (so real deletions still show up).
pub fn apply_drill(&mut self, id: i32, view: DrillView) {
if self.drill_retried != Some(id)
&& let Some(cached) = self.drill_cache.get(&id)
&& looks_like_flap(cached, &view)
{
self.drill_retried = Some(id);
self.drill_request = Some((ResourceTab::Projects, id, view.title));
return;
}
self.drill_retried = None;
self.drill_cache.insert(id, view.clone());
if self.drill_fetching_id != Some(id) {
return;
}
let selected = self
.drill
.as_ref()
.map_or(0, |d| d.selected)
.min(view.items.len().saturating_sub(1));
self.drill = Some(DrillView {
selected,
..view
});
}
/// Closes the drill-in view; the content pane reverts to the selected
/// sidebar entry's default view.
pub fn close_drill(&mut self) {
self.drill = None;
self.drill_fetching_id = None;
self.detail_open = false;
}
/// Opens the full details view for the drill item under the highlight:
/// points the detail renderer at that resource in its own category.
/// Returns false when the resource is not loaded (yet).
pub fn open_drill_item_detail(&mut self) -> bool {
let Some(item) = self
.drill
.as_ref()
.and_then(|d| d.items.get(d.selected))
.cloned()
else {
return false;
};
let index = match item.tab {
ResourceTab::Servers => position(&self.servers, |s| s.id.to_string() == item.id),
ResourceTab::Databases => position(&self.databases, |d| d.id.to_string() == item.id),
ResourceTab::S3 => position(&self.s3_storages, |s| s.id.to_string() == item.id),
ResourceTab::Kubernetes => {
position(&self.k8s_clusters, |c| c.id.to_string() == item.id)
}
ResourceTab::Balancers => position(&self.balancers, |b| b.id.to_string() == item.id),
ResourceTab::DedicatedServers => {
position(&self.dedicated_servers, |d| d.id.to_string() == item.id)
}
ResourceTab::Apps => position(&self.apps, |a| a.id.to_string() == item.id),
_ => None
};
let Some(index) = index else {
return false;
};
self.active_tab = item.tab;
self.filter.clear();
self.filter_editing = false;
self.selected = index;
self.detail_scroll = 0;
self.detail_open = true;
self.detail_selected = crate::tui::widgets::details::initial_cursor(self);
self.detail_fetch = Some((item.tab, item.id));
true
}
/// Opens the interactive details panel for the resource selected in the
/// current service list: the same view a project drill opens, with the
/// action buttons on top and the deep sections fetched in the background.
pub fn open_selected_detail(&mut self) -> bool {
let Some((id, _)) = self.selected_resource() else {
return false;
};
self.detail_scroll = 0;
self.detail_open = true;
self.detail_selected = crate::tui::widgets::details::initial_cursor(self);
self.detail_fetch = Some((self.active_tab, id));
true
}
/// Takes the pending detail action for the loop to execute.
#[must_use]
pub const fn take_detail_action(
&mut self
) -> Option<(i32, crate::tui::widgets::details::DetailAction)> {
self.detail_action.take()
}
/// Takes the pending deep-detail fetch for the loop to run in the
/// background.
#[must_use]
pub const fn take_detail_fetch(&mut self) -> Option<(ResourceTab, String)> {
self.detail_fetch.take()
}
/// Returns true while a drill-in view is open.
#[must_use]
pub const fn drill_open(&self) -> bool {
self.drill.is_some()
}
/// Returns the open drill-in view, for rendering.
#[must_use]
pub const fn drill_view(&self) -> Option<&DrillView> {
self.drill.as_ref()
}
}
/// Index of the first element matching the predicate.
fn position<T>(items: &[T], pred: impl Fn(&T) -> bool) -> Option<usize> {
items.iter().position(pred)
}
/// True when `fresh` is a strict subset of `cached` — the signature of the
/// project-resources endpoint transiently dropping a collection rather than a
/// real deletion (which would change ids, not just shrink the set).
fn looks_like_flap(cached: &DrillView, fresh: &DrillView) -> bool {
fresh.items.len() < cached.items.len()
&& fresh
.items
.iter()
.all(|f| cached.items.iter().any(|c| c.tab == f.tab && c.id == f.id))
}
#[cfg(test)]
mod tests {
use crate::tui::app::{App, ResourceTab, VpcSummary};
#[test]
fn string_id_detail_fetch_and_extra_cache_round_trip() {
let mut app = App::new(5);
app.active_tab = ResourceTab::Vpc;
app.vpcs = vec![VpcSummary {
id: "8b3f9c2a-uuid".to_string(),
name: "net".to_string(),
subnet: "10.0.0.0/24".to_string(),
location: "ru-1".to_string()
}];
assert!(app.open_selected_detail());
let fetch = app.take_detail_fetch();
assert_eq!(
fetch,
Some((ResourceTab::Vpc, "8b3f9c2a-uuid".to_string())),
"a uuid-id resource must queue its raw string id"
);
assert_eq!(app.take_detail_fetch(), None, "the fetch is taken once");
let (tab, id) = fetch.unwrap();
let sections = vec![(
"Ports".to_string(),
vec![("10.0.0.2".to_string(), "dnat+snat".to_string())]
)];
app.detail_extra.insert((tab, id), sections.clone());
assert_eq!(
app.detail_extra
.get(&(ResourceTab::Vpc, "8b3f9c2a-uuid".to_string())),
Some(§ions),
"extras cached under the string id must be readable back"
);
}
}