Skip to content

Commit c5b697d

Browse files
authored
Bug fixes, age, watcher rework, yaml search (#175)
Signed-off-by: Daniel Guns <danbguns@gmail.com>
1 parent f0b0f85 commit c5b697d

27 files changed

Lines changed: 2061 additions & 1012 deletions

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,14 @@ openssl = { version = "0.10", optional = true }
121121
# Logging (tracing is already a transitive dependency via kube-rs, but we need it explicitly for macros)
122122
tracing = "0.1"
123123
tracing-subscriber = { version = "0.3", features = ["env-filter", "ansi"] }
124-
insta = "1.46.0"
125124

126125
# Version update notifications
127126
update-informer = "1.1"
128127

129128
[dev-dependencies]
130129
# Testing
131130
mockall = "0.13"
131+
insta = "1.46.0"
132132

133133
[package.metadata.binstall]
134134
# cargo-binstall configuration for pre-built binary installation

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ By default, `flux9s` watches the `flux-system` namespace. Use `:ns all` to view
115115
- `j` / `k` - Navigate up/down
116116
- `:` - Command mode (e.g., `:kustomization`, `:gitrepository`)
117117
- `Enter` - View resource details
118-
- `/` - Filter resources by name
118+
- `/` - Filter resources by name (list views) or search text (YAML/describe/trace views)
119+
- `n` / `N` - Next/previous search match (in text views)
120+
- `Shift+N` / `Shift+A` / `Shift+T` / `Shift+S` - Sort by name/age/type/status (press again to reverse)
119121
- `s` - Suspend resource
120122
- `r` - Resume resource
121123
- `R` - Reconcile resource

docs/content/user-guide/_index.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,12 @@ Use these keyboard shortcuts to navigate flux9s:
5555
| `j` / `k` | Navigate up/down |
5656
| `:` | Command mode (e.g., `:kustomization`, `:gitrepository`) |
5757
| `Enter` | View resource details |
58-
| `/` | Filter resources by name |
58+
| `/` | Filter resources by name (in list views) or search text (in YAML/describe/trace views) |
59+
| `n` / `N` | Jump to next/previous search match (in YAML/describe/trace views) |
60+
| `Shift+N` | Sort list by name (press again to reverse, third press restores default order) |
61+
| `Shift+A` | Sort list by age |
62+
| `Shift+T` | Sort list by type |
63+
| `Shift+S` | Sort list by status (problems first) |
5964
| `s` | Suspend reconciliation |
6065
| `r` | Resume reconciliation |
6166
| `R` | Reconcile resource |
@@ -165,6 +170,30 @@ The header displays a health percentage indicator showing the overall health of
165170
- **Yellow (⚠)** - 70-89% health
166171
- **Red (✗)** - Below 70% health
167172

173+
## Sorting
174+
175+
Sort the resource list k9s-style with shift-key shortcuts: `Shift+N` (name), `Shift+A` (age), `Shift+T` (type), or `Shift+S` (status, problems first). Press the same key again to reverse the order, and a third time to restore the default namespace/type/name ordering. The active sort column is marked with an arrow (``/``) in the table header, and favorites always stay grouped at the top.
176+
177+
## Searching Text Views
178+
179+
Inside the YAML (`y`), describe (`d`), and trace (`t`) views, press `/` to search:
180+
181+
- Type a query and press `Enter` to jump to the first match (matching is case-insensitive)
182+
- `n` / `N` - Jump to the next/previous match
183+
- `Esc` - Clear the search (press again to leave the view)
184+
185+
The view title shows the active query and match position (e.g., `/spec (2/7)`), and matching lines are highlighted.
186+
187+
## Watch Status Banner
188+
189+
flux9s watches the cluster continuously. If the connection to the API server degrades (network outage, VPN reconnect, laptop sleep), a red banner appears in the top-right corner of the resource list:
190+
191+
```
192+
⚠ Watch degraded (N) — data may be stale, reconnecting...
193+
```
194+
195+
Watchers retry automatically with exponential backoff; the banner disappears as soon as the watch streams recover. While the banner is visible, the displayed resources may be out of date.
196+
168197
## Resource Views
169198

170199
### Graph View (`g`)

src/config/schema.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,43 @@ pub struct Config {
5656
pub connect_timeout_seconds: u64,
5757
}
5858

59+
impl Config {
60+
/// Resolve which skin to use for the given context.
61+
///
62+
/// Priority order:
63+
/// 1. `FLUX9S_SKIN` environment variable (highest)
64+
/// 2. Context-specific skin from `contextSkins`
65+
/// 3. Readonly-specific skin (`ui.skinReadOnly`) when `readOnly` is true
66+
/// 4. Default skin (`ui.skin`)
67+
pub fn resolve_skin_name(&self, context_name: Option<&str>) -> String {
68+
if let Ok(env_skin) = std::env::var("FLUX9S_SKIN") {
69+
tracing::debug!(
70+
"Using skin from FLUX9S_SKIN environment variable: {}",
71+
env_skin
72+
);
73+
return env_skin;
74+
}
75+
if let Some(context) = context_name {
76+
if let Some(context_skin) = self.context_skins.get(context) {
77+
tracing::debug!(
78+
"Using context-specific skin for '{}': {}",
79+
context,
80+
context_skin
81+
);
82+
return context_skin.clone();
83+
}
84+
}
85+
if self.read_only {
86+
if let Some(ref skin) = self.ui.skin_read_only {
87+
tracing::debug!("Using readonly-specific skin: {}", skin);
88+
return skin.clone();
89+
}
90+
}
91+
tracing::debug!("Using default skin: {}", self.ui.skin);
92+
self.ui.skin.clone()
93+
}
94+
}
95+
5996
/// UI configuration
6097
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6198
#[serde(rename_all = "camelCase")]

src/kube/api.rs

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,38 @@ pub fn get_flux_api_resources_with_fallback(
238238
.collect())
239239
}
240240

241+
/// Classification of a failed GET used while probing API versions.
242+
#[derive(Debug, PartialEq, Eq)]
243+
enum NotFoundKind {
244+
/// The API version itself is not served on this cluster (404 for the API path).
245+
VersionMissing,
246+
/// The version is served but the named resource does not exist.
247+
ResourceMissing,
248+
/// Any other failure (auth, network, server error, ...).
249+
Other,
250+
}
251+
252+
/// Distinguish "this API version is not served" from "the named resource does
253+
/// not exist". Both surface as HTTP 404, but the API server uses a generic
254+
/// "could not find the requested resource" (or "404 page not found") message
255+
/// for unserved versions, and names the object (`<plural>.<group> "<name>"
256+
/// not found`) when only the object is missing — in which case the version IS
257+
/// served and there is no point probing fallback versions.
258+
fn classify_not_found(error: &kube::Error) -> NotFoundKind {
259+
if let kube::Error::Api(api_err) = error {
260+
if api_err.code == 404 {
261+
let msg = api_err.message.to_lowercase();
262+
if msg.contains("could not find the requested resource")
263+
|| msg.contains("page not found")
264+
{
265+
return NotFoundKind::VersionMissing;
266+
}
267+
return NotFoundKind::ResourceMissing;
268+
}
269+
}
270+
NotFoundKind::Other
271+
}
272+
241273
/// Get ApiResource for a resource type with version fallback
242274
///
243275
/// **Why kubectl works without versions but kube-rs doesn't:**
@@ -286,12 +318,17 @@ pub async fn get_api_resource_with_fallback(
286318
// Default version works!
287319
return Ok(api_resource);
288320
}
289-
Err(e) => {
290-
if !is_version_missing_error(&format!("{}", e)) {
321+
Err(e) => match classify_not_found(&e) {
322+
// Version doesn't exist on this cluster, try fallback versions below
323+
NotFoundKind::VersionMissing => {}
324+
// The API answered for this GVR, so the version is served — the named
325+
// resource just doesn't exist. Return immediately instead of probing
326+
// fallback versions; the caller's own GET produces the proper error.
327+
NotFoundKind::ResourceMissing => return Ok(api_resource),
328+
NotFoundKind::Other => {
291329
return Err(anyhow::anyhow!("Failed to fetch {}: {}", resource_type, e));
292330
}
293-
// Version doesn't exist on this cluster, try fallback versions
294-
}
331+
},
295332
}
296333

297334
// Generate fallback versions dynamically based on the default version
@@ -322,12 +359,14 @@ pub async fn get_api_resource_with_fallback(
322359
);
323360
return Ok(fallback_api_resource);
324361
}
325-
Err(e) => {
326-
if !is_version_missing_error(&format!("{}", e)) {
327-
// Non-"not found" error means this version exists but the resource doesn't
328-
return Ok(fallback_api_resource);
362+
Err(e) => match classify_not_found(&e) {
363+
NotFoundKind::VersionMissing => {} // Try the next version
364+
// Version is served; resource doesn't exist at any version.
365+
NotFoundKind::ResourceMissing => return Ok(fallback_api_resource),
366+
NotFoundKind::Other => {
367+
return Err(anyhow::anyhow!("Failed to fetch {}: {}", resource_type, e));
329368
}
330-
}
369+
},
331370
}
332371
}
333372

@@ -443,4 +482,51 @@ mod tests {
443482
assert!(!is_version_missing_error("timeout waiting for response"));
444483
assert!(!is_version_missing_error("unauthorized: token expired"));
445484
}
485+
486+
fn api_error(code: u16, message: &str) -> kube::Error {
487+
kube::Error::Api(Box::new(
488+
kube::core::Status::failure(message, "NotFound").with_code(code),
489+
))
490+
}
491+
492+
#[test]
493+
fn test_classify_not_found_version_missing() {
494+
// The message the API server returns when an apiVersion is not served
495+
assert_eq!(
496+
classify_not_found(&api_error(
497+
404,
498+
"the server could not find the requested resource"
499+
)),
500+
NotFoundKind::VersionMissing
501+
);
502+
// Raw 404 from a missing API group path
503+
assert_eq!(
504+
classify_not_found(&api_error(404, "404 page not found")),
505+
NotFoundKind::VersionMissing
506+
);
507+
}
508+
509+
#[test]
510+
fn test_classify_not_found_resource_missing() {
511+
// The message when the GVR is served but the named object doesn't exist
512+
assert_eq!(
513+
classify_not_found(&api_error(
514+
404,
515+
"helmreleases.helm.toolkit.fluxcd.io \"my-release\" not found"
516+
)),
517+
NotFoundKind::ResourceMissing
518+
);
519+
}
520+
521+
#[test]
522+
fn test_classify_not_found_other_errors() {
523+
assert_eq!(
524+
classify_not_found(&api_error(403, "forbidden")),
525+
NotFoundKind::Other
526+
);
527+
assert_eq!(
528+
classify_not_found(&api_error(500, "internal error")),
529+
NotFoundKind::Other
530+
);
531+
}
446532
}

src/main.rs

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -104,49 +104,8 @@ async fn main() -> Result<()> {
104104

105105
let read_only = config.read_only;
106106

107-
// Determine which skin to use (priority order):
108-
// 1. FLUX9S_SKIN environment variable (highest priority)
109-
// 2. Context-specific skin from config.context_skins
110-
// 3. Readonly-specific skin (config.ui.skin_read_only) if readonly mode
111-
// 4. Default skin (config.ui.skin)
112-
let skin_name = if let Ok(env_skin) = std::env::var("FLUX9S_SKIN") {
113-
tracing::debug!(
114-
"Using skin from FLUX9S_SKIN environment variable: {}",
115-
env_skin
116-
);
117-
env_skin
118-
} else if let Some(context) = context_name {
119-
if let Some(context_skin) = config.context_skins.get(context) {
120-
tracing::debug!(
121-
"Using context-specific skin for '{}': {}",
122-
context,
123-
context_skin
124-
);
125-
context_skin.clone()
126-
} else if read_only {
127-
if let Some(ref skin) = config.ui.skin_read_only {
128-
tracing::debug!("Using readonly-specific skin: {}", skin);
129-
skin.clone()
130-
} else {
131-
tracing::debug!("Using default skin: {}", config.ui.skin);
132-
config.ui.skin.clone()
133-
}
134-
} else {
135-
tracing::debug!("Using default skin: {}", config.ui.skin);
136-
config.ui.skin.clone()
137-
}
138-
} else if read_only {
139-
if let Some(ref skin) = config.ui.skin_read_only {
140-
tracing::debug!("Using readonly-specific skin: {}", skin);
141-
skin.clone()
142-
} else {
143-
tracing::debug!("Using default skin: {}", config.ui.skin);
144-
config.ui.skin.clone()
145-
}
146-
} else {
147-
tracing::debug!("Using default skin: {}", config.ui.skin);
148-
config.ui.skin.clone()
149-
};
107+
// Determine which skin to use (env var > context skin > readonly skin > default)
108+
let skin_name = config.resolve_skin_name(context_name);
150109

151110
// Load theme based on determined skin name
152111
let theme = config::ThemeLoader::load_theme(&skin_name).unwrap_or_else(|e| {

src/services/cluster_session.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ impl ClusterSession {
299299
name,
300300
namespace: ns,
301301
resource_type,
302-
age: Some(chrono::Utc::now()),
302+
age: crate::watcher::extract_creation_timestamp(&obj_json),
303303
suspended,
304304
ready,
305305
message,
@@ -319,6 +319,12 @@ impl ClusterSession {
319319
WatchEvent::Error(msg) => {
320320
tracing::warn!("Watch event error: {}", msg);
321321
}
322+
WatchEvent::WatcherDegraded(name) => {
323+
tracing::warn!("Watcher degraded (retrying with backoff): {}", name);
324+
}
325+
WatchEvent::WatcherRecovered(name) => {
326+
tracing::info!("Watcher recovered: {}", name);
327+
}
322328
WatchEvent::PodApplied(_, _)
323329
| WatchEvent::PodDeleted(_)
324330
| WatchEvent::DeploymentApplied(_) => {}

0 commit comments

Comments
 (0)