Skip to content

Commit

Permalink
Allow accessing the web UI from webpack (#1074)
Browse files Browse the repository at this point in the history
Trello:
https://trello.com/c/8fSBUP9P/3594-3-improve-agama-web-server-integration-with-the-web-ui

## Accessing Agama server through webpack

When developing the web UI, you need to access Agama web server.
Similarly to what we implemented for Cockpit, you can access it directly
by setting the `AGAMA_SERVER` environment variable. By default, it is
set to `localhost:3000`.

```sh
AGAMA_SERVER=localhost:4567 npm run server
```

The API is available under the `/api` path.

```javascript
const ping = await fetch("/api/ping");
```

## Service the web UI in `agama-web-server`

The new Agama web server will expose the web UI in the root (`/`) path.
By default, it uses `$HOME/.local/agama/web-ui`, falling back to
`/usr/share/agama/web-ui`. However, you can override that route using
the new `--public-dir` argument.

```sh
agama-web-server serve --public-dir /srv/agama/web-ui
```

NOTE: Additionally, we could implement support for reading the public
directory from an environment variable (e.g., `AGAMA_PUBLIC_DIR`). In
that case, you could use `dotenv` (or your favorite alternative) to set
it automatically to `../web/dist`.

# Tasks

- [x] Access Agama server from webpack
- [x] Serve the web UI through `agama-web-server`
- [x] Move the API to `/api`
- [x] Update the documentation
  • Loading branch information
imobachgs authored Mar 6, 2024
2 parents 7de4495 + 08c8700 commit 3d97d73
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 64 deletions.
31 changes: 31 additions & 0 deletions rust/Cargo.lock

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

2 changes: 1 addition & 1 deletion rust/agama-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ macaddr = "1.0"
async-trait = "0.1.75"
axum = { version = "0.7.4", features = ["ws"] }
serde_json = "1.0.113"
tower-http = { version = "0.5.1", features = ["compression-br", "trace"] }
tower-http = { version = "0.5.1", features = ["compression-br", "fs", "trace"] }
tracing-subscriber = "0.3.18"
tracing-journald = "0.3.0"
tracing = "0.1.40"
Expand Down
24 changes: 22 additions & 2 deletions rust/agama-server/src/agama-web-server.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::process::{ExitCode, Termination};
use std::{
path::{Path, PathBuf},
process::{ExitCode, Termination},
};

use agama_lib::connection_to;
use agama_server::{
Expand All @@ -10,6 +13,8 @@ use tokio::sync::broadcast::channel;
use tracing_subscriber::prelude::*;
use utoipa::OpenApi;

const DEFAULT_WEB_UI_DIR: &'static str = "/usr/share/agama/web_ui";

#[derive(Subcommand, Debug)]
enum Commands {
/// Start the API server.
Expand All @@ -27,6 +32,9 @@ pub struct ServeArgs {
// Agama D-Bus address
#[arg(long, default_value = "unix:path=/run/agama/bus")]
dbus_address: String,
// Directory containing the web UI code.
#[arg(long)]
web_ui_dir: Option<PathBuf>,
}

#[derive(Parser, Debug)]
Expand All @@ -39,6 +47,17 @@ struct Cli {
pub command: Commands,
}

fn find_web_ui_dir() -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
let path = Path::new(&home).join(".local/share/agama");
if path.exists() {
return path;
}
}

Path::new(DEFAULT_WEB_UI_DIR).into()
}

/// Start serving the API.
///
/// `args`: command-line arguments.
Expand All @@ -55,7 +74,8 @@ async fn serve_command(args: ServeArgs) -> anyhow::Result<()> {

let config = web::ServiceConfig::load()?;
let dbus = connection_to(&args.dbus_address).await?;
let service = web::service(config, tx, dbus).await?;
let web_ui_dir = args.web_ui_dir.unwrap_or(find_web_ui_dir());
let service = web::service(config, tx, dbus, web_ui_dir).await?;
axum::serve(listener, service)
.await
.expect("could not mount app on listener");
Expand Down
15 changes: 11 additions & 4 deletions rust/agama-server/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,25 @@ pub use config::ServiceConfig;
pub use docs::ApiDoc;
pub use event::{Event, EventsReceiver, EventsSender};
pub use service::MainServiceBuilder;
use std::path::Path;
use tokio_stream::StreamExt;

/// Returns a service that implements the web-based Agama API.
///
/// * `config`: service configuration.
/// * `events`: D-Bus connection.
pub async fn service(
/// * `events`: channel to send the events through the WebSocket.
/// * `dbus`: D-Bus connection.
/// * `web_ui_dir`: public directory containing the web UI.
pub async fn service<P>(
config: ServiceConfig,
events: EventsSender,
dbus: zbus::Connection,
) -> Result<Router, ServiceError> {
let router = MainServiceBuilder::new(events.clone())
web_ui_dir: P,
) -> Result<Router, ServiceError>
where
P: AsRef<Path>,
{
let router = MainServiceBuilder::new(events.clone(), web_ui_dir)
.add_service("/l10n", l10n_service(events.clone()))
.add_service("/software", software_service(dbus).await?)
.with_config(config)
Expand Down
50 changes: 41 additions & 9 deletions rust/agama-server/src/web/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,65 @@ use axum::{
routing::{get, post},
Router,
};
use std::convert::Infallible;
use std::{
convert::Infallible,
path::{Path, PathBuf},
};
use tower::Service;
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use tower_http::{compression::CompressionLayer, services::ServeDir, trace::TraceLayer};

/// Builder for Agama main service.
///
/// It is responsible for building an axum service which includes:
///
/// * A static assets directory (`public_dir`).
/// * A websocket at the `/ws` path.
/// * An authentication endpoint at `/authenticate`.
/// * A 'ping' endpoint at '/ping'.
/// * A number of authenticated services that are added using the `add_service` function.
pub struct MainServiceBuilder {
config: ServiceConfig,
events: EventsSender,
router: Router<ServiceState>,
api_router: Router<ServiceState>,
public_dir: PathBuf,
}

impl MainServiceBuilder {
pub fn new(events: EventsSender) -> Self {
let router = Router::new().route("/ws", get(super::ws::ws_handler));
/// Returns a new service builder.
///
/// * `events`: channel to send events through the WebSocket.
/// * `public_dir`: path to the public directory.
pub fn new<P>(events: EventsSender, public_dir: P) -> Self
where
P: AsRef<Path>,
{
let api_router = Router::new().route("/ws", get(super::ws::ws_handler));
let config = ServiceConfig::default();

Self {
events,
router,
api_router,
config,
public_dir: PathBuf::from(public_dir.as_ref()),
}
}

pub fn with_config(self, config: ServiceConfig) -> Self {
Self { config, ..self }
}

/// Add an authenticated service.
///
/// * `path`: Path to mount the service under `/api`.
/// * `service`: Service to mount on the given `path`.
pub fn add_service<T>(self, path: &str, service: T) -> Self
where
T: Service<Request, Error = Infallible> + Clone + Send + 'static,
T::Response: IntoResponse,
T::Future: Send + 'static,
{
Self {
router: self.router.nest_service(path, service),
api_router: self.api_router.nest_service(path, service),
..self
}
}
Expand All @@ -49,12 +74,19 @@ impl MainServiceBuilder {
config: self.config,
events: self.events,
};
self.router

let api_router = self
.api_router
.route_layer(middleware::from_extractor_with_state::<TokenClaims, _>(
state.clone(),
))
.route("/ping", get(super::http::ping))
.route("/authenticate", post(super::http::authenticate))
.route("/authenticate", post(super::http::authenticate));

let serve = ServeDir::new(self.public_dir);
Router::new()
.nest_service("/", serve)
.nest("/api", api_router)
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new().br(true))
.with_state(state)
Expand Down
26 changes: 19 additions & 7 deletions rust/agama-server/tests/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,34 @@ use axum::{
Router,
};
use common::{body_to_string, DBusServer};
use std::error::Error;
use std::{error::Error, path::PathBuf};
use tokio::{sync::broadcast::channel, test};
use tower::ServiceExt;

async fn build_service() -> Router {
let (tx, _) = channel(16);
let server = DBusServer::new().start().await.unwrap();
service(ServiceConfig::default(), tx, server.connection())
.await
.unwrap()
service(
ServiceConfig::default(),
tx,
server.connection(),
public_dir(),
)
.await
.unwrap()
}

fn public_dir() -> PathBuf {
std::env::current_dir().unwrap().join("public")
}

#[test]
async fn test_ping() -> Result<(), Box<dyn Error>> {
let web_service = build_service().await;
let request = Request::builder().uri("/ping").body(Body::empty()).unwrap();
let request = Request::builder()
.uri("/api/ping")
.body(Body::empty())
.unwrap();

let response = web_service.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
Expand All @@ -46,13 +58,13 @@ async fn access_protected_route(token: &str, jwt_secret: &str) -> Response {
jwt_secret: jwt_secret.to_string(),
};
let (tx, _) = channel(16);
let web_service = MainServiceBuilder::new(tx)
let web_service = MainServiceBuilder::new(tx, public_dir())
.add_service("/protected", get(protected))
.with_config(config)
.build();

let request = Request::builder()
.uri("/protected")
.uri("/api/protected")
.method(Method::GET)
.header("Authorization", format!("Bearer {}", token))
.body(Body::empty())
Expand Down
44 changes: 16 additions & 28 deletions web/README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
# Agama Web-Based UI
# Agama Web UI

This Cockpit modules offers a UI to the [Agama service](file:../service). The code is based on
[Cockpit's Starter Kit
(b2379f7)](https://github.com/cockpit-project/starter-kit/tree/b2379f78e203aab0028d8548b39f5f0bd2b27d2a).
The Agama web user interface is a React-based application that offers a user
interface to the [Agama service](file:../service).

## Development

TODO: update when new way is clear how to do
There are basically two ways how to develop the Agama fronted. You can
override the original Cockpit plugins with your own code in your `$HOME` directory
or you can run a development server which works as a proxy and sends the Cockpit
requests to a real Cockpit server.

The advantage of using the development server is that you can use the
[Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/)
feature for automatically updating the code and stylesheet in the browser
without reloading the page.
The easiest way to work on the Agama Web UI is to use the development server.
The advantage is that you can use the [Hot Module Replacement] (https://
webpack.js.org/concepts/hot-module-replacement/) feature for automatically
updating the code and stylesheet in the browser without reloading the page.

### Using a development server

TODO: update when new way is clear how to do
To start the [webpack-dev-server](https://github.com/webpack/webpack-dev-server)
use this command:

Expand All @@ -29,28 +21,25 @@ use this command:

The extra `--open` option automatically opens the server page in your default
web browser. In this case the server will use the `https://localhost:8080` URL
and expects a running Cockpit instance at `https://localhost:9090`.

At the first start the development server generates a self-signed SSL
certificate, you have to accept it in the browser. The certificate is saved to
disk and is used in the next runs so you do not have to accept it again.
and expects a running `agama-web-server` at `https://localhost:9090`.

This can work also remotely, with a Agama instance running in a different
machine (a virtual machine as well). In that case run

```
COCKPIT_TARGET=<IP> npm run server -- --open
AGAMA_SERVER=<IP> npm run server -- --open
```

Where `COCKPIT_TARGET` is the IP address or hostname of the running Agama
instance. This is especially useful if you use the Live ISO which does not contain
any development tools, you can develop the web frontend easily from your workstation.
Where `AGAMA_SERVER` is the IP address, the hostname or the full URL of the
running Agama server instance. This is especially useful if you use the Live ISO
which does not contain any development tools, you can develop the web frontend
easily from your workstation.

### Special Environment Variables

`COCKPIT_TARGET` - When running the development server set up a proxy to the
specified Cockpit server. See the [using a development
server](#using-a-development-server) section above.
`AGAMA_SERVER` - When running the development server set up a proxy to
the specified Agama web server. See the [using a development server]
(#using-a-development-server) section above.

`LOCAL_CONNECTION` - Force behaving as in a local connection, useful for
development or testing some Agama features. For example the keyboard layout
Expand Down Expand Up @@ -89,7 +78,6 @@ you want a JavaScript file to be type-checked, please add a `// @ts-check` comme

### Links

- [Cockpit developer documentation](https://cockpit-project.org/guide/latest/development)
- [Webpack documentation](https://webpack.js.org/configuration/)
- [PatternFly documentation](https://www.patternfly.org)
- [Material Symbols (aka icons)](https://fonts.google.com/icons)
Loading

0 comments on commit 3d97d73

Please sign in to comment.