rename lol

This commit is contained in:
nora 2022-03-19 14:27:30 +01:00
parent c68cd04af7
commit 543e39f129
70 changed files with 283 additions and 266 deletions

View file

@ -0,0 +1,23 @@
[package]
name = "haesli_dashboard"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
haesli_core = { path = "../haesli_core" }
anyhow = "1.0.55"
axum = "0.4.8"
mime_guess = "2.0.4"
serde = { version = "1.0.136", features = ["derive"] }
tokio = { version = "1.17.0", features = ["full"] }
tower = "0.4.12"
tower-http = { version = "0.2.3", features = ["cors"] }
tracing = "0.1.31"
zip = "0.5.13"
[build-dependencies]
anyhow = "1.0.55"
walkdir = "2.3.2"
zip = "0.5.13"

75
haesli_dashboard/build.rs Normal file
View file

@ -0,0 +1,75 @@
use std::{
env,
fs::File,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{ensure, Context, Result};
use walkdir::WalkDir;
use zip::{write::FileOptions, ZipWriter};
// inspired by https://fasterthanli.me/series/dont-shell-out/part-8
fn main() -> Result<()> {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let frontend_dir = PathBuf::from(manifest_dir).join("frontend");
// this is not always fully correct, but we need to ignore `build` from the rerun or else it
// will always trigger itself
println!(
"cargo:rerun-if-changed={}",
frontend_dir.join("src").display()
);
build_frontend(&frontend_dir)
}
fn build_frontend(path: &Path) -> Result<()> {
let status = Command::new("yarn")
.arg("install")
.current_dir(path)
.status()
.context("run yarn install failed")?;
ensure!(status.success(), "Failed to install frontend dependencies");
let status = Command::new("yarn")
.arg("build")
.current_dir(path)
.status()
.context("run yarn build")?;
ensure!(status.success(), "Failed to build frontend");
let yarn_build_dir = path.join("build");
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let zip_path = out_dir.join("frontend.zip");
let mut zw = ZipWriter::new(File::create(&zip_path).unwrap());
for entry in WalkDir::new(&yarn_build_dir) {
let entry = entry.context("walk build directory")?;
let disk_path = entry.path();
let rel_path = disk_path
.strip_prefix(&yarn_build_dir)
.unwrap()
.to_string_lossy();
let meta = entry.metadata().context("entry metadata")?;
if meta.is_dir() {
zw.add_directory(rel_path, FileOptions::default()).unwrap();
} else if meta.is_file() {
zw.start_file(rel_path, Default::default()).unwrap();
std::io::copy(&mut File::open(disk_path).unwrap(), &mut zw).unwrap();
} else {
println!("cargo:warning=Ignoring entry {}", disk_path.display());
}
}
Ok(())
}

23
haesli_dashboard/frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -0,0 +1 @@
build

View file

@ -0,0 +1,3 @@
{
"singleQuote": true
}

View file

@ -0,0 +1 @@
# Dashboard frontend for the AMQP broker

View file

@ -0,0 +1,48 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"fmt": "prettier -w .",
"check-fmt": "prettier -c ."
},
"dependencies": {
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@types/react": "^17.0.20",
"@types/react-dom": "^17.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "5.0.0",
"typescript": "^4.4.2",
"web-vitals": "^2.1.0"
},
"devDependencies": {
"prettier": "^2.5.1"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1,17 @@
html {
font-family: arial, sans-serif;
background-color: #282c34;
color: white;
}
.app {
margin: 50px;
}
table,
th,
td {
border: 1px solid white;
border-collapse: collapse;
padding: 10px;
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import DataPage from './components/data-page';
import './app.css';
const IS_PROD = process.env.NODE_ENV === 'production';
const URL_PREFIX = IS_PROD ? '' : 'http://localhost:8080/';
const App = () => {
return (
<div className="app">
<header className="header">
<h1>Haesli Dashboard</h1>
</header>
<DataPage prefix={URL_PREFIX} />
</div>
);
};
export default App;

View file

@ -0,0 +1,88 @@
import React, { FC, useCallback, useEffect, useState } from 'react';
import Table from './table';
import type { Data } from '../types';
const fetchData = async (prefix: string): Promise<Data> => {
const url = `${prefix}api/data`;
return fetch(url).then((res) => res.json());
};
type Props = {
prefix: string;
};
const DataPage: FC<Props> = ({ prefix }) => {
const [data, setData] = useState<Data | null>(null);
const refresh = useCallback(async () => {
const newData = await fetchData(prefix);
setData(newData);
}, [setData, prefix]);
useEffect(() => {
const interval = setInterval(refresh, 1000);
return () => clearInterval(interval);
}, [refresh]);
return (
<div>
<section>
<h2>Connections</h2>
{data ? (
<Table
headers={['Connection ID', 'Client Address', 'Channels']}
rows={data.connections.map((connection) => [
connection.id,
connection.peerAddr,
connection.channels.length,
])}
/>
) : (
<div>Loading...</div>
)}
</section>
<section>
<h2>Queues</h2>
{data ? (
<Table
headers={['Queue ID', 'Name', 'Durable', 'Message Count']}
rows={data.queues.map((queue) => [
queue.id,
queue.name,
queue.durable ? 'Yes' : 'No',
queue.messages,
])}
/>
) : (
<div>Loading...</div>
)}
</section>
<section>
<h2>Channels</h2>
{data ? (
<Table
headers={['Channel ID', 'Connection ID', 'Number']}
rows={data.connections
.map((connection) =>
connection.channels.map((channel) => ({
...channel,
connectionId: connection.id,
}))
)
.flat()
.map((channel) => [
channel.id,
channel.connectionId,
channel.number,
])}
/>
) : (
<div>Loading...</div>
)}
</section>
</div>
);
};
export default DataPage;

View file

@ -0,0 +1,31 @@
import React, { FC } from 'react';
type Cell = string | number;
type Row = ReadonlyArray<Cell>;
type Props = {
headers: ReadonlyArray<string>;
rows: ReadonlyArray<Row>;
};
const Table: FC<Props> = ({ headers, rows }) => {
return (
<table>
<tr>
{headers.map((header) => (
<th>{header}</th>
))}
</tr>
{rows.map((row) => (
<tr>
{row.map((cell) => (
<td>{cell}</td>
))}
</tr>
))}
</table>
);
};
export default Table;

View file

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './app';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

View file

@ -0,0 +1,22 @@
export type Channel = {
id: string;
number: number;
};
export type Connection = {
id: string;
peerAddr: string;
channels: ReadonlyArray<Channel>;
};
export type Queue = {
id: string;
name: string;
durable: boolean;
messages: number;
};
export type Data = {
connections: ReadonlyArray<Connection>;
queues: ReadonlyArray<Queue>;
};

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,152 @@
use std::{
collections::HashMap,
fmt::{Debug, Formatter},
future,
io::Cursor,
path::Path,
task::{Context, Poll},
};
use axum::{
body::Body,
http::{header, Request, Response, StatusCode},
};
use mime_guess::mime;
use tracing::trace;
use zip::ZipArchive;
#[derive(Debug, Clone)]
enum StaticFileKind {
File { mime: mime::Mime },
Directory,
}
#[derive(Clone)]
struct StaticFile {
data: Vec<u8>,
kind: StaticFileKind,
}
impl Debug for StaticFile {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StaticFile")
.field("kind", &self.kind)
.field("data", &format!("[{} bytes]", self.data.len()))
.finish()
}
}
#[derive(Debug, Clone)]
pub struct StaticFileService {
files: HashMap<String, StaticFile>,
}
impl StaticFileService {
/// Creates a new static file service from zip data. This is a blocking operation!
#[tracing::instrument(skip(zip))]
pub fn new(zip: &[u8]) -> anyhow::Result<Self> {
let mut archive = ZipArchive::new(Cursor::new(zip))?;
let mut files = HashMap::with_capacity(archive.len());
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let mut data = Vec::with_capacity(usize::try_from(file.size())?);
std::io::copy(&mut file, &mut data)?;
trace!(name = %file.name(), size = %file.size(),"Unpacking dashboard frontend file");
let path = Path::new(file.name());
let kind = if file.is_dir() {
StaticFileKind::Directory
} else {
let mime = match path.extension() {
Some(ext) => {
mime_guess::from_ext(&ext.to_string_lossy()).first_or_octet_stream()
}
None => mime::APPLICATION_OCTET_STREAM,
};
StaticFileKind::File { mime }
};
files.insert(
file.name().trim_start_matches('/').to_owned(),
StaticFile { data, kind },
);
}
trace!(?files, "Created StaticFileService");
Ok(Self { files })
}
fn call_inner(&mut self, req: Request<Body>) -> Result<Response<Body>, anyhow::Error> {
trace!(?req, "Got request for static file");
let path = req.uri().path().trim_start_matches('/');
let entry = self.files.get(path);
match entry {
Some(file) => {
if let StaticFileKind::File { mime } = &file.kind {
trace!(%path, "Found file");
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.essence_str())
.body(Body::from(file.data.clone()))?)
} else {
trace!(%path, "Found directory, trying to append index.html");
let new_path = if path.is_empty() {
"index.html".to_owned()
} else {
let new_path = path.trim_end_matches('/');
format!("{}/index.html", new_path)
};
match self.files.get(&new_path) {
Some(file) => {
trace!(%new_path, "Found index.html");
if let StaticFileKind::File { mime } = &file.kind {
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.essence_str())
.body(Body::from(file.data.clone()))?)
} else {
unreachable!()
}
}
None => {
trace!(%new_path, "Did not find index.html");
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())?)
}
}
}
}
None => {
trace!(%path, "Did not find file");
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())?)
}
}
}
}
impl tower::Service<Request<Body>> for StaticFileService {
type Response = Response<Body>;
type Error = anyhow::Error;
type Future = future::Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
future::ready(self.call_inner(req))
}
}

121
haesli_dashboard/src/lib.rs Normal file
View file

@ -0,0 +1,121 @@
#![warn(rust_2018_idioms)]
mod archive;
use axum::{
http::{Method, StatusCode},
response::IntoResponse,
routing::{get, get_service},
Json, Router,
};
use haesli_core::GlobalData;
use serde::Serialize;
use tower_http::cors::{Any, CorsLayer};
use tracing::{error, info};
use crate::archive::StaticFileService;
const DATA_ZIP: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/frontend.zip"));
pub async fn start_dashboard(global_data: GlobalData) {
match dashboard(global_data).await {
Ok(()) => {}
Err(err) => error!(%err, "Failed to start dashboard"),
}
}
#[tracing::instrument(skip(global_data))]
pub async fn dashboard(global_data: GlobalData) -> anyhow::Result<()> {
let cors = CorsLayer::new()
.allow_methods(vec![Method::GET])
.allow_origin(Any);
let static_file_service =
tokio::task::spawn_blocking(|| StaticFileService::new(DATA_ZIP)).await??;
let static_file_service = get_service(static_file_service).handle_error(|error| async move {
error!(?error, "Error in static file service");
StatusCode::INTERNAL_SERVER_ERROR
});
let app = Router::new()
.route("/api/data", get(move || get_data(global_data)).layer(cors))
.fallback(static_file_service);
let socket_addr = "0.0.0.0:8080".parse().unwrap();
info!(%socket_addr, "Starting up dashboard on address");
axum::Server::bind(&socket_addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
#[derive(Serialize)]
struct Data {
connections: Vec<Connection>,
queues: Vec<Queue>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Connection {
id: String,
peer_addr: String,
channels: Vec<Channel>,
}
#[derive(Serialize)]
struct Channel {
id: String,
number: u16,
}
#[derive(Serialize)]
struct Queue {
id: String,
name: String,
durable: bool,
messages: usize,
}
async fn get_data(global_data: GlobalData) -> impl IntoResponse {
let global_data = global_data.lock();
let connections = global_data
.connections
.values()
.map(|conn| Connection {
id: conn.id.to_string(),
peer_addr: conn.peer_addr.to_string(),
channels: conn
.channels
.lock()
.values()
.map(|chan| Channel {
id: chan.id.to_string(),
number: chan.num.num(),
})
.collect(),
})
.collect();
let queues = global_data
.queues
.values()
.map(|queue| Queue {
id: queue.id.to_string(),
name: queue.name.to_string(),
durable: queue.durable,
messages: queue.messages.len(),
})
.collect();
let data = Data {
connections,
queues,
};
Json(data)
}