diff --git a/core/src/api/update.rs b/core/src/api/update.rs index 8e5bf19d7..b3e9600ce 100644 --- a/core/src/api/update.rs +++ b/core/src/api/update.rs @@ -9,6 +9,7 @@ use types::{PermissionLevel, Update, UpdateTarget}; use crate::{ auth::{RequestUser, RequestUserExtension}, + helpers::parse_comma_seperated_list, response, state::{State, StateExtension}, }; @@ -36,8 +37,18 @@ pub fn router() -> Router { .unwrap_or_default() }) .unwrap_or_default(); + let operations = value + .get("operations") + .map(|o| { + let o = o.as_str().unwrap_or_default(); + if o.len() == 0 { + return None; + } + parse_comma_seperated_list::(o).ok() + }) + .flatten(); let updates = state - .list_updates(target, offset, show_builds, &user) + .list_updates(target, offset, show_builds, operations, &user) .await .map_err(handle_anyhow_error)?; response!(Json(updates)) @@ -92,9 +103,10 @@ impl State { target: Option, offset: u64, show_builds: bool, + operations: Option>, user: &RequestUser, ) -> anyhow::Result> { - let filter = match target { + let mut filter = match target { Some(target) => { if let (UpdateTarget::Deployment(id), true) = (&target, show_builds) { let deployment = self @@ -105,32 +117,32 @@ impl State { .get_build_check_permissions(build_id, user, PermissionLevel::Read) .await; if let Ok(_) = build { - Some(doc! { + doc! { "$or": [ {"target": to_bson(&target).unwrap()}, {"target": { "type": "Build", "id": build_id }, "operation": "build_build"} ], - }) + } } else { - Some(doc! { + doc! { "target": to_bson(&target).unwrap() - }) + } } } else { - Some(doc! { + doc! { "target": to_bson(&target).unwrap() - }) + } } } else { self.permission_on_update_target(&target, user).await?; - Some(doc! { + doc! { "target": to_bson(&target).unwrap() - }) + } } } None => { if user.is_admin { - None + doc! {} } else { let permissions_field = format!("permissions.{}", user.id); let target_filter = doc! { @@ -184,10 +196,13 @@ impl State { { "target.type": "Procedure", "target.id": { "$in": &procedure_ids } } ] }; - Some(filter) + filter } } }; + if let Some(operations) = operations { + filter.insert("operation", doc! { "$in": operations }); + } let mut updates = self .db .updates diff --git a/core/src/helpers.rs b/core/src/helpers.rs index da57fafd1..cb6549f0d 100644 --- a/core/src/helpers.rs +++ b/core/src/helpers.rs @@ -1,3 +1,6 @@ +use std::str::FromStr; + +use anyhow::anyhow; use diff::{Diff, OptionDiff}; #[macro_export] @@ -25,3 +28,16 @@ where } return false; } + +pub fn parse_comma_seperated_list(comma_sep_list: &str) -> anyhow::Result> { + comma_sep_list + .split(",") + .filter(|item| item.len() > 0) + .map(|item| { + let item = item + .parse() + .map_err(|_| anyhow!("error parsing string {item} into type T"))?; + Ok::(item) + }) + .collect() +} diff --git a/core/src/monitoring.rs b/core/src/monitoring.rs index fbb9d9778..47e5e39c6 100644 --- a/core/src/monitoring.rs +++ b/core/src/monitoring.rs @@ -101,7 +101,12 @@ impl State { async fn check_cpu(&self, server: &Server, stats: &SystemStats) { let server_alert_status = self.server_alert_status.lock().await; - if self.slack.is_none() || server_alert_status.get(&server.id).map(|s| s.cpu_alert).unwrap_or(false) { + if self.slack.is_none() + || server_alert_status + .get(&server.id) + .map(|s| s.cpu_alert) + .unwrap_or(false) + { return; } drop(server_alert_status); @@ -175,7 +180,12 @@ impl State { async fn check_mem(&self, server: &Server, stats: &SystemStats) { let server_alert_status = self.server_alert_status.lock().await; - if self.slack.is_none() || server_alert_status.get(&server.id).map(|s| s.mem_alert).unwrap_or(false) { + if self.slack.is_none() + || server_alert_status + .get(&server.id) + .map(|s| s.mem_alert) + .unwrap_or(false) + { return; } drop(server_alert_status); diff --git a/frontend/src/components/home/Summary.tsx b/frontend/src/components/home/Summary.tsx index 1f6998354..9bc911218 100644 --- a/frontend/src/components/home/Summary.tsx +++ b/frontend/src/components/home/Summary.tsx @@ -23,7 +23,7 @@ const SummaryItem: Component<{ }> = (p) => { return ( diff --git a/frontend/src/components/home/Updates/Updates.tsx b/frontend/src/components/home/Updates/Updates.tsx index 134e621de..e8a4842ff 100644 --- a/frontend/src/components/home/Updates/Updates.tsx +++ b/frontend/src/components/home/Updates/Updates.tsx @@ -1,15 +1,46 @@ -import { Component, For, Show } from "solid-js"; +import { Component, createEffect, createSignal, For, Show } from "solid-js"; import { useAppState } from "../../../state/StateProvider"; +import { Operation } from "../../../types"; import Flex from "../../shared/layout/Flex"; import Grid from "../../shared/layout/Grid"; import Loading from "../../shared/loading/Loading"; +import Selector from "../../shared/menu/Selector"; import Update from "./Update"; +const OPERATIONS = Object.values(Operation) + .filter((e) => e !== "none" && !e.includes("user")) + .map((e) => e.replaceAll("_", " ")); + const Updates: Component<{}> = () => { const { updates } = useAppState(); + const [operation, setOperation] = createSignal(); + createEffect(() => { + if (operation()) { + updates.load([operation()!]); + } else { + updates.load(); + } + }); return ( -

updates

+ +

updates

+ + o === "all" + ? setOperation(undefined) + : setOperation(o.replaceAll(" ", "_") as Operation) + } + targetStyle={{ padding: "0" }} + position="bottom right" + searchStyle={{ width: "15rem" }} + menuClass="scroller" + menuStyle={{ "max-height": "50vh" }} + useSearch + /> +
{ - if (show()) ref?.focus(); + if (show()) setTimeout(() => ref?.focus(), 200); }); return ( { if (e.key === "Escape") { toggle(); @@ -104,6 +107,8 @@ const Selector: Component<{ } + menuClass={p.menuClass} + menuStyle={p.menuStyle} position={p.position} padding="0.25rem" /> diff --git a/frontend/src/components/update/UpdateMenu.tsx b/frontend/src/components/update/UpdateMenu.tsx index 466ab110e..0373eb419 100644 --- a/frontend/src/components/update/UpdateMenu.tsx +++ b/frontend/src/components/update/UpdateMenu.tsx @@ -1,7 +1,12 @@ import { Component, For, Show } from "solid-js"; import { useAppState } from "../../state/StateProvider"; import { Operation, Update as UpdateType, UpdateStatus } from "../../types"; -import { combineClasses, readableDuration, readableMonitorTimestamp } from "../../util/helpers"; +import { + combineClasses, + readableDuration, + readableMonitorTimestamp, + readableVersion, +} from "../../util/helpers"; import { useToggle } from "../../util/hooks"; import Icon from "../shared/Icon"; import Flex from "../shared/layout/Flex"; @@ -10,7 +15,7 @@ import CenterMenu from "../shared/menu/CenterMenu"; import s from "./update.module.scss"; const UpdateMenu: Component<{ update: UpdateType }> = (p) => { - const { deployments, servers, builds } = useAppState(); + const { deployments, servers, builds } = useAppState(); const name = () => { if (p.update.target.type === "Deployment" && deployments.loaded()) { return deployments.get(p.update.target.id!)?.deployment.name || "deleted"; @@ -24,7 +29,7 @@ const UpdateMenu: Component<{ update: UpdateType }> = (p) => { }; const operation = () => { if (p.update.operation === Operation.BuildBuild) { - return "build" + return "build"; } return p.update.operation.replaceAll("_", " "); }; @@ -38,9 +43,7 @@ const UpdateMenu: Component<{ update: UpdateType }> = (p) => { targetStyle={{ "place-self": "center end" }} targetClass="blue" padding="1rem 2rem" - content={ - () => - } + content={() => } /> ); }; @@ -50,60 +53,88 @@ export default UpdateMenu; const UpdateMenuContent: Component<{ update: UpdateType }> = (p) => { const { usernames } = useAppState(); return ( - - -
status: {p.update.status.replaceAll("_", " ")}
-
operator: {usernames.get(p.update.operator)}
-
started at: {readableMonitorTimestamp(p.update.start_ts)}
+ + + + status:

{p.update.status.replaceAll("_", " ")}

+
+

+ {readableMonitorTimestamp(p.update.start_ts)} +

-
- duration: {readableDuration(p.update.start_ts, p.update.end_ts!)} -
+ + duration: +

{readableDuration(p.update.start_ts, p.update.end_ts!)}

+
+
+ + +

{usernames.get(p.update.operator)}

+
+ + + version: +

{readableVersion(p.update.version!)}

+
- - {(log, index) => { - return ( - - -

{log.stage}

-
- (stage {index() + 1} of {p.update.logs.length}) -
-
- {readableDuration(log.start_ts, log.end_ts)} -
-
- -
command
-
{log.command}
-
- -
stdout
-
-                  {log.stdout}
-                
-
- -
stderr
-
-                  {log.stderr}
-                
-
-
- ); - }} -
+
); -} \ No newline at end of file +}; + +const UpdateLogs: Component<{ update: UpdateType }> = (p) => { + return ( + + {(log, index) => { + return ( + + +

{log.stage}

+
+ (stage {index() + 1} of {p.update.logs.length}) +
+
+ {readableDuration(log.start_ts, log.end_ts)} +
+
+ +
command
+
{log.command}
+
+ +
stdout
+
+                {log.stdout}
+              
+
+ +
stderr
+
+                {log.stderr}
+              
+
+
+ ); + }} +
+ ); +}; diff --git a/frontend/src/state/hooks.ts b/frontend/src/state/hooks.ts index d5760a3ec..65a6e4fcd 100644 --- a/frontend/src/state/hooks.ts +++ b/frontend/src/state/hooks.ts @@ -1,6 +1,6 @@ import { createEffect, createResource, createSignal } from "solid-js"; import { client } from ".."; -import { ServerStatus, SystemInformation, SystemStats, UpdateTarget } from "../types"; +import { Operation, ServerStatus, SystemInformation, SystemStats, UpdateTarget } from "../types"; import { filterOutFromObj, getNestedEntry, @@ -152,7 +152,7 @@ export function useDeployments() { export function useUpdates(target?: UpdateTarget, show_builds?: boolean) { const updates = useArrayWithId( - () => client.list_updates(0, target, show_builds), + (operations?: Operation[]) => client.list_updates(0, target, show_builds, operations), ["_id", "$oid"] ); const [noMore, setNoMore] = createSignal(false); @@ -173,10 +173,13 @@ export function useUpdates(target?: UpdateTarget, show_builds?: boolean) { }; } -export function useArray(query: () => Promise) { +export function useArray(query: (options?: O) => Promise, options?: O) { const [collection, set] = createSignal(); + const load = (options?: O) => { + query(options).then(set); + } createEffect(() => { - query().then(set); + load(options); }); const add = (item: T) => { set((items: T[] | undefined) => (items ? [item, ...items] : [item])); @@ -186,6 +189,7 @@ export function useArray(query: () => Promise) { }; const loaded = () => (collection() ? true : false); return { + load, collection, add, addManyToEnd, @@ -193,10 +197,13 @@ export function useArray(query: () => Promise) { }; } -export function useArrayWithId(query: () => Promise, idPath: string[]) { +export function useArrayWithId(query: (options?: O) => Promise, idPath: string[], options?: O) { const [collection, set] = createSignal(); + const load = (options?: O) => { + query(options).then(set); + }; createEffect(() => { - query().then(set); + load(options); }); const addOrUpdate = (item: T) => { set((items: T[] | undefined) => { @@ -226,6 +233,7 @@ export function useArrayWithId(query: () => Promise, idPath: string[]) { }; const loaded = () => (collection() ? true : false); return { + load, collection, addOrUpdate, addManyToEnd, diff --git a/frontend/src/style/app.scss b/frontend/src/style/app.scss index 8a10d3ff8..e2fc6b1a0 100644 --- a/frontend/src/style/app.scss +++ b/frontend/src/style/app.scss @@ -9,6 +9,7 @@ max-width: calc(1200px + 2rem); box-sizing: border-box; place-items: center; + margin-bottom: 50vh; } @media only screen and (max-width: 1200px) { diff --git a/frontend/src/util/client.ts b/frontend/src/util/client.ts index 7c73662b8..259af2172 100644 --- a/frontend/src/util/client.ts +++ b/frontend/src/util/client.ts @@ -13,6 +13,7 @@ import { Group, HistoricalStatsQuery, Log, + Operation, Procedure, Server, ServerActionState, @@ -233,7 +234,7 @@ export class Client { } get_server_system_info(id: string): Promise { - return this.get(`/api/server/${id}/system_information`) + return this.get(`/api/server/${id}/system_information`); } create_server(body: CreateServerBody): Promise { @@ -314,8 +315,11 @@ export class Client { get_build_action_state(id: string): Promise { return this.get(`/api/build/${id}/action_state`); } - - get_build_versions(id: string, query?: BuildVersionsQuery): Promise { + + get_build_versions( + id: string, + query?: BuildVersionsQuery + ): Promise { return this.get(`/api/build/${id}/versions${generateQuery(query as any)}`); } @@ -405,13 +409,19 @@ export class Client { // updates // show_builds is only relevant for Deployment targets, must pass show_builds = true to include build updates of attached build_id - list_updates(offset: number, target?: UpdateTarget, show_builds?: boolean): Promise { + list_updates( + offset: number, + target?: UpdateTarget, + show_builds?: boolean, + operations?: Operation[] + ): Promise { return this.get( `/api/update/list${generateQuery({ offset, type: target && target.type, id: target && target.id, show_builds, + operations: operations?.join(","), })}` ); } diff --git a/frontend/src/util/helpers.ts b/frontend/src/util/helpers.ts index 0160d2904..be9d43b8a 100644 --- a/frontend/src/util/helpers.ts +++ b/frontend/src/util/helpers.ts @@ -1,4 +1,10 @@ -import { DockerContainerState, EnvironmentVar, ServerStatus, Timelength, Version } from "../types"; +import { + DockerContainerState, + EnvironmentVar, + ServerStatus, + Timelength, + Version, +} from "../types"; export function combineClasses(...classes: (string | false | undefined)[]) { return classes.filter((c) => (c ? true : false)).join(" "); @@ -48,8 +54,12 @@ export function readableDuration(start_ts: string, end_ts: string) { const start = new Date(start_ts); const end = new Date(end_ts); const durr = end.getTime() - start.getTime(); - const seconds = (durr / 1000).toFixed(1); - return `${seconds} seconds`; + const seconds = durr / 1000; + const minutes = Math.floor(seconds / 60); + const remaining_seconds = seconds % 60; + return `${ + minutes > 0 ? `${minutes} minute${minutes > 1 ? "s" : ""} ` : "" + }${remaining_seconds.toFixed(minutes > 0 ? 0 : 1)} seconds`; } const tzOffset = new Date().getTimezoneOffset() * 60; @@ -163,9 +173,7 @@ export function parseDotEnvToEnvVars(env: string): EnvironmentVar[] { .map(([variable, value]) => ({ variable, value })); } -export function deploymentHeaderStateClass( - state: DockerContainerState, -) { +export function deploymentHeaderStateClass(state: DockerContainerState) { switch (state) { case DockerContainerState.Running: return "running"; @@ -175,28 +183,28 @@ export function deploymentHeaderStateClass( } export function version_to_string(version: Version) { - return `${version.major}.${version.minor}.${version.patch}` + return `${version.major}.${version.minor}.${version.patch}`; } export function string_to_version(version: string): Version { - const [major, minor, patch] = version.split(".") + const [major, minor, patch] = version.split("."); return { major: Number(major), minor: Number(minor), patch: Number(patch), - } + }; } export function get_to_one_sec_divisor(timelength: Timelength) { // returns what the timelength needs to be divided to convert to per second values if (timelength === Timelength.OneSecond) { - return 1 + return 1; } else if (timelength === Timelength.FiveSeconds) { - return 5 + return 5; } else if (timelength === Timelength.ThirtySeconds) { - return 30 + return 30; } else if (timelength === Timelength.OneMinute) { - return 60 + return 60; } } @@ -215,14 +223,14 @@ export function convert_timelength_to_ms(timelength: Timelength) { export function readableStorageAmount(gb: number) { if (gb > 512) { - return `${(gb / 1024).toFixed(1)} TB` + return `${(gb / 1024).toFixed(1)} TB`; } else if (gb < 1) { - return `${(gb * 1024).toFixed()} MiB` + return `${(gb * 1024).toFixed()} MiB`; } else { - return `${gb.toFixed()} GiB` + return `${gb.toFixed()} GiB`; } } export function readableVersion(version: Version) { return `v${version.major}.${version.minor}.${version.patch}`; -} \ No newline at end of file +}