Compare commits

...

3 Commits

Author SHA1 Message Date
Joe Bellus b1b3d21d1a Favicon support, password changes
Favicon support for bookmarks and applications

Password change implemented in setting screen

Logout implemented in setting screen
2022-02-15 18:22:42 -05:00
Joe Bellus 6d9d863039 Dont reepat healthcheck statuses
Only sends healthcheck statuses if they are different then the current value
2022-02-15 10:19:13 -05:00
Joe Bellus 04c2f37b60 Added margin to tile descriptions 2022-02-15 00:12:51 -05:00
13 changed files with 170 additions and 33 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE application ADD COLUMN favicon Boolean DEFAULT false;
ALTER TABLE bookmark ADD COLUMN favicon Boolean DEFAULT false;

View File

@ -15,6 +15,7 @@ struct ApiApplication {
pub application_category_id: Option<i32>, pub application_category_id: Option<i32>,
pub enable_healthcheck: bool, pub enable_healthcheck: bool,
pub healthcheck_status: Option<bool>, pub healthcheck_status: Option<bool>,
pub favicon: bool,
} }
impl From<application::Model> for ApiApplication { impl From<application::Model> for ApiApplication {
@ -29,6 +30,7 @@ impl From<application::Model> for ApiApplication {
application_category_id: model.application_category_id, application_category_id: model.application_category_id,
enable_healthcheck: model.enable_healthcheck, enable_healthcheck: model.enable_healthcheck,
healthcheck_status: None, healthcheck_status: None,
favicon: model.favicon,
} }
} }
} }
@ -65,6 +67,7 @@ pub async fn new_application(
glyph: Set(data.0.glyph), glyph: Set(data.0.glyph),
application_category_id: Set(data.0.application_category_id), application_category_id: Set(data.0.application_category_id),
enable_healthcheck: Set(data.0.enable_healthcheck), enable_healthcheck: Set(data.0.enable_healthcheck),
favicon: Set(data.0.favicon),
}; };
let app = model.insert(&state.db).await?; let app = model.insert(&state.db).await?;
Ok(HttpResponse::Ok().json(app)) Ok(HttpResponse::Ok().json(app))
@ -106,6 +109,7 @@ pub async fn update_applications(
application_category_id: Set(data.application_category_id), application_category_id: Set(data.application_category_id),
glyph: Set(data.glyph), glyph: Set(data.glyph),
enable_healthcheck: Set(data.enable_healthcheck), enable_healthcheck: Set(data.enable_healthcheck),
favicon: Set(data.favicon),
}; };
let model = ret.update(&state.db).await?; let model = ret.update(&state.db).await?;
Ok(HttpResponse::Ok().json(model)) Ok(HttpResponse::Ok().json(model))
@ -210,6 +214,7 @@ mod tests {
active: true, active: true,
application_category_id: None, application_category_id: None,
enable_healthcheck: false, enable_healthcheck: false,
..Default::default()
}; };
let state = setup_state().await?; let state = setup_state().await?;

View File

@ -49,7 +49,7 @@ pub struct UpdatePasswordRequest {
} }
#[instrument] #[instrument]
#[post("password")] #[put("password")]
pub async fn update_password( pub async fn update_password(
state: web::Data<AppState>, state: web::Data<AppState>,
req: web::Json<UpdatePasswordRequest>, req: web::Json<UpdatePasswordRequest>,

View File

@ -15,6 +15,7 @@ pub struct Model {
pub glyph: Option<String>, pub glyph: Option<String>,
pub application_category_id: Option<i32>, pub application_category_id: Option<i32>,
pub enable_healthcheck: bool, pub enable_healthcheck: bool,
pub favicon: bool,
} }
#[derive(Copy, Clone, Debug, EnumIter)] #[derive(Copy, Clone, Debug, EnumIter)]
@ -52,6 +53,7 @@ impl Default for Model {
glyph: Default::default(), glyph: Default::default(),
application_category_id: Default::default(), application_category_id: Default::default(),
enable_healthcheck: false, enable_healthcheck: false,
favicon: false,
} }
} }
} }

View File

@ -73,7 +73,6 @@ impl Handler<Event> for EventBroker {
type Result = (); type Result = ();
fn handle(&mut self, msg: Event, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: Event, _ctx: &mut Self::Context) -> Self::Result {
tracing::info!("Event received");
for (_, ses) in self.sessions.iter() { for (_, ses) in self.sessions.iter() {
let _ = ses.addr.do_send(msg.clone()); let _ = ses.addr.do_send(msg.clone());
} }

View File

@ -6,7 +6,6 @@ use actix_web::{
web::{self, Data}, web::{self, Data},
App, HttpResponse, HttpServer, App, HttpResponse, HttpServer,
}; };
use clap::crate_version;
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use sea_orm::{prelude::*, Database}; use sea_orm::{prelude::*, Database};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -100,13 +99,21 @@ async fn main() {
.await .await
{ {
Ok(res) if res.status() == 200 => { Ok(res) if res.status() == 200 => {
st.healthcheck_status.lock().await.insert(app.id, true); if !st
let _ = events::EventBroker::from_registry() .healthcheck_status
.send(events::Event::HealthcheckChange { .lock()
app_id: app.id, .await
alive: true, .get(&app.id)
}) .unwrap_or(&false)
.await; {
st.healthcheck_status.lock().await.insert(app.id, true);
let _ = events::EventBroker::from_registry()
.send(events::Event::HealthcheckChange {
app_id: app.id,
alive: true,
})
.await;
}
} }
Err(e) => { Err(e) => {
tracing::warn!("Error performing healthcheck: {}", e); tracing::warn!("Error performing healthcheck: {}", e);
@ -119,14 +126,22 @@ async fn main() {
.await; .await;
} }
Ok(res) => { Ok(res) => {
tracing::warn!("Non 200 status code: {}", res.status()); if *st
st.healthcheck_status.lock().await.insert(app.id, false); .healthcheck_status
let _ = events::EventBroker::from_registry() .lock()
.send(events::Event::HealthcheckChange { .await
app_id: app.id, .get(&app.id)
alive: false, .unwrap_or(&true)
}) {
.await; tracing::warn!("Non 200 status code: {}", res.status());
st.healthcheck_status.lock().await.insert(app.id, false);
let _ = events::EventBroker::from_registry()
.send(events::Event::HealthcheckChange {
app_id: app.id,
alive: false,
})
.await;
}
} }
} }
} }

View File

@ -12,7 +12,8 @@
<text-field v-model="app.appName" label="Name" required /> <text-field v-model="app.appName" label="Name" required />
<text-field v-model="app.description" label="Description" /> <text-field v-model="app.description" label="Description" />
<text-field v-model="app.url" label="URL" required /> <text-field v-model="app.url" label="URL" required />
<icon-picker v-model="app.glyph" /> <switch-field v-model="app.favicon" label="Use favicon" />
<icon-picker v-model="app.glyph" v-if="!app.favicon" />
<switch-field v-model="app.enableHealthcheck" label="Health check" /> <switch-field v-model="app.enableHealthcheck" label="Health check" />
</form> </form>
</template> </template>
@ -84,6 +85,7 @@ export default {
let resp = await axios.post("/api/applications", { let resp = await axios.post("/api/applications", {
active: true, active: true,
enableHealthcheck: !!this.app.enableHealthcheck, enableHealthcheck: !!this.app.enableHealthcheck,
favicon: !!this.app.favicon,
...this.app, ...this.app,
}); });
if (resp.status == 200) { if (resp.status == 200) {

View File

@ -1,7 +1,7 @@
<template> <template>
<div :class="{tile: true, alive: appData.healthcheckStatus === true, dead: appData.healthcheckStatus === false}" @click="click"> <div :class="{tile: true, alive: appData.healthcheckStatus === true, dead: appData.healthcheckStatus === false}" @click="click">
<font-awesome-icon v-if="appData.glyph" :icon="appData.glyph" size="2x" /> <font-awesome-icon v-if="appData.glyph && !appData.favicon" :icon="appData.glyph" size="2x" />
<font-awesome-icon v-if="!appData.glyph && appData.healthcheckStatus === false" icon="ban" size="2x" /> <img :src="favicon" v-if="appData.favicon" width="30" height="30"/>
<div class="label"> <div class="label">
<div class="title">{{appData.appName}}</div> <div class="title">{{appData.appName}}</div>
<div class="description">{{appData.description}}</div> <div class="description">{{appData.description}}</div>
@ -12,6 +12,16 @@
<script> <script>
export default { export default {
props: ["appData"], props: ["appData"],
computed: {
favicon() {
try {
const url = new URL(this.appData.url);
return `${url.protocol}${url.hostname}/favicon.ico`;
} catch {
return "";
}
}
},
methods: { methods: {
click(e) { click(e) {
this.$emit("clicked", e, this.appData); this.$emit("clicked", e, this.appData);
@ -19,7 +29,7 @@ export default {
}, },
}; };
</script> </script>
<style> <style scoped>
.tile { .tile {
transition: all 0.5s; transition: all 0.5s;
padding: 16px 25px; padding: 16px 25px;
@ -40,7 +50,7 @@ export default {
flex: 1; flex: 1;
text-align: left; text-align: left;
} }
svg { svg, img {
color: #fff; color: #fff;
margin-right: 20px; margin-right: 20px;
} }
@ -52,7 +62,7 @@ svg {
.tile.alive svg { .tile.alive svg {
color: #009900; color: #009900;
} }
.tile.dead svg { .tile.dead svg {
color: #900; color: #900;
} }
.title { .title {
@ -65,6 +75,7 @@ svg {
color: #ccc; color: #ccc;
font-size: 0.8em; font-size: 0.8em;
line-height: 1; line-height: 1;
margin-top: 4px;
} }
@ -78,4 +89,7 @@ svg {
.lightMode .tile:hover { .lightMode .tile:hover {
background-color: rgba(200,200,200, 0.5); background-color: rgba(200,200,200, 0.5);
} }
.tile.dead {
background: rgba(50,0,0,0.4);
}
</style> </style>

View File

@ -1,6 +1,7 @@
<template> <template>
<div class="bookmark-tile" @click="click"> <div class="bookmark-tile" @click="click">
<font-awesome-icon icon="external-link-alt"/> <font-awesome-icon v-if="!showFavIcon" icon="external-link-alt" size="2x"/>
<img v-if="showFavIcon" :src="favicon" @error="faviconError"/>
{{bookmark.bookmarkName}} {{bookmark.bookmarkName}}
</div> </div>
</template> </template>
@ -8,19 +9,45 @@
<script> <script>
export default { export default {
props: ["bookmark"], props: ["bookmark"],
data() {
return {
showFavIcon: true,
}
},
computed: {
favicon() {
try {
const url = new URL(this.bookmark.url);
return `${url.protocol}${url.hostname}/favicon.ico`;
} catch {
return "";
}
}
},
methods: { methods: {
click() { click() {
this.$emit("clicked", this.bookmark); this.$emit("clicked", this.bookmark);
}, },
faviconError() {
this.showFavIcon = false;
}
}, },
}; };
</script> </script>
<style> <style scoped>
.bookmark-tile { .bookmark-tile {
padding: 5px 25px; padding: 5px 25px;
border-radius: 5px; border-radius: 5px;
transition: all 0.2s; transition: all 0.2s;
line-height: 20px;
display: flex;
}
.bookmark-tile img, .bookmark-tile svg {
margin-right: 20px;
width: 20px;
height: 20px;
} }
.bookmark-tile, .bookmark-tile svg { .bookmark-tile, .bookmark-tile svg {
@ -42,6 +69,4 @@ export default {
background: rgba(255,255,255,0.25); background: rgba(255,255,255,0.25);
color: #222; color: #222;
} }
</style> </style>

View File

@ -14,9 +14,34 @@ export default {
</script> </script>
<style scoped> <style scoped>
svg {
color: #fff;
margin-right: 20px;
}
.tile.new { .tile.new {
transition: all 0.5s;
padding: 16px 25px;
border-radius: 5px;
margin: 5px;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: flex-start;
background: rgba(0,0,0,0.8); background: rgba(0,0,0,0.8);
} }
.label {
font-weight: 900;
flex: 1;
text-align: left;
}
.title {
font-weight: bold;
}
.tile:hover {
transition: all 0.1s;
box-shadow: rgba(0, 0, 0, 0.5) 0px 20px 30px -10px;
}
.lightMode .tile.new { .lightMode .tile.new {
background: rgba(255,255,255,0.8); background: rgba(255,255,255,0.8);
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<form-field> <form-field>
<label v-if="label" :class="{error: isError}">{{label}}</label> <label v-if="label" :class="{error: isError}">{{label}}</label>
<input :value="modelValue" ref="field" @input="handleInput" :type="inputType" :class="{error: isError}" /> <input :placeholder="placeholder" :value="modelValue" ref="field" @input="handleInput" :type="inputType" :class="{error: isError}" />
</form-field> </form-field>
</template> </template>
@ -9,6 +9,7 @@
import FormField from "./FormField.vue"; import FormField from "./FormField.vue";
export default { export default {
props: { props: {
placeholder: String,
required: Boolean, required: Boolean,
label: String, label: String,
modelValue: String, modelValue: String,

View File

@ -2,7 +2,8 @@
<div class="login-wrapper"> <div class="login-wrapper">
<form @submit.prevent="goLogin"> <form @submit.prevent="goLogin">
<panel> <panel>
<input v-model="password" type="password" autofocus /> <div class="error" v-if="error">{{error}}</div>
<input v-model="password" type="password" autofocus @input="resetError"/>
<button @clicked="goLogin" type="submit">Authenticate</button> <button @clicked="goLogin" type="submit">Authenticate</button>
</panel> </panel>
</form> </form>
@ -21,15 +22,23 @@ export default {
data() { data() {
return { return {
password: "", password: "",
error: ""
}; };
}, },
methods: { methods: {
resetError() {
this.error = "";
},
async goLogin() { async goLogin() {
const response = await axios.post("/api/authorize", { const response = await axios.post("/api/authorize", {
password: this.password, password: this.password,
}); });
localStorage.setItem("token", response.data.token); if (response.status == 200) {
this.$router.push("/"); localStorage.setItem("token", response.data.token);
this.$router.push("/");
} else {
this.error = "Could not authenticate";
}
}, },
}, },
}; };
@ -71,4 +80,13 @@ button:hover {
box-shadow: rgba(0, 0, 0, 0.25) 0px 14px 28px, box-shadow: rgba(0, 0, 0, 0.25) 0px 14px 28px,
rgba(0, 0, 0, 0.22) 0px 10px 10px; rgba(0, 0, 0, 0.22) 0px 10px 10px;
} }
.error {
color: #f33;
background: #311;
border-radius: 5px;
margin-bottom: 10px;
font-size: 16px;
font-weight: bold;
padding: 8px 12px;
}
</style>> </style>>

View File

@ -16,7 +16,17 @@
<btn label="Update" @click="updateMode" /> <btn label="Update" @click="updateMode" />
</div> </div>
</div> </div>
<div class="section">
<h1>Password</h1>
<h2>Change password</h2>
<text-field v-model="password" password placeholder="password" />
<text-field v-model="confirmPassword" password placeholder="confirm password" />
<div class="actions">
<btn :disabled="password !== confirmPassword || !password.length" label="Change Password" @click="updatePassword" />
</div>
</div>
<div class="control-buttons"> <div class="control-buttons">
<btn danger label="Logout" @click="logout" />
<btn primary label="Back to Dashboard" @click="backClicked" /> <btn primary label="Back to Dashboard" @click="backClicked" />
</div> </div>
</div> </div>
@ -26,13 +36,16 @@
import Btn from "../components/Button.vue"; import Btn from "../components/Button.vue";
import SwitchField from "../components/Switch.vue"; import SwitchField from "../components/Switch.vue";
import axios from "axios"; import axios from "axios";
import TextField from '../components/TextField.vue';
export default { export default {
components: { Btn, SwitchField }, components: { Btn, SwitchField, TextField },
data() { data() {
return { return {
file: "", file: "",
darkMode: true, darkMode: true,
password: "",
confirmPassword: "",
} }
}, },
async mounted() { async mounted() {
@ -51,8 +64,20 @@ export default {
mode: this.darkMode ? "dark" : "light" mode: this.darkMode ? "dark" : "light"
}); });
}, },
async updatePassword() {
const res = await axios.put("/api/password", {
password: this.password,
});
if (res.status === 200) {
this.$router.push("/login");
}
},
backClicked() { backClicked() {
this.$router.push("/"); this.$router.push("/");
},
logout() {
localStorage.removeItem("token");
this.$router.push("/login");
} }
} }
} }
@ -96,4 +121,7 @@ export default {
margin-top: 15px; margin-top: 15px;
text-align: right; text-align: right;
} }
.control-buttons button + button {
margin-left: 15px;
}
</style> </style>