html frontend

This commit is contained in:
Sam Rijs 2018-01-27 20:47:12 +11:00
parent 21cd986d5e
commit d821851fd8
12 changed files with 411 additions and 176 deletions

View file

@ -7,6 +7,7 @@ authors = ["Sam Rijs <srijs@airpost.net>"]
futures = "0.1.18" futures = "0.1.18"
hyper = "0.11.15" hyper = "0.11.15"
hyper-tls = "0.1.2" hyper-tls = "0.1.2"
maud = "0.17.2"
route-recognizer = "0.1.12" route-recognizer = "0.1.12"
semver = { version = "0.9.0", features = ["serde"] } semver = { version = "0.9.0", features = ["serde"] }
serde = "1.0.27" serde = "1.0.27"

70
assets/static/style.css Normal file
View file

@ -0,0 +1,70 @@
@import url('https://fonts.googleapis.com/css?family=Fira+Sans:400,500,600,700');
@import url('https://fonts.googleapis.com/css?family=Source+Code+Pro:400,600');
html {
padding: 0;
margin: 0;
background: #eee;
font-family: "Fira Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
body {
padding: 30px 40px;
margin: 0 auto;
background: #fff;
border-left: 5px solid #aaa;
border-right: 5px solid #aaa;
width: 800px;
}
pre {
font-family: "Source Code Pro", Menlo, Monaco, Consolas, "DejaVu Sans Mono", Inconsolata, monospace;
white-space: pre-wrap;
background: #dedede;
color: #555;
padding: .5em .3em;
}
code {
font-family: "Source Code Pro", Menlo, Monaco, Consolas, "DejaVu Sans Mono", Inconsolata, monospace;
background: #dedede;
padding: .1em .3em;
border-radius: .3em;
color: #555;
}
h1 code {
letter-spacing: -0.02em;
}
table {
width: 100%;
border: 0;
border-spacing: 0;
}
tr:nth-child(even) {
background: #f3f3f3;
}
tr:nth-child(odd) {
background: #fff;
}
td {
padding: .5em .7em;
}
span.status {
padding: .1em .3em;
border-radius: .3em;
color: #fff;
}
span.status.up-to-date {
background: #97ca00;
}
span.status.outdated {
background: #dfb317;
}

View file

@ -1,167 +0,0 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use futures::{Future, IntoFuture, future};
use hyper::{Error as HyperError, Method, Request, Response, StatusCode};
use hyper::header::ContentType;
use route_recognizer::{Params, Router};
use semver::{Version, VersionReq};
use serde_json;
use slog::Logger;
use tokio_service::Service;
use ::assets;
use ::engine::{Engine, AnalyzeDependenciesOutcome};
use ::models::repo::RepoPath;
#[derive(Clone, Copy)]
enum StatusFormat {
Json,
Svg
}
enum Route {
Status(StatusFormat)
}
#[derive(Clone)]
pub struct Api {
engine: Engine,
router: Arc<Router<Route>>
}
impl Api {
pub fn new(engine: Engine) -> Api {
let mut router = Router::new();
router.add("/repo/:site/:qual/:name/status.json", Route::Status(StatusFormat::Json));
router.add("/repo/:site/:qual/:name/status.svg", Route::Status(StatusFormat::Svg));
Api { engine, router: Arc::new(router) }
}
}
#[derive(Debug, Serialize)]
struct AnalyzeDependenciesResponseDetail {
required: VersionReq,
latest: Option<Version>,
outdated: bool
}
#[derive(Debug, Serialize)]
struct AnalyzeDependenciesResponseSingle {
dependencies: BTreeMap<String, AnalyzeDependenciesResponseDetail>,
#[serde(rename="dev-dependencies")]
dev_dependencies: BTreeMap<String, AnalyzeDependenciesResponseDetail>,
#[serde(rename="build-dependencies")]
build_dependencies: BTreeMap<String, AnalyzeDependenciesResponseDetail>
}
#[derive(Debug, Serialize)]
struct AnalyzeDependenciesResponse {
crates: BTreeMap<String, AnalyzeDependenciesResponseSingle>
}
impl Service for Api {
type Request = Request;
type Response = Response;
type Error = HyperError;
type Future = Box<Future<Item=Response, Error=HyperError>>;
fn call(&self, req: Request) -> Self::Future {
if let Ok(route_match) = self.router.recognize(req.uri().path()) {
match route_match.handler {
&Route::Status(format) => {
if *req.method() == Method::Get {
return Box::new(self.status(req, route_match.params, format));
}
}
}
}
let mut response = Response::new();
response.set_status(StatusCode::NotFound);
Box::new(future::ok(response))
}
}
impl Api {
fn status(&self, _req: Request, params: Params, format: StatusFormat) ->
impl Future<Item=Response, Error=HyperError>
{
let engine = self.engine.clone();
let site = params.find("site").expect("route param 'site' not found");
let qual = params.find("qual").expect("route param 'qual' not found");
let name = params.find("name").expect("route param 'name' not found");
RepoPath::from_parts(site, qual, name).into_future().then(move |repo_path_result| {
match repo_path_result {
Err(err) => {
let mut response = Response::new();
response.set_status(StatusCode::BadRequest);
response.set_body(format!("{:?}", err));
future::Either::A(future::ok(response))
},
Ok(repo_path) => {
future::Either::B(engine.analyze_dependencies(repo_path).then(move |analyze_result| {
match analyze_result {
Err(err) => {
let mut response = Response::new();
response.set_status(StatusCode::InternalServerError);
response.set_body(format!("{:?}", err));
future::Either::A(future::ok(response))
},
Ok(analysis_outcome) => {
let response = Api::status_format_analysis(analysis_outcome, format);
future::Either::B(future::ok(response))
}
}
}))
}
}
})
}
fn status_format_analysis(analysis_outcome: AnalyzeDependenciesOutcome, format: StatusFormat) -> Response {
match format {
StatusFormat::Json => {
let single = AnalyzeDependenciesResponseSingle {
dependencies: analysis_outcome.deps.main.into_iter()
.map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail {
outdated: analyzed.is_outdated(),
required: analyzed.required,
latest: analyzed.latest
})).collect(),
dev_dependencies: analysis_outcome.deps.dev.into_iter()
.map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail {
outdated: analyzed.is_outdated(),
required: analyzed.required,
latest: analyzed.latest
})).collect(),
build_dependencies: analysis_outcome.deps.build.into_iter()
.map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail {
outdated: analyzed.is_outdated(),
required: analyzed.required,
latest: analyzed.latest
})).collect()
};
let multi = AnalyzeDependenciesResponse {
crates: vec![(analysis_outcome.name.into(), single)].into_iter().collect()
};
Response::new()
.with_header(ContentType::json())
.with_body(serde_json::to_string(&multi).unwrap())
},
StatusFormat::Svg => {
let mut response = Response::new()
.with_header(ContentType("image/svg+xml;charset=utf-8".parse().unwrap()));
if analysis_outcome.deps.any_outdated() {
response.set_body(assets::BADGE_OUTDATED_SVG.to_vec());
} else {
response.set_body(assets::BADGE_UPTODATE_SVG.to_vec());
}
response
}
}
}
}

View file

@ -1,4 +0,0 @@
pub static BADGE_UPTODATE_SVG: &'static [u8; 978] =
include_bytes!("../assets/badges/up-to-date.svg");
pub static BADGE_OUTDATED_SVG: &'static [u8; 974] =
include_bytes!("../assets/badges/outdated.svg");

View file

@ -1,9 +1,11 @@
#![feature(ascii_ctype)] #![feature(ascii_ctype)]
#![feature(conservative_impl_trait)] #![feature(conservative_impl_trait)]
#![feature(proc_macro)]
extern crate futures; extern crate futures;
extern crate hyper; extern crate hyper;
extern crate hyper_tls; extern crate hyper_tls;
extern crate maud;
extern crate route_recognizer; extern crate route_recognizer;
extern crate semver; extern crate semver;
#[macro_use] extern crate serde_derive; #[macro_use] extern crate serde_derive;
@ -19,8 +21,7 @@ mod models;
mod parsers; mod parsers;
mod interactors; mod interactors;
mod engine; mod engine;
mod assets; mod server;
mod api;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Mutex; use std::sync::Mutex;
@ -32,7 +33,7 @@ use hyper_tls::HttpsConnector;
use slog::Drain; use slog::Drain;
use tokio_core::reactor::Core; use tokio_core::reactor::Core;
use self::api::Api; use self::server::Server;
use self::engine::Engine; use self::engine::Engine;
fn main() { fn main() {
@ -63,9 +64,9 @@ fn main() {
logger: logger.clone() logger: logger.clone()
}; };
let api = Api::new(engine); let server = Server::new(engine);
let serve = http.serve_addr_handle(&addr, &handle, move || Ok(api.clone())) let serve = http.serve_addr_handle(&addr, &handle, move || Ok(server.clone()))
.expect("failed to bind server"); .expect("failed to bind server");
let serving = serve.for_each(move |conn| { let serving = serve.for_each(move |conn| {

View file

@ -1,5 +1,6 @@
use std::str::FromStr; use std::str::FromStr;
#[derive(Clone)]
pub struct RepoPath { pub struct RepoPath {
pub site: RepoSite, pub site: RepoSite,
pub qual: RepoQualifier, pub qual: RepoQualifier,
@ -19,6 +20,7 @@ impl RepoPath {
#[derive(Debug)] #[derive(Debug)]
pub struct RepoValidationError; pub struct RepoValidationError;
#[derive(Clone)]
pub struct RepoSite(String); pub struct RepoSite(String);
impl FromStr for RepoSite { impl FromStr for RepoSite {
@ -43,6 +45,7 @@ impl AsRef<str> for RepoSite {
} }
} }
#[derive(Clone)]
pub struct RepoQualifier(String); pub struct RepoQualifier(String);
impl FromStr for RepoQualifier { impl FromStr for RepoQualifier {
@ -67,6 +70,7 @@ impl AsRef<str> for RepoQualifier {
} }
} }
#[derive(Clone)]
pub struct RepoName(String); pub struct RepoName(String);
impl FromStr for RepoName { impl FromStr for RepoName {

9
src/server/assets.rs Normal file
View file

@ -0,0 +1,9 @@
//pub mod templates;
pub static BADGE_UPTODATE_SVG: &'static [u8; 978] =
include_bytes!("../../assets/badges/up-to-date.svg");
pub static BADGE_OUTDATED_SVG: &'static [u8; 974] =
include_bytes!("../../assets/badges/outdated.svg");
pub static STATIC_STYLE_CSS: &'static str =
include_str!("../../assets/static/style.css");

140
src/server/mod.rs Normal file
View file

@ -0,0 +1,140 @@
use std::sync::Arc;
use futures::{Future, IntoFuture, future};
use hyper::{Error as HyperError, Method, Request, Response, StatusCode};
use hyper::header::ContentType;
use route_recognizer::{Params, Router};
use slog::Logger;
use tokio_service::Service;
mod assets;
mod views;
use ::engine::{Engine, AnalyzeDependenciesOutcome};
use ::models::repo::RepoPath;
#[derive(Clone, Copy)]
enum StatusFormat {
Html,
Json,
Svg
}
#[derive(Clone, Copy)]
enum StaticFile {
StyleCss
}
enum Route {
Static(StaticFile),
Status(StatusFormat)
}
#[derive(Clone)]
pub struct Server {
engine: Engine,
router: Arc<Router<Route>>
}
impl Server {
pub fn new(engine: Engine) -> Server {
let mut router = Router::new();
router.add("/static/style.css", Route::Static(StaticFile::StyleCss));
router.add("/repo/:site/:qual/:name", Route::Status(StatusFormat::Html));
router.add("/repo/:site/:qual/:name/status.json", Route::Status(StatusFormat::Json));
router.add("/repo/:site/:qual/:name/status.svg", Route::Status(StatusFormat::Svg));
Server { engine, router: Arc::new(router) }
}
}
impl Service for Server {
type Request = Request;
type Response = Response;
type Error = HyperError;
type Future = Box<Future<Item=Response, Error=HyperError>>;
fn call(&self, req: Request) -> Self::Future {
if let Ok(route_match) = self.router.recognize(req.uri().path()) {
match route_match.handler {
&Route::Status(format) => {
if *req.method() == Method::Get {
return Box::new(self.status(req, route_match.params, format));
}
},
&Route::Static(file) => {
if *req.method() == Method::Get {
return Box::new(future::ok(Server::static_file(file)));
}
}
}
}
let mut response = Response::new();
response.set_status(StatusCode::NotFound);
Box::new(future::ok(response))
}
}
impl Server {
fn status(&self, _req: Request, params: Params, format: StatusFormat) ->
impl Future<Item=Response, Error=HyperError>
{
let server = self.clone();
let site = params.find("site").expect("route param 'site' not found");
let qual = params.find("qual").expect("route param 'qual' not found");
let name = params.find("name").expect("route param 'name' not found");
RepoPath::from_parts(site, qual, name).into_future().then(move |repo_path_result| {
match repo_path_result {
Err(err) => {
let mut response = Response::new();
response.set_status(StatusCode::BadRequest);
response.set_body(format!("{:?}", err));
future::Either::A(future::ok(response))
},
Ok(repo_path) => {
future::Either::B(server.engine.analyze_dependencies(repo_path.clone()).then(move |analyze_result| {
match analyze_result {
Err(err) => {
let mut response = Response::new();
response.set_status(StatusCode::InternalServerError);
response.set_body(format!("{:?}", err));
future::Either::A(future::ok(response))
},
Ok(analysis_outcome) => {
let response = Server::status_format_analysis(analysis_outcome, format, repo_path);
future::Either::B(future::ok(response))
}
}
}))
}
}
})
}
fn status_format_analysis(analysis_outcome: AnalyzeDependenciesOutcome, format: StatusFormat, repo_path: RepoPath) -> Response {
match format {
StatusFormat::Json =>
views::status_json(analysis_outcome),
StatusFormat::Svg =>
views::status_svg(analysis_outcome),
StatusFormat::Html =>
views::status_html(analysis_outcome, repo_path)
}
}
fn static_file(file: StaticFile) -> Response {
match file {
StaticFile::StyleCss => {
Response::new()
.with_header(ContentType("text/css".parse().unwrap()))
.with_body(assets::STATIC_STYLE_CSS)
}
}
}
}

8
src/server/views/mod.rs Normal file
View file

@ -0,0 +1,8 @@
mod status_html;
pub use self::status_html::status_html;
mod status_json;
pub use self::status_json::status_json;
mod status_svg;
pub use self::status_svg::status_svg;

View file

@ -0,0 +1,97 @@
use hyper::Response;
use hyper::header::ContentType;
use maud::{Markup, html};
use ::engine::AnalyzeDependenciesOutcome;
use ::models::crates::{CrateName, AnalyzedDependency};
use ::models::repo::RepoPath;
const SELF_BASE_URL: &'static str = "http://example.com";
fn dependency_table<I: IntoIterator<Item=(CrateName, AnalyzedDependency)>>(deps: I) -> Markup {
html! {
table {
tr {
th "Crate"
th "Required"
th "Latest"
th "Status"
}
@for (name, dep) in deps {
tr {
td {
a href=(format!("https://crates.io/crates/{}", name.as_ref())) (name.as_ref())
}
td code (dep.required.to_string())
td {
@if let Some(ref latest) = dep.latest {
code (latest.to_string())
} @else {
"N/A"
}
}
td {
@if dep.is_outdated() {
span class="status outdated" "out of date"
} @else {
span class="status up-to-date" "up to date"
}
}
}
}
}
}
}
pub fn status_html(analysis_outcome: AnalyzeDependenciesOutcome, repo_path: RepoPath) -> Response {
let self_path = format!("repo/{}/{}/{}", repo_path.site.as_ref(), repo_path.qual.as_ref(), repo_path.name.as_ref());
let status_base_url = format!("{}/{}", SELF_BASE_URL, self_path);
let title = format!("{} / {} - Dependency Status", repo_path.qual.as_ref(), repo_path.name.as_ref());
let rendered = html! {
html {
head {
title (title)
link rel="stylesheet" type="text/css" href="/static/style.css";
}
body {
header {
h1 {
"Dependency status for "
code (format!("{}/{}", repo_path.qual.as_ref(), repo_path.name.as_ref()))
}
h2 {
"Crate "
code (analysis_outcome.name.as_ref())
}
img src=(format!("/{}/status.svg", self_path));
pre {
(format!("[![dependency status]({}/status.svg)]({})", status_base_url, status_base_url))
}
@if !analysis_outcome.deps.main.is_empty() {
h3 "Dependencies"
(dependency_table(analysis_outcome.deps.main))
}
@if !analysis_outcome.deps.dev.is_empty() {
h3 "Dev dependencies"
(dependency_table(analysis_outcome.deps.dev))
}
@if !analysis_outcome.deps.build.is_empty() {
h3 "Build dependencies"
(dependency_table(analysis_outcome.deps.build))
}
}
}
}
};
Response::new()
.with_header(ContentType::html())
.with_body(rendered.0)
}

View file

@ -0,0 +1,60 @@
use std::collections::BTreeMap;
use hyper::Response;
use hyper::header::ContentType;
use semver::{Version, VersionReq};
use serde_json;
use ::engine::AnalyzeDependenciesOutcome;
#[derive(Debug, Serialize)]
struct AnalyzeDependenciesResponseDetail {
required: VersionReq,
latest: Option<Version>,
outdated: bool
}
#[derive(Debug, Serialize)]
struct AnalyzeDependenciesResponseSingle {
dependencies: BTreeMap<String, AnalyzeDependenciesResponseDetail>,
#[serde(rename="dev-dependencies")]
dev_dependencies: BTreeMap<String, AnalyzeDependenciesResponseDetail>,
#[serde(rename="build-dependencies")]
build_dependencies: BTreeMap<String, AnalyzeDependenciesResponseDetail>
}
#[derive(Debug, Serialize)]
struct AnalyzeDependenciesResponse {
crates: BTreeMap<String, AnalyzeDependenciesResponseSingle>
}
pub fn status_json(analysis_outcome: AnalyzeDependenciesOutcome) -> Response {
let single = AnalyzeDependenciesResponseSingle {
dependencies: analysis_outcome.deps.main.into_iter()
.map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail {
outdated: analyzed.is_outdated(),
required: analyzed.required,
latest: analyzed.latest
})).collect(),
dev_dependencies: analysis_outcome.deps.dev.into_iter()
.map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail {
outdated: analyzed.is_outdated(),
required: analyzed.required,
latest: analyzed.latest
})).collect(),
build_dependencies: analysis_outcome.deps.build.into_iter()
.map(|(name, analyzed)| (name.into(), AnalyzeDependenciesResponseDetail {
outdated: analyzed.is_outdated(),
required: analyzed.required,
latest: analyzed.latest
})).collect()
};
let multi = AnalyzeDependenciesResponse {
crates: vec![(analysis_outcome.name.into(), single)].into_iter().collect()
};
Response::new()
.with_header(ContentType::json())
.with_body(serde_json::to_string(&multi).unwrap())
}

View file

@ -0,0 +1,16 @@
use hyper::Response;
use hyper::header::ContentType;
use ::server::assets;
use ::engine::AnalyzeDependenciesOutcome;
pub fn status_svg(analysis_outcome: AnalyzeDependenciesOutcome) -> Response {
let mut response = Response::new()
.with_header(ContentType("image/svg+xml;charset=utf-8".parse().unwrap()));
if analysis_outcome.deps.any_outdated() {
response.set_body(assets::BADGE_OUTDATED_SVG.to_vec());
} else {
response.set_body(assets::BADGE_UPTODATE_SVG.to_vec());
}
response
}