Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ flowchart TD
UI --> Relm[Relm4 App Component]
Relm --> Commands[Async Commands and Streams]
Commands --> KubeCrate[aetheris-kube]
Commands -. optional VPN .-> OpenConnectCrate[aetheris-openconnect]
KubeCrate --> Kubeconfig[Kubeconfig]
KubeCrate --> Api[Kubernetes / OpenShift API]
Relm --> Store[ProjectStore]
Expand All @@ -19,7 +18,7 @@ flowchart TD
VTE --> Commands
```

`aetheris-app` owns windows, widgets, user state, and persistence of Aetheris projects. `aetheris-kube` owns kubeconfig parsing, Kubernetes clients, discovery, list/watch, mutations, logs, exec, port-forwarding, metrics, and resource details. `aetheris-openconnect` is an optional native integration crate for libopenconnect; it isolates C FFI and exposes a Rust API for future VPN workflows.
`aetheris-app` owns windows, widgets, user state, and persistence of Aetheris projects. `aetheris-kube` owns kubeconfig parsing, Kubernetes clients, discovery, list/watch, mutations, logs, exec, port-forwarding, metrics, and resource details.

## Crate Boundaries

Expand All @@ -41,17 +40,10 @@ flowchart LR
Ops[Logs exec port-forward mutations]
end

subgraph OpenConnect["aetheris-openconnect"]
SafeApi[Safe Rust API]
Ffi[libopenconnect FFI]
end

Widgets --> State
State --> Commands
State --> Streams
Commands --> Manager
Commands -. optional .-> SafeApi
SafeApi --> Ffi
Streams --> Manager
Manager --> Session
Session --> Resources
Expand All @@ -60,7 +52,7 @@ flowchart LR
Projects --> State
```

The backend crate must not import GTK, Adwaita, Relm4, VTE, or application widgets. Shared data crosses the boundary through DTOs exported from `aetheris-kube::types`. Native C integrations are kept out of both UI widgets and `aetheris-kube`; each integration gets a focused crate such as `aetheris-openconnect`, with `unsafe` declarations confined to its FFI module.
The backend crate must not import GTK, Adwaita, Relm4, VTE, or application widgets. Shared data crosses the boundary through DTOs exported from `aetheris-kube::types`.

## Application Lifecycle

Expand Down
11 changes: 0 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,6 @@ sudo dnf install -y \
openssl-devel
```

OpenConnect integration is optional while the VPN UI is under development. To
build it locally, install the libopenconnect development files and enable the
feature:

```sh
sudo dnf install -y openconnect-devel
cargo check -p aetheris-openconnect --features system-libopenconnect
cargo check -p aetheris-app --features openconnect
```

Run the app:

```sh
Expand Down Expand Up @@ -66,7 +56,6 @@ Aetheris has two Rust crates:

- `aetheris-kube` — pure Kubernetes backend. It must not depend on GTK, Relm4, Libadwaita, or VTE.
- `aetheris-app` — Relm4/GTK4 application. It owns UI state, widgets, project persistence, and command wiring.
- `aetheris-openconnect` — optional libopenconnect integration. It owns C FFI and exposes only safe Rust types to the app.

See [ARCHITECTURE.md](ARCHITECTURE.md) for diagrams and module responsibilities.

Expand Down
9 changes: 0 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ resolver = "2"
members = [
"crates/aetheris-app",
"crates/aetheris-kube",
"crates/aetheris-openconnect",
]

[workspace.package]
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright 2026 LuminusOS

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
5 changes: 0 additions & 5 deletions crates/aetheris-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,9 @@ resources = [

[features]
default = []
openconnect = [
"dep:aetheris-openconnect",
"aetheris-openconnect/system-libopenconnect",
]

[dependencies]
aetheris-kube = { path = "../aetheris-kube" }
aetheris-openconnect = { path = "../aetheris-openconnect", optional = true }
anyhow = "1"
dirs = "6"
futures = "0.3"
Expand Down
10 changes: 6 additions & 4 deletions crates/aetheris-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ use std::{
};

use aetheris_kube::{
AddClusterRequest, ClusterSummary, ContainerUsage, ContextInfo, KubeManager, ObjectCondition,
ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, PodExecEvent, PodExecRequest,
PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, ResourceKind, ResourceUsage,
AddClusterRequest, ClusterSummary, ContainerResources, ContainerUsage, ContextInfo,
KubeManager, ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent,
PodExecEvent, PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest,
ResourceKind, ResourceUsage,
};
use futures::FutureExt;
use futures::future::{AbortHandle, Abortable};
Expand Down Expand Up @@ -178,6 +179,7 @@ pub struct App {
object_columns: Vec<(ObjectTableColumn, gtk::ColumnViewColumn)>,
object_list_stack: gtk::Stack,
detail: DetailPane,
object_load_token: u64,
object_watch_token: u64,
object_watch_abort_handle: Option<AbortHandle>,
log_streaming: bool,
Expand Down Expand Up @@ -308,7 +310,7 @@ pub enum AppMsg {
CaFileLoaded(Result<String, String>),
ShowImportFile,
Refresh,
ObjectsLoaded(Result<Vec<ObjectSummary>, String>),
ObjectsLoaded(u64, Result<Vec<ObjectSummary>, String>),
AddCluster,
ClusterAdded(Result<(String, String), String>),
StateLoadedForCluster(String, Result<LoadedState, String>),
Expand Down
51 changes: 33 additions & 18 deletions crates/aetheris-app/src/app/commands.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use std::time::Duration;

use super::utils::*;
use super::*;

const CLUSTER_LOAD_TIMEOUT: Duration = Duration::from_secs(30);

pub(super) async fn load_state() -> AppMsg {
let result = async {
// A missing kubeconfig is the normal first-run state — the user
Expand Down Expand Up @@ -34,29 +38,39 @@ pub(super) async fn load_state() -> AppMsg {
}

pub(super) async fn load_cluster(context: String) -> AppMsg {
let result = async {
let manager = KubeManager::load()?;
let session = manager.connect_context(&context).await?;
let (namespaces, namespace_warning) = match session.list_namespaces().await {
Ok(namespaces) => (namespaces, None),
Err(error) => (
manager.namespace_for_context(&context),
Some(format_error(error)),
),
};
let resources = session.discover_resources().await?;
Ok::<_, anyhow::Error>(ClusterState {
namespaces,
resources,
namespace_warning,
})
}
let result = match tokio::time::timeout(CLUSTER_LOAD_TIMEOUT, async {
load_cluster_state(context).await
})
.await
{
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"Timed out while discovering Kubernetes resources for this cluster. Check that the API server is reachable and try again."
)),
}
.map_err(format_error);

AppMsg::ClusterLoaded(result)
}

async fn load_cluster_state(context: String) -> anyhow::Result<ClusterState> {
let manager = KubeManager::load()?;
let session = manager.connect_context(&context).await?;
let (namespaces, namespace_warning) = match session.list_namespaces().await {
Ok(namespaces) => (namespaces, None),
Err(error) => (
manager.namespace_for_context(&context),
Some(format_error(error)),
),
};
let resources = session.discover_resources().await?;
Ok(ClusterState {
namespaces,
resources,
namespace_warning,
})
}

pub(super) async fn load_cluster_summary(context: String) -> AppMsg {
let result = async {
let manager = KubeManager::load()?;
Expand All @@ -70,13 +84,14 @@ pub(super) async fn load_cluster_summary(context: String) -> AppMsg {
}

pub(super) async fn list_objects(
token: u64,
context: String,
resource: ResourceKind,
namespace: Option<String>,
) -> AppMsg {
let result = list_objects_snapshot(context, resource, namespace).await;

AppMsg::ObjectsLoaded(result)
AppMsg::ObjectsLoaded(token, result)
}

pub(super) async fn list_objects_snapshot(
Expand Down
1 change: 1 addition & 0 deletions crates/aetheris-app/src/app/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,7 @@ impl Component for App {
node_unschedulable: None,
request_token: 0,
},
object_load_token: 0,
object_watch_token: 0,
object_watch_abort_handle: None,
log_streaming: false,
Expand Down
10 changes: 8 additions & 2 deletions crates/aetheris-app/src/app/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,10 @@ impl App {
self.refresh_objects(sender);
}
}
AppMsg::ObjectsLoaded(Ok(objects)) => {
AppMsg::ObjectsLoaded(token, Ok(objects)) => {
if token != self.object_load_token {
return;
}
self.loading = false;
let count = objects.len();
self.objects = objects;
Expand All @@ -1136,7 +1139,10 @@ impl App {
self.rebuild_object_list();
self.start_object_watch(sender);
}
AppMsg::ObjectsLoaded(Err(error)) => {
AppMsg::ObjectsLoaded(token, Err(error)) => {
if token != self.object_load_token {
return;
}
self.loading = false;
self.stop_object_watch();
self.objects.clear();
Expand Down
12 changes: 9 additions & 3 deletions crates/aetheris-app/src/app/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,16 @@ impl App {
None
};
self.stop_object_watch();
self.object_load_token = self.object_load_token.saturating_add(1);
let token = self.object_load_token;
self.loading = true;
self.objects.clear();
self.status = tr_format("Loading {resource}...", &[("{resource}", resource.label())]);
self.sync_status();
sender.oneshot_command(async move { list_objects(context, resource, namespace).await });
self.rebuild_object_list();
sender.oneshot_command(
async move { list_objects(token, context, resource, namespace).await },
);
}

pub(super) fn selected_resource_kind(&self) -> Option<&ResourceKind> {
Expand Down Expand Up @@ -446,9 +452,9 @@ impl App {
.set_sensitive(self.selected_context.is_some() && !self.loading);
self.column_filter_list
.set_sensitive(self.selected_context.is_some() && !self.loading);
self.cluster_back_button.set_sensitive(!self.loading);
self.cluster_back_button.set_sensitive(true);
self.cluster_menu_button
.set_sensitive(self.selected_context.is_some() && !self.loading);
.set_sensitive(self.selected_context.is_some());
self.cluster_refresh_button
.set_sensitive(!self.loading && !self.visible_contexts().is_empty());
self.add_cluster_button.set_sensitive(!self.loading);
Expand Down
Loading
Loading