* start. 1.19.3

* deploy 1.19.3-dev-1

* repo state from db includes BuildRepo success

* clean up version mismatch text

* feat(containers): debounced search input and added filter by server name (#796)

* Fix cleaning Alerter resource whitelist / blacklist on resource delete re #581

* fmt

* Fix signup button not working correctly (#801)

* Improve route protection and authentication flow (#798)

* Improve route protection and authentication flow

* Cleanup

* fix: inconsistent behaviour of new resource create button (#800)

* fix monaco crashing with absolute path config files

* deploy 1.19.3-dev-2

* proofread config

* Fix #427

* deploy 1.19.3-dev-3

* poll logs use println

* Sync: Only show commit / execute when viewing pending tab

* Improve sync UX

* deploy 1.19.3-dev-4

* bold link

* remove claims about database resource usage.

* 1.19.3

---------

Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Antonio Sarro <tech@antoniosarro.dev>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
This commit is contained in:
Maxwell Becker
2025-09-05 13:41:58 -07:00
committed by GitHub
parent 0873104b5a
commit a65fd4dca7
27 changed files with 580 additions and 244 deletions

32
Cargo.lock generated
View File

@@ -907,7 +907,7 @@ dependencies = [
[[package]] [[package]]
name = "cache" name = "cache"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"tokio", "tokio",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]] [[package]]
name = "command" name = "command"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"komodo_client", "komodo_client",
"run_command", "run_command",
@@ -1102,7 +1102,7 @@ checksum = "2957e823c15bde7ecf1e8b64e537aa03a6be5fda0e2334e99887669e75b12e01"
[[package]] [[package]]
name = "config" name = "config"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"colored", "colored",
"indexmap 2.11.0", "indexmap 2.11.0",
@@ -1342,7 +1342,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "database" name = "database"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-compression", "async-compression",
@@ -1641,7 +1641,7 @@ dependencies = [
[[package]] [[package]]
name = "environment" name = "environment"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"formatting", "formatting",
@@ -1651,7 +1651,7 @@ dependencies = [
[[package]] [[package]]
name = "environment_file" name = "environment_file"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"thiserror 2.0.16", "thiserror 2.0.16",
] ]
@@ -1741,7 +1741,7 @@ dependencies = [
[[package]] [[package]]
name = "formatting" name = "formatting"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"serror", "serror",
] ]
@@ -1903,7 +1903,7 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]] [[package]]
name = "git" name = "git"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cache", "cache",
@@ -2511,7 +2511,7 @@ dependencies = [
[[package]] [[package]]
name = "interpolate" name = "interpolate"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"komodo_client", "komodo_client",
@@ -2642,7 +2642,7 @@ dependencies = [
[[package]] [[package]]
name = "komodo_cli" name = "komodo_cli"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -2667,7 +2667,7 @@ dependencies = [
[[package]] [[package]]
name = "komodo_client" name = "komodo_client"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async_timing_util", "async_timing_util",
@@ -2702,7 +2702,7 @@ dependencies = [
[[package]] [[package]]
name = "komodo_core" name = "komodo_core"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -2772,7 +2772,7 @@ dependencies = [
[[package]] [[package]]
name = "komodo_periphery" name = "komodo_periphery"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -2894,7 +2894,7 @@ dependencies = [
[[package]] [[package]]
name = "logger" name = "logger"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"komodo_client", "komodo_client",
@@ -3637,7 +3637,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "periphery_client" name = "periphery_client"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"komodo_client", "komodo_client",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]] [[package]]
name = "response" name = "response"
version = "1.19.2" version = "1.19.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",

View File

@@ -8,7 +8,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.19.2" version = "1.19.3"
edition = "2024" edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"] authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@@ -549,20 +549,20 @@ async fn poll_update_until_complete(
} else { } else {
format!("{}/updates/{}", cli_config().host, update.id) format!("{}/updates/{}", cli_config().host, update.id)
}; };
info!("Link: '{}'", link.bold()); println!("Link: '{}'", link.bold());
let client = super::komodo_client().await?; let client = super::komodo_client().await?;
let timer = tokio::time::Instant::now(); let timer = tokio::time::Instant::now();
let update = client.poll_update_until_complete(&update.id).await?; let update = client.poll_update_until_complete(&update.id).await?;
if update.success { if update.success {
info!( println!(
"FINISHED in {}: {}", "FINISHED in {}: {}",
format!("{:.1?}", timer.elapsed()).bold(), format!("{:.1?}", timer.elapsed()).bold(),
"EXECUTION SUCCESSFUL".green(), "EXECUTION SUCCESSFUL".green(),
); );
} else { } else {
warn!( eprintln!(
"FINISHED in {}: {}", "FINISHED in {}: {}",
format!("{:.1?}", timer.elapsed()).bold(), format!("{:.1?}", timer.elapsed()).bold(),
"EXECUTION FAILED".red(), "EXECUTION FAILED".red(),

View File

@@ -29,12 +29,12 @@ pub async fn send_alert(
match alert.level { match alert.level {
SeverityLevel::Ok => { SeverityLevel::Ok => {
format!( format!(
"{level} | **{name}** ({region}) | Server version now matches core version ✅\n{link}" "{level} | **{name}**{region} | Periphery version now matches Core version ✅\n{link}"
) )
} }
_ => { _ => {
format!( format!(
"{level} | **{name}** ({region}) | Version mismatch detected ⚠️\nServer: **{server_version}** | Core: **{core_version}**\n{link}" "{level} | **{name}**{region} | Version mismatch detected ⚠️\nPeriphery: **{server_version}** | Core: **{core_version}**\n{link}"
) )
} }
} }

View File

@@ -275,12 +275,12 @@ fn standard_alert_content(alert: &Alert) -> String {
match alert.level { match alert.level {
SeverityLevel::Ok => { SeverityLevel::Ok => {
format!( format!(
"{level} | {name} ({region}) | Server version now matches core version ✅\n{link}" "{level} | {name}{region} | Periphery version now matches Core version ✅\n{link}"
) )
} }
_ => { _ => {
format!( format!(
"{level} | {name} ({region}) | Version mismatch detected ⚠️\nServer: {server_version} | Core: {core_version}\n{link}" "{level} | {name}{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}\n{link}"
) )
} }
} }

View File

@@ -34,12 +34,12 @@ pub async fn send_alert(
let text = match alert.level { let text = match alert.level {
SeverityLevel::Ok => { SeverityLevel::Ok => {
format!( format!(
"{level} | {name} ({region}) | Server version now matches core version ✅" "{level} | *{name}*{region} | Periphery version now matches Core version ✅"
) )
} }
_ => { _ => {
format!( format!(
"{level} | {name} ({region}) | Version mismatch detected ⚠️\nServer: {server_version} | Core: {core_version}" "{level} | *{name}*{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}"
) )
} }
}; };

View File

@@ -853,9 +853,15 @@ pub async fn delete<T: KomodoResource>(
); );
update.push_simple_log("Deleted Toml", toml); update.push_simple_log("Deleted Toml", toml);
if let Err(e) = T::post_delete(&resource, &mut update).await { tokio::join!(
update.push_error_log("post delete", format_serror(&e.into())); async {
} if let Err(e) = T::post_delete(&resource, &mut update).await {
update
.push_error_log("post delete", format_serror(&e.into()));
}
},
delete_from_alerters::<T>(&resource.id)
);
refresh_all_resources_cache().await; refresh_all_resources_cache().await;
@@ -865,6 +871,26 @@ pub async fn delete<T: KomodoResource>(
Ok(resource) Ok(resource)
} }
async fn delete_from_alerters<T: KomodoResource>(id: &str) {
let target_bson = doc! {
"type": T::resource_type().as_ref(),
"id": id,
};
if let Err(e) = db_client()
.alerters
.update_many(Document::new(), doc! {
"$pull": {
"config.resources": &target_bson,
"config.except_resources": target_bson,
}
})
.await
.context("Failed to clear deleted resource from alerter whitelist / blacklist")
{
warn!("{e:#}");
}
}
// ======= // =======
#[instrument(level = "debug")] #[instrument(level = "debug")]

View File

@@ -300,6 +300,7 @@ async fn get_repo_state_from_db(id: &str) -> RepoState {
"$or": [ "$or": [
{ "operation": "CloneRepo" }, { "operation": "CloneRepo" },
{ "operation": "PullRepo" }, { "operation": "PullRepo" },
{ "operation": "BuildRepo" },
], ],
}) })
.with_options( .with_options(

View File

@@ -1,6 +1,6 @@
{ {
"name": "komodo_client", "name": "komodo_client",
"version": "1.19.2", "version": "1.19.3",
"description": "Komodo client package", "description": "Komodo client package",
"homepage": "https://komo.do", "homepage": "https://komo.do",
"main": "dist/lib.js", "main": "dist/lib.js",

View File

@@ -9,14 +9,14 @@
## All fields with a "Default" provided are optional. If they are ## All fields with a "Default" provided are optional. If they are
## left out of the file, the "Default" value will be used. ## left out of the file, the "Default" value will be used.
## This file is bundled into the official image, `ghcr.io/moghtech/komodo`, ## This file is bundled into the official image, `ghcr.io/moghtech/komodo-core`,
## as the default config at `/config/.default.config.toml`. ## as the default config at `/config/.default.config.toml`.
## Komodo can start with no external config file mounted. ## Komodo Core can start with no external config file mounted.
## Most fields can also be configured using environment variables. ## Most fields can also be configured using environment variables.
## Environment variables will override values set in this file. ## Environment variables will override values set in this file.
## Can also use JSON or YAML if preffered. You can convert here: ## Can also use JSON or YAML if preferred. You can convert here:
## - YAML: https://it-tools.tech/toml-to-yaml ## - YAML: https://it-tools.tech/toml-to-yaml
## - JSON: https://it-tools.tech/toml-to-json ## - JSON: https://it-tools.tech/toml-to-json

View File

@@ -9,7 +9,7 @@
## Most fields can also be configured using cli arguments and environment variables. ## Most fields can also be configured using cli arguments and environment variables.
## These will will override values set in this file. (cli args > env > config files). ## These will will override values set in this file. (cli args > env > config files).
## You can also use JSON or YAML if preffered. You can convert here: ## You can also use JSON or YAML if preferred. You can convert here:
## - YAML: https://it-tools.tech/toml-to-yaml ## - YAML: https://it-tools.tech/toml-to-yaml
## - JSON: https://it-tools.tech/toml-to-json ## - JSON: https://it-tools.tech/toml-to-json

View File

@@ -16,7 +16,7 @@
## Most fields can also be configured using environment variables. ## Most fields can also be configured using environment variables.
## Environment variables will override values set in this file. ## Environment variables will override values set in this file.
## You can also use JSON or YAML if preffered. You can convert here: ## You can also use JSON or YAML if preferred. You can convert here:
## - YAML: https://it-tools.tech/toml-to-yaml ## - YAML: https://it-tools.tech/toml-to-yaml
## - JSON: https://it-tools.tech/toml-to-json ## - JSON: https://it-tools.tech/toml-to-json

View File

@@ -5,10 +5,12 @@ To run Komodo, you will need Docker. See [the docker install docs](https://docs.
### Deploy with Docker Compose ### Deploy with Docker Compose
- [**Using MongoDB**](./mongo.mdx) - [**Using MongoDB**](./mongo.mdx)
- Lower CPU usage, Higher RAM usage.
- Some systems [do not support running the latest MongoDB versions](https://github.com/moghtech/komodo/issues/59).
- [**Using FerretDB** (Postgres)](./ferretdb.mdx) - [**Using FerretDB** (Postgres)](./ferretdb.mdx)
- Lower RAM usage, Higher CPU usage.
:::info
Some systems [do not support running the latest MongoDB versions](https://github.com/moghtech/komodo/issues/59).
Users with these systems should use FerretDB instead.
:::
:::info :::info
**FerretDB v1** users: **FerretDB v1** users:

View File

@@ -170,6 +170,7 @@ interface SectionProps {
actions?: ReactNode; actions?: ReactNode;
// otherwise items-start // otherwise items-start
itemsCenterTitleRow?: boolean; itemsCenterTitleRow?: boolean;
className?: string;
} }
export const Section = ({ export const Section = ({
@@ -180,8 +181,9 @@ export const Section = ({
actions, actions,
children, children,
itemsCenterTitleRow, itemsCenterTitleRow,
className,
}: SectionProps) => ( }: SectionProps) => (
<div className="flex flex-col gap-4"> <div className={cn("flex flex-col gap-4", className)}>
{(title || icon || titleRight || titleOther || actions) && ( {(title || icon || titleRight || titleOther || actions) && (
<div <div
className={cn( className={cn(
@@ -222,6 +224,7 @@ export const NewLayout = ({
}) => { }) => {
const [open, set] = useState(false); const [open, set] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -248,9 +251,14 @@ export const NewLayout = ({
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
await onConfirm(); try {
setLoading(false); await onConfirm();
set(false); set(false);
} catch (error) {
console.error("Error creating resource:", error);
} finally {
setLoading(false);
}
}} }}
disabled={!enabled || loading} disabled={!enabled || loading}
> >

View File

@@ -172,7 +172,7 @@ export const MonacoEditor = ({
language={language} language={language}
value={value} value={value}
theme={theme} theme={theme}
defaultPath={filename ? `file:///${filename}` : undefined} defaultPath={defaultPath(filename)}
options={options} options={options}
onChange={(v) => onValueChange?.(v ?? "")} onChange={(v) => onValueChange?.(v ?? "")}
onMount={(editor) => setEditor(editor)} onMount={(editor) => setEditor(editor)}
@@ -181,6 +181,14 @@ export const MonacoEditor = ({
); );
}; };
const defaultPath = (filename?: string) => {
if (!filename) return undefined;
// Extract only the filename part of path,
// avoiding critical issue when path starts with '/'
const split = filename.split("/");
return split[split.length - 1];
};
const MIN_DIFF_HEIGHT = 100; const MIN_DIFF_HEIGHT = 100;
const MAX_DIFF_HEIGHT = 400; const MAX_DIFF_HEIGHT = 400;

View File

@@ -7,7 +7,7 @@ import { useExecute, useInvalidate, useRead, useWrite } from "@lib/hooks";
import { file_contents_empty, sync_no_changes } from "@lib/utils"; import { file_contents_empty, sync_no_changes } from "@lib/utils";
import { usePermissions } from "@lib/hooks"; import { usePermissions } from "@lib/hooks";
import { NotebookPen, RefreshCcw, SquarePlay } from "lucide-react"; import { NotebookPen, RefreshCcw, SquarePlay } from "lucide-react";
import { useFullResourceSync, usePendingView } from "."; import { useFullResourceSync, useResourceSyncTabsView } from ".";
export const RefreshSync = ({ id }: { id: string }) => { export const RefreshSync = ({ id }: { id: string }) => {
const inv = useInvalidate(); const inv = useInvalidate();
@@ -34,11 +34,10 @@ export const ExecuteSync = ({ id }: { id: string }) => {
{ refetchInterval: 5000 } { refetchInterval: 5000 }
).data?.syncing; ).data?.syncing;
const sync = useFullResourceSync(id); const sync = useFullResourceSync(id);
const [_pendingView] = usePendingView(); const { view } = useResourceSyncTabsView(sync);
const pendingView = sync?.config?.managed ? _pendingView : "Execute";
if ( if (
pendingView === "Commit" || view !== "Execute" ||
!sync || !sync ||
sync_no_changes(sync) || sync_no_changes(sync) ||
!sync.info?.remote_contents !sync.info?.remote_contents
@@ -73,11 +72,12 @@ export const ExecuteSync = ({ id }: { id: string }) => {
export const CommitSync = ({ id }: { id: string }) => { export const CommitSync = ({ id }: { id: string }) => {
const { mutate, isPending } = useWrite("CommitSync"); const { mutate, isPending } = useWrite("CommitSync");
const sync = useFullResourceSync(id); const sync = useFullResourceSync(id);
const { view } = useResourceSyncTabsView(sync);
const { canWrite } = usePermissions({ type: "ResourceSync", id }); const { canWrite } = usePermissions({ type: "ResourceSync", id });
const [_pendingView] = usePendingView();
const pendingView = sync?.config?.managed ? _pendingView : "Execute";
if (pendingView === "Execute" || !canWrite || !sync) return null; if (view !== "Commit" || !canWrite || !sync) {
return null;
}
const freshSync = const freshSync =
!sync.config?.files_on_host && !sync.config?.files_on_host &&

View File

@@ -1,4 +1,4 @@
import { atomWithStorage, useLocalStorage, useRead, useUser } from "@lib/hooks"; import { atomWithStorage, useRead, useUser } from "@lib/hooks";
import { RequiredResourceComponents } from "@types"; import { RequiredResourceComponents } from "@types";
import { Card } from "@ui/card"; import { Card } from "@ui/card";
import { Clock, FolderSync } from "lucide-react"; import { Clock, FolderSync } from "lucide-react";
@@ -45,23 +45,16 @@ const ResourceSyncIcon = ({ id, size }: { id?: string; size: number }) => {
return <FolderSync className={cn(`w-${size} h-${size}`, state && color)} />; return <FolderSync className={cn(`w-${size} h-${size}`, state && color)} />;
}; };
const pendingViewAtom = atomWithStorage<"Execute" | "Commit">( type ResourceSyncTabsView = "Config" | "Info" | "Execute" | "Commit";
"sync-view-v1", const syncTabsViewAtom = atomWithStorage<ResourceSyncTabsView>(
"Execute" "sync-tabs-v4",
"Config"
); );
export const usePendingView = () => {
return useAtom(pendingViewAtom) as [
"Execute" | "Commit",
(view: "Execute" | "Commit") => void,
];
};
const ConfigInfoPending = ({ id }: { id: string }) => { export const useResourceSyncTabsView = (
const [_view, setView] = useLocalStorage<"Config" | "Info" | "Pending">( sync: Types.ResourceSync | undefined
"sync-tabs-v3", ) => {
"Config" const [_view, setView] = useAtom<ResourceSyncTabsView>(syncTabsViewAtom);
);
const sync = useFullResourceSync(id);
const hideInfo = sync?.config?.files_on_host const hideInfo = sync?.config?.files_on_host
? false ? false
@@ -75,13 +68,28 @@ const ConfigInfoPending = ({ id }: { id: string }) => {
const view = const view =
_view === "Info" && hideInfo _view === "Info" && hideInfo
? "Config" ? "Config"
: _view === "Pending" && !showPending : (_view === "Execute" || _view === "Commit") && !showPending
? sync?.config?.files_on_host || ? sync?.config?.files_on_host ||
sync?.config?.repo || sync?.config?.repo ||
sync?.config?.linked_repo sync?.config?.linked_repo
? "Info" ? "Info"
: "Config" : "Config"
: _view; : _view === "Commit" && !sync?.config?.managed
? "Execute"
: _view;
return {
view,
setView,
hideInfo,
showPending,
};
};
const ConfigInfoPending = ({ id }: { id: string }) => {
const sync = useFullResourceSync(id);
const { view, setView, hideInfo, showPending } =
useResourceSyncTabsView(sync);
const title = ( const title = (
<TabsList className="justify-start w-fit"> <TabsList className="justify-start w-fit">
@@ -96,12 +104,21 @@ const ConfigInfoPending = ({ id }: { id: string }) => {
Info Info
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="Pending" value="Execute"
className="w-[110px]" className="w-[110px]"
disabled={!showPending} disabled={!showPending}
> >
Pending Execute
</TabsTrigger> </TabsTrigger>
{sync?.config?.managed && (
<TabsTrigger
value="Commit"
className="w-[110px]"
disabled={!showPending}
>
Commit
</TabsTrigger>
)}
</TabsList> </TabsList>
); );
return ( return (
@@ -112,7 +129,10 @@ const ConfigInfoPending = ({ id }: { id: string }) => {
<TabsContent value="Info"> <TabsContent value="Info">
<ResourceSyncInfo id={id} titleOther={title} /> <ResourceSyncInfo id={id} titleOther={title} />
</TabsContent> </TabsContent>
<TabsContent value="Pending"> <TabsContent value="Execute">
<ResourceSyncPending id={id} titleOther={title} />
</TabsContent>
<TabsContent value="Commit">
<ResourceSyncPending id={id} titleOther={title} /> <ResourceSyncPending id={id} titleOther={title} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -164,7 +184,7 @@ export const ResourceSyncComponents: RequiredResourceComponents = {
}, },
GroupActions: () => ( GroupActions: () => (
<GroupActions type="ResourceSync" actions={["RunSync"]} /> <GroupActions type="ResourceSync" actions={["RunSync", "CommitSync"]} />
), ),
Table: ({ resources }) => ( Table: ({ resources }) => (

View File

@@ -10,8 +10,7 @@ import { cn, sanitizeOnlySpan } from "@lib/utils";
import { ConfirmButton } from "@components/util"; import { ConfirmButton } from "@components/util";
import { SquarePlay } from "lucide-react"; import { SquarePlay } from "lucide-react";
import { usePermissions } from "@lib/hooks"; import { usePermissions } from "@lib/hooks";
import { useFullResourceSync, usePendingView } from "."; import { useFullResourceSync, useResourceSyncTabsView } from ".";
import { Tabs, TabsList, TabsTrigger } from "@ui/tabs";
import { ResourceDiff } from "komodo_client/dist/types"; import { ResourceDiff } from "komodo_client/dist/types";
export const ResourceSyncPending = ({ export const ResourceSyncPending = ({
@@ -24,31 +23,16 @@ export const ResourceSyncPending = ({
const syncing = useRead("GetResourceSyncActionState", { sync: id }).data const syncing = useRead("GetResourceSyncActionState", { sync: id }).data
?.syncing; ?.syncing;
const sync = useFullResourceSync(id); const sync = useFullResourceSync(id);
const { view } = useResourceSyncTabsView(sync);
const { canExecute } = usePermissions({ type: "ResourceSync", id }); const { canExecute } = usePermissions({ type: "ResourceSync", id });
const [_pendingView, setPendingView] = usePendingView();
const pendingView = sync?.config?.managed ? _pendingView : "Execute";
const { mutate, isPending } = useExecute("RunSync"); const { mutate, isPending } = useExecute("RunSync");
const loading = isPending || syncing; const loading = isPending || syncing;
return ( return (
<Section <Section titleOther={titleOther} className="min-h-[500px]">
titleOther={titleOther} <div className="flex items-center gap-4 pl-1 py-2 flex-wrap">
> <div className="text-muted-foreground">{view} Mode:</div>
<div className="flex items-center gap-4 py-2 flex-wrap">
{sync?.config?.managed && (
<Tabs value={pendingView} onValueChange={setPendingView as any}>
<TabsList className="justify-start w-fit">
<TabsTrigger value="Execute" className="w-[110px]">
Execute
</TabsTrigger>
<TabsTrigger value="Commit" className="w-[110px]">
Commit
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="text-muted-foreground">{pendingView} Mode:</div>
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
{pendingView === "Execute" && ( {view === "Execute" && (
<> <>
Update resources in the Update resources in the
<div className="font-bold">UI</div> <div className="font-bold">UI</div>
@@ -56,7 +40,7 @@ export const ResourceSyncPending = ({
<div className="font-bold">file changes.</div> <div className="font-bold">file changes.</div>
</> </>
)} )}
{pendingView === "Commit" && ( {view === "Commit" && (
<> <>
Update resources in the Update resources in the
<div className="font-bold">file</div> <div className="font-bold">file</div>
@@ -89,7 +73,7 @@ export const ResourceSyncPending = ({
) : undefined} ) : undefined}
{/* Pending Deploy */} {/* Pending Deploy */}
{pendingView === "Execute" && sync?.info?.pending_deploy?.to_deploy ? ( {view === "Execute" && sync?.info?.pending_deploy?.to_deploy ? (
<Card> <Card>
<CardHeader <CardHeader
className={cn( className={cn(
@@ -118,13 +102,10 @@ export const ResourceSyncPending = ({
<div className="flex items-center gap-4 font-mono"> <div className="flex items-center gap-4 font-mono">
<div <div
className={text_color_class_by_intention( className={text_color_class_by_intention(
diff_type_intention( diff_type_intention(update.data.type, view === "Commit")
update.data.type,
pendingView === "Commit"
)
)} )}
> >
{pendingView === "Commit" {view === "Commit"
? reverse_pending_type(update.data.type) ? reverse_pending_type(update.data.type)
: update.data.type}{" "} : update.data.type}{" "}
{update.target.type} {update.target.type}
@@ -139,7 +120,7 @@ export const ResourceSyncPending = ({
/> />
)} )}
</div> </div>
{canExecute && pendingView === "Execute" && ( {canExecute && view === "Execute" && (
<ConfirmButton <ConfirmButton
title="Execute Change" title="Execute Change"
icon={<SquarePlay className="w-4 h-4" />} icon={<SquarePlay className="w-4 h-4" />}
@@ -168,7 +149,7 @@ export const ResourceSyncPending = ({
)} )}
{update.data.type === "Update" && ( {update.data.type === "Update" && (
<> <>
{pendingView === "Execute" && ( {view === "Execute" && (
<MonacoDiffEditor <MonacoDiffEditor
original={update.data.data.current} original={update.data.data.current}
modified={update.data.data.proposed} modified={update.data.data.proposed}
@@ -176,7 +157,7 @@ export const ResourceSyncPending = ({
readOnly readOnly
/> />
)} )}
{pendingView === "Commit" && ( {view === "Commit" && (
<MonacoDiffEditor <MonacoDiffEditor
original={update.data.data.proposed} original={update.data.data.proposed}
modified={update.data.data.current} modified={update.data.data.current}
@@ -205,13 +186,11 @@ export const ResourceSyncPending = ({
className={cn( className={cn(
"font-mono pb-2", "font-mono pb-2",
text_color_class_by_intention( text_color_class_by_intention(
diff_type_intention(data.type, pendingView === "Commit") diff_type_intention(data.type, view === "Commit")
) )
)} )}
> >
{pendingView === "Commit" {view === "Commit" ? reverse_pending_type(data.type) : data.type}{" "}
? reverse_pending_type(data.type)
: data.type}{" "}
Variable Variable
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -224,7 +203,7 @@ export const ResourceSyncPending = ({
)} )}
{data.type === "Update" && ( {data.type === "Update" && (
<> <>
{pendingView === "Execute" && ( {view === "Execute" && (
<MonacoDiffEditor <MonacoDiffEditor
original={data.data.current} original={data.data.current}
modified={data.data.proposed} modified={data.data.proposed}
@@ -232,7 +211,7 @@ export const ResourceSyncPending = ({
readOnly readOnly
/> />
)} )}
{pendingView === "Commit" && ( {view === "Commit" && (
<MonacoDiffEditor <MonacoDiffEditor
original={data.data.proposed} original={data.data.proposed}
modified={data.data.current} modified={data.data.current}
@@ -261,13 +240,11 @@ export const ResourceSyncPending = ({
className={cn( className={cn(
"font-mono pb-2", "font-mono pb-2",
text_color_class_by_intention( text_color_class_by_intention(
diff_type_intention(data.type, pendingView === "Commit") diff_type_intention(data.type, view === "Commit")
) )
)} )}
> >
{pendingView === "Commit" {view === "Commit" ? reverse_pending_type(data.type) : data.type}{" "}
? reverse_pending_type(data.type)
: data.type}{" "}
User Group User Group
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -280,7 +257,7 @@ export const ResourceSyncPending = ({
)} )}
{data.type === "Update" && ( {data.type === "Update" && (
<> <>
{pendingView === "Execute" && ( {view === "Execute" && (
<MonacoDiffEditor <MonacoDiffEditor
original={data.data.current} original={data.data.current}
modified={data.data.proposed} modified={data.data.proposed}
@@ -288,7 +265,7 @@ export const ResourceSyncPending = ({
readOnly readOnly
/> />
)} )}
{pendingView === "Commit" && ( {view === "Commit" && (
<MonacoDiffEditor <MonacoDiffEditor
original={data.data.proposed} original={data.data.proposed}
modified={data.data.current} modified={data.data.current}

View File

@@ -3,13 +3,19 @@ import { useRead } from "@lib/hooks";
import { Types } from "komodo_client"; import { Types } from "komodo_client";
import { useMemo } from "react"; import { useMemo } from "react";
import { useStatsGranularity } from "./hooks"; import { useStatsGranularity } from "./hooks";
import { Loader2 } from "lucide-react"; import { Loader2, OctagonAlert } from "lucide-react";
import { AxisOptions, Chart } from "react-charts"; import { AxisOptions, Chart } from "react-charts";
import { convertTsMsToLocalUnixTsInMs } from "@lib/utils"; import { convertTsMsToLocalUnixTsInMs } from "@lib/utils";
import { useTheme } from "@ui/theme"; import { useTheme } from "@ui/theme";
import { fmt_utc_date } from "@lib/formatting"; import { fmt_utc_date } from "@lib/formatting";
type StatType = "Cpu" | "Memory" | "Disk" | "Network Ingress" | "Network Egress" | "Load Average"; type StatType =
| "Cpu"
| "Memory"
| "Disk"
| "Network Ingress"
| "Network Egress"
| "Load Average";
type StatDatapoint = { date: number; value: number }; type StatDatapoint = { date: number; value: number };
@@ -35,15 +41,15 @@ export const StatChart = ({
if (type === "Load Average") { if (type === "Load Average") {
const one = records.map((s) => ({ const one = records.map((s) => ({
date: convertTsMsToLocalUnixTsInMs(s.ts), date: convertTsMsToLocalUnixTsInMs(s.ts),
value: (s.load_average?.one ?? 0), value: s.load_average?.one ?? 0,
})); }));
const five = records.map((s) => ({ const five = records.map((s) => ({
date: convertTsMsToLocalUnixTsInMs(s.ts), date: convertTsMsToLocalUnixTsInMs(s.ts),
value: (s.load_average?.five ?? 0), value: s.load_average?.five ?? 0,
})); }));
const fifteen = records.map((s) => ({ const fifteen = records.map((s) => ({
date: convertTsMsToLocalUnixTsInMs(s.ts), date: convertTsMsToLocalUnixTsInMs(s.ts),
value: (s.load_average?.fifteen ?? 0), value: s.load_average?.fifteen ?? 0,
})); }));
return [ return [
{ label: "1m", data: one }, { label: "1m", data: one },
@@ -65,9 +71,13 @@ export const StatChart = ({
<div className="w-full max-w-full h-full flex items-center justify-center"> <div className="w-full max-w-full h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin" /> <Loader2 className="w-8 h-8 animate-spin" />
</div> </div>
) : seriesData.length > 0 ? ( ) : (
<InnerStatChart type={type} stats={seriesData.flatMap((s) => s.data)} seriesData={seriesData} /> <InnerStatChart
) : null} type={type}
stats={seriesData.flatMap((s) => s.data)}
seriesData={seriesData}
/>
)}
</div> </div>
); );
}; };
@@ -113,10 +123,12 @@ export const InnerStatChart = ({
cursor: (_value?: Date) => false, cursor: (_value?: Date) => false,
}, },
}; };
}, []); }, [min, max, diff]);
// Determine the dynamic scaling for network-related types // Determine the dynamic scaling for network-related types
const allValues = (seriesData ?? [{ data: stats ?? [] }]).flatMap((s) => s.data.map((d) => d.value)); const allValues = (seriesData ?? [{ data: stats ?? [] }]).flatMap((s) =>
s.data.map((d) => d.value)
);
const maxStatValue = Math.max(...(allValues.length ? allValues : [0])); const maxStatValue = Math.max(...(allValues.length ? allValues : [0]));
const { unit, maxUnitValue } = useMemo(() => { const { unit, maxUnitValue } = useMemo(() => {
@@ -133,7 +145,10 @@ export const InnerStatChart = ({
} }
if (type === "Load Average") { if (type === "Load Average") {
// Leave unitless; set max slightly above observed // Leave unitless; set max slightly above observed
return { unit: "", maxUnitValue: maxStatValue === 0 ? 1 : maxStatValue * 1.2 }; return {
unit: "",
maxUnitValue: maxStatValue === 0 ? 1 : maxStatValue * 1.2,
};
} }
return { unit: "", maxUnitValue: 100 }; // Default for CPU, memory, disk return { unit: "", maxUnitValue: 100 }; // Default for CPU, memory, disk
}, [type, maxStatValue]); }, [type, maxStatValue]);
@@ -161,6 +176,16 @@ export const InnerStatChart = ({
], ],
[type, maxUnitValue, unit] [type, maxUnitValue, unit]
); );
if ((seriesData?.[0]?.data.length ?? 0) < 2) {
return (
<div className="w-full h-full flex gap-4 justify-center items-center">
<OctagonAlert className="w-6 h-6" />
<h1>Not enough data yet, choose a smaller interval.</h1>
</div>
);
}
return ( return (
<Chart <Chart
options={{ options={{

View File

@@ -1,13 +1,7 @@
import { Section } from "@components/layouts"; import { Section } from "@components/layouts";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@ui/card";
import { Progress } from "@ui/progress"; import { Progress } from "@ui/progress";
import { import { Cpu, Database, Loader2, MemoryStick, Search } from "lucide-react";
Cpu,
Database,
Loader2,
MemoryStick,
Search,
} from "lucide-react";
import { useLocalStorage, usePermissions, useRead } from "@lib/hooks"; import { useLocalStorage, usePermissions, useRead } from "@lib/hooks";
import { Types } from "komodo_client"; import { Types } from "komodo_client";
import { DataTable, SortableHeader } from "@ui/data-table"; import { DataTable, SortableHeader } from "@ui/data-table";
@@ -336,6 +330,11 @@ export const ServerStats = ({
} }
> >
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<StatChart
server_id={id}
type="Load Average"
className="w-full h-[250px]"
/>
<StatChart server_id={id} type="Cpu" className="w-full h-[250px]" /> <StatChart server_id={id} type="Cpu" className="w-full h-[250px]" />
<StatChart <StatChart
server_id={id} server_id={id}
@@ -347,11 +346,6 @@ export const ServerStats = ({
type="Disk" type="Disk"
className="w-full h-[250px]" className="w-full h-[250px]"
/> />
<StatChart
server_id={id}
type="Load Average"
className="w-full h-[250px]"
/>
<StatChart <StatChart
server_id={id} server_id={id}
type="Network Ingress" type="Network Ingress"
@@ -501,16 +495,28 @@ const CPU = ({ stats }: { stats: Types.SystemStats | undefined }) => {
); );
}; };
const LOAD_AVERAGE = ({ id, stats }: { id: string; stats: Types.SystemStats | undefined }) => { const LOAD_AVERAGE = ({
id,
stats,
}: {
id: string;
stats: Types.SystemStats | undefined;
}) => {
if (!stats?.load_average) return null; if (!stats?.load_average) return null;
const { one = 0, five = 0, fifteen = 0 } = stats.load_average || {}; const { one = 0, five = 0, fifteen = 0 } = stats.load_average || {};
const cores = useRead("GetSystemInformation", { server: id }).data?.core_count; const cores = useRead("GetSystemInformation", { server: id }).data
?.core_count;
const pct = (load: number) => (cores && cores > 0) ? Math.min((load / cores) * 100, 100) : undefined; const pct = (load: number) =>
cores && cores > 0 ? Math.min((load / cores) * 100, 100) : undefined;
const textColor = (load: number) => { const textColor = (load: number) => {
const p = pct(load); const p = pct(load);
if (p === undefined) return "text-muted-foreground"; if (p === undefined) return "text-muted-foreground";
return p <= 50 ? "text-green-600" : p <= 80 ? "text-yellow-600" : "text-red-600"; return p <= 50
? "text-green-600"
: p <= 80
? "text-yellow-600"
: "text-red-600";
}; };
return ( return (
@@ -524,15 +530,18 @@ const LOAD_AVERAGE = ({ id, stats }: { id: string; stats: Types.SystemStats | un
{/* Current Load */} {/* Current Load */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className={`text-3xl font-bold tabular-nums ${textColor(one)}`}>{one.toFixed(2)}</span> <span
className={`text-3xl font-bold tabular-nums ${textColor(one)}`}
>
{one.toFixed(2)}
</span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{cores && cores > 0 ? `${(pct(one) ?? 0).toFixed(0)}% of ${cores} cores` : "N/A"} {cores && cores > 0
? `${(pct(one) ?? 0).toFixed(0)}% of ${cores} cores`
: "N/A"}
</span> </span>
</div> </div>
<Progress <Progress value={pct(one) ?? 0} className="h-2" />
value={pct(one) ?? 0}
className="h-2"
/>
</div> </div>
{/* Time Intervals */} {/* Time Intervals */}
@@ -546,14 +555,13 @@ const LOAD_AVERAGE = ({ id, stats }: { id: string; stats: Types.SystemStats | un
<div className="space-y-1" key={label as string}> <div className="space-y-1" key={label as string}>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-muted-foreground">{label}</span> <span className="text-muted-foreground">{label}</span>
<span className={`font-medium tabular-nums ${textColor(value as number)}`}> <span
className={`font-medium tabular-nums ${textColor(value as number)}`}
>
{(value as number).toFixed(2)} {(value as number).toFixed(2)}
</span> </span>
</div> </div>
<Progress <Progress value={pct(value as number) ?? 0} className="h-1" />
value={(pct(value as number) ?? 0)}
className="h-1"
/>
</div> </div>
))} ))}
</div> </div>

View File

@@ -805,3 +805,24 @@ export const useContainerPortsMap = (ports: Types.Port[]) => {
return map; return map;
}, [ports]); }, [ports]);
}; };
/**
* A custom React hook that debounces a value, delaying its update until after
* a specified period of inactivity. This is useful for performance optimization
* in scenarios like search inputs, form validation, or API calls.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -6,33 +6,66 @@ import {
StatusBadge, StatusBadge,
} from "@components/util"; } from "@components/util";
import { container_state_intention } from "@lib/color"; import { container_state_intention } from "@lib/color";
import { useRead } from "@lib/hooks"; import { useDebounce, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table"; import { DataTable, SortableHeader } from "@ui/data-table";
import { Input } from "@ui/input"; import { Input } from "@ui/input";
import { Box, Search } from "lucide-react"; import { MultiSelect } from "@ui/multi-select";
import { Box, Search, RotateCcw } from "lucide-react";
import { Button } from "@ui/button";
import { Fragment, useCallback, useMemo, useState } from "react"; import { Fragment, useCallback, useMemo, useState } from "react";
export default function ContainersPage() { export default function ContainersPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const searchSplit = search const [selectedServers, setSelectedServers] = useState<string[]>([]);
const debouncedSearch = useDebounce(search, 300);
const searchSplit = debouncedSearch
.toLowerCase() .toLowerCase()
.split(" ") .split(" ")
.filter((term) => term); .filter((term) => term);
const servers = useRead("ListServers", {}).data; const servers = useRead("ListServers", {}).data;
const serverOptions = useMemo(
() =>
servers?.map((server) => ({
label: server.name,
value: server.id,
})) || [],
[servers]
);
const serverName = useCallback( const serverName = useCallback(
(id: string) => servers?.find((server) => server.id === id)?.name, (id: string) => servers?.find((server) => server.id === id)?.name,
[servers] [servers]
); );
const _containers = useRead("ListAllDockerContainers", {}).data; const _containers = useRead("ListAllDockerContainers", {}).data;
const containers = useMemo( const containers = useMemo(
() => () =>
_containers?.filter((c) => { _containers?.filter((c) => {
if (searchSplit.length === 0) return true; if (searchSplit.length > 0) {
const lower = c.name.toLowerCase(); const lower = c.name.toLowerCase();
return searchSplit.every((search) => lower.includes(search)); const searchMatch = searchSplit.every((search) =>
lower.includes(search)
);
if (!searchMatch) return false;
}
if (selectedServers.length > 0) {
return selectedServers.includes(c.server_id!);
}
return true;
}), }),
[_containers, searchSplit] [_containers, searchSplit, selectedServers]
); );
const clearAllServers = useCallback(() => {
setSelectedServers([]);
}, []);
return ( return (
<Page <Page
title="Containers" title="Containers"
@@ -44,18 +77,45 @@ export default function ContainersPage() {
icon={<Box className="w-8 h-8" />} icon={<Box className="w-8 h-8" />}
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<div></div> <div className="flex items-center gap-2 flex-wrap">
{/* Server Filter Multi-Select */}
<div className="w-[280px]">
<MultiSelect
options={serverOptions}
value={selectedServers}
onChange={setSelectedServers}
placeholder="Filter by server..."
className="w-full h-10"
/>
</div>
{/* Reset Server Filter Button */}
{selectedServers.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={clearAllServers}
className="h-10 px-3"
>
<RotateCcw className="w-4 h-4 mr-1" />
Reset
</Button>
)}
</div>
{/* Search Input */}
<div className="relative"> <div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" /> <Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input <Input
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="search..." placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]" className="pl-8 w-[200px] lg:w-[300px] py-0 h-10"
/> />
</div> </div>
</div> </div>
<DataTable <DataTable
data={containers ?? []} data={containers ?? []}
tableKey="containers-page-v1" tableKey="containers-page-v1"
@@ -189,29 +249,6 @@ export default function ContainersPage() {
/> />
), ),
}, },
// {
// accessorKey: "volumes.0",
// minSize: 300,
// header: ({ column }) => (
// <SortableHeader column={column} title="Volumes" />
// ),
// cell: ({ row }) => (
// <div className="flex items-center gap-x-2 flex-wrap">
// {row.original.volumes.map((volume, i) => (
// <Fragment key={volume}>
// <DockerResourceLink
// type="volume"
// server_id={row.original.server_id!}
// name={volume}
// />
// {i !== row.original.volumes.length - 1 && (
// <div className="text-muted-foreground">|</div>
// )}
// </Fragment>
// ))}
// </div>
// ),
// },
]} ]}
/> />
</div> </div>

View File

@@ -15,7 +15,7 @@ import {
useLoginOptions, useLoginOptions,
useUserInvalidate, useUserInvalidate,
} from "@lib/hooks"; } from "@lib/hooks";
import { type FormEvent } from "react"; import { useRef } from "react";
import { ThemeToggle } from "@ui/theme"; import { ThemeToggle } from "@ui/theme";
import { KOMODO_BASE_URL } from "@main"; import { KOMODO_BASE_URL } from "@main";
import { KeyRound, X } from "lucide-react"; import { KeyRound, X } from "lucide-react";
@@ -40,6 +40,7 @@ export default function Login() {
const options = useLoginOptions().data; const options = useLoginOptions().data;
const userInvalidate = useUserInvalidate(); const userInvalidate = useUserInvalidate();
const { toast } = useToast(); const { toast } = useToast();
const formRef = useRef<HTMLFormElement>(null);
// If signing in another user, need to redirect away from /login manually // If signing in another user, need to redirect away from /login manually
const maybeNavigate = location.pathname.startsWith("/login") const maybeNavigate = location.pathname.startsWith("/login")
@@ -63,13 +64,13 @@ export default function Login() {
const message = e?.response?.data?.error as string | undefined; const message = e?.response?.data?.error as string | undefined;
if (message) { if (message) {
toast({ toast({
title: `Failed to login user. '${message}'`, title: `Failed to sign up user. '${message}'`,
variant: "destructive", variant: "destructive",
}); });
console.error(e); console.error(e);
} else { } else {
toast({ toast({
title: "Failed to login user. See console log for details.", title: "Failed to sign up user. See console log for details.",
variant: "destructive", variant: "destructive",
}); });
console.error(e); console.error(e);
@@ -97,17 +98,24 @@ export default function Login() {
}, },
}); });
const handleSubmit = (e: FormEvent<HTMLFormElement>) => { const getFormCredentials = () => {
e.preventDefault(); if (!formRef.current) return undefined;
const fd = new FormData(e.currentTarget); const fd = new FormData(formRef.current);
const username = String(fd.get("username") ?? ""); const username = String(fd.get("username") ?? "");
const password = String(fd.get("password") ?? ""); const password = String(fd.get("password") ?? "");
const action = String(fd.get("action") ?? "login"); return { username, password };
if (action === "signup") { };
signup({ username, password });
} else { const handleLogin = () => {
login({ username, password }); const creds = getFormCredentials();
} if (!creds) return;
login(creds);
};
const handleSignUp = () => {
const creds = getFormCredentials();
if (!creds) return;
signup(creds);
}; };
const no_auth_configured = const no_auth_configured =
@@ -176,7 +184,7 @@ export default function Login() {
</CardHeader> </CardHeader>
{options?.local && ( {options?.local && (
<form <form
onSubmit={handleSubmit} ref={formRef}
autoComplete="on" autoComplete="on"
> >
<CardContent className="flex flex-col justify-center w-full gap-4"> <CardContent className="flex flex-col justify-center w-full gap-4">
@@ -204,9 +212,9 @@ export default function Login() {
{show_sign_up && ( {show_sign_up && (
<Button <Button
variant="outline" variant="outline"
type="submit" type="button"
name="action"
value="signup" value="signup"
onClick={handleSignUp}
disabled={signupPending} disabled={signupPending}
> >
Sign Up Sign Up
@@ -214,9 +222,9 @@ export default function Login() {
)} )}
<Button <Button
variant="default" variant="default"
type="submit" type="button"
name="action"
value="login" value="login"
onClick={handleLogin}
disabled={loginPending} disabled={loginPending}
> >
Log In Log In

View File

@@ -3,7 +3,7 @@ import { LOGIN_TOKENS, useAuth, useUser } from "@lib/hooks";
import UpdatePage from "@pages/update"; import UpdatePage from "@pages/update";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
// Lazy import pages // Lazy import pages
const Resources = lazy(() => import("@pages/resources")); const Resources = lazy(() => import("@pages/resources"));
@@ -63,8 +63,6 @@ const useExchangeToken = () => {
}; };
export const Router = () => { export const Router = () => {
const { data: user, error } = useUser();
// Handle exchange token loop to avoid showing login flash // Handle exchange token loop to avoid showing login flash
const exchangeTokenPending = useExchangeToken(); const exchangeTokenPending = useExchangeToken();
if (exchangeTokenPending) { if (exchangeTokenPending) {
@@ -75,13 +73,6 @@ export const Router = () => {
); );
} }
// Only how login once error indicating logged out state actually recieved
if (error) return <Login />;
// Don't display anything if !error and !user. This is loading state.
if (!user) return null;
// Don't try displaying pages if user disabled, will fail to load with many errors.
if (!user.enabled) return <UserDisabled />;
return ( return (
<Suspense <Suspense
fallback={ fallback={
@@ -93,34 +84,36 @@ export const Router = () => {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="login" element={<Login />} /> <Route path="login" element={<Login />} />
<Route path="/" element={<Layout />}> <Route element={<RequireAuth />}>
<Route path="" element={<Home />} /> <Route path="/" element={<Layout />}>
<Route path="settings" element={<Settings />} /> <Route path="" element={<Home />} />
<Route path="tree" element={<Tree />} /> <Route path="settings" element={<Settings />} />
<Route path="containers" element={<ContainersPage />} /> <Route path="tree" element={<Tree />} />
<Route path="resources" element={<AllResources />} /> <Route path="containers" element={<ContainersPage />} />
<Route path="schedules" element={<SchedulesPage />} /> <Route path="resources" element={<AllResources />} />
<Route path="alerts" element={<AlertsPage />} /> <Route path="schedules" element={<SchedulesPage />} />
<Route path="user-groups/:id" element={<UserGroupPage />} /> <Route path="alerts" element={<AlertsPage />} />
<Route path="users/:id" element={<UserPage />} /> <Route path="user-groups/:id" element={<UserGroupPage />} />
<Route path="updates"> <Route path="users/:id" element={<UserPage />} />
<Route path="" element={<UpdatesPage />} /> <Route path="updates">
<Route path=":id" element={<UpdatePage />} /> <Route path="" element={<UpdatesPage />} />
</Route> <Route path=":id" element={<UpdatePage />} />
<Route path=":type"> </Route>
<Route path="" element={<Resources />} /> <Route path=":type">
<Route path=":id" element={<Resource />} /> <Route path="" element={<Resources />} />
<Route <Route path=":id" element={<Resource />} />
path=":id/service/:service" <Route
element={<StackServicePage />} path=":id/service/:service"
/> element={<StackServicePage />}
<Route />
path=":id/container/:container" <Route
element={<ContainerPage />} path=":id/container/:container"
/> element={<ContainerPage />}
<Route path=":id/network/:network" element={<NetworkPage />} /> />
<Route path=":id/image/:image" element={<ImagePage />} /> <Route path=":id/network/:network" element={<NetworkPage />} />
<Route path=":id/volume/:volume" element={<VolumePage />} /> <Route path=":id/image/:image" element={<ImagePage />} />
<Route path=":id/volume/:volume" element={<VolumePage />} />
</Route>
</Route> </Route>
</Route> </Route>
</Routes> </Routes>
@@ -131,4 +124,29 @@ export const Router = () => {
// return <RouterProvider router={ROUTER} />; // return <RouterProvider router={ROUTER} />;
}; };
const RequireAuth = () => {
const { data: user, error } = useUser();
const location = useLocation();
if (error) {
if (location.pathname === "/") {
return <Navigate to="/login" replace />;
}
const backto = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?backto=${backto}`} replace />;
}
if (!user) {
return (
<div className="w-screen h-screen flex justify-center items-center">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!user.enabled) return <UserDisabled />;
return <Outlet />;
};

View File

@@ -0,0 +1,161 @@
import * as React from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover";
import { Skeleton } from "@/ui/skeleton";
interface MultiSelectProps {
options?: { label: string; value: string }[];
value: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
className?: string;
isLoading?: boolean;
disabled?: boolean;
}
function MultiSelect({
options,
value,
onChange,
placeholder = "Select items...",
className,
isLoading = false,
disabled = false,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const handleUnselect = (item: string) => {
onChange(value.filter((i) => i !== item));
};
const handleSelect = (item: string) => {
if (value.includes(item)) {
handleUnselect(item);
} else {
onChange([...value, item]);
}
};
return (
<div className={cn("w-full", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
"flex h-full w-full transition-all items-center justify-between rounded-md border border-input bg-background text-sm",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"hover:bg-accent hover:text-accent-foreground"
)}
disabled={disabled}
aria-expanded={open}
>
<div className="flex justify-between flex-1 overflow-hidden">
<div
className="flex gap-1 flex-1 py-2 px-3 overflow-x-auto"
style={{
scrollbarWidth: "thin",
scrollbarColor: "hsl(var(--border)) transparent",
}}
>
{value.length === 0 ? (
<span className="text-muted-foreground truncate">
{placeholder}
</span>
) : (
value.map((item) => {
const option = options?.find((opt) => opt.value === item);
return (
<Badge key={item} variant="default" className="text-xs">
{option?.label}
<span
role="button"
tabIndex={0}
className="ml-1 hover:bg-destructive transition-all hover:text-destructive-foreground rounded-full p-0.5"
onKeyDown={(e) =>
e.key === "Enter" && handleUnselect(item)
}
onClick={() => handleUnselect(item)}
>
<X className="h-3 w-3" />
</span>
</Badge>
);
})
)}
</div>
<hr className="border-l border-border bg-red-300 h-6 mx-0.5 my-auto" />
<span
role="button"
onClick={(e) => {
e.stopPropagation();
setOpen((prev) => !prev);
}}
tabIndex={0}
className={cn(
"p-1 mx-1.5 my-auto h-full outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"hover:bg-accent/50 rounded-sm cursor-pointer"
)}
>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput autoFocus={false} placeholder="Search items..." />
<CommandList>
<CommandEmpty className="p-0">
{isLoading ? (
<div className="p-2">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={index}
className="h-4 w-full mb-1 last:mb-0"
/>
))}
</div>
) : (
<div className="text-center text-sm py-4 text-muted-foreground">
No items found.
</div>
)}
</CommandEmpty>
<CommandGroup>
{options?.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value.includes(option.value)
? "opacity-100"
: "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
export {MultiSelect}

View File

@@ -0,0 +1,15 @@
import { cn } from "@lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -55,8 +55,9 @@ pub async fn copy(
} }
} }
if !buffer.is_empty() { if !buffer.is_empty() {
bulk_update_retry_too_big(&target_db, &collection, &buffer, true).await.context("Failed to flush documents")?; bulk_update_retry_too_big(&target_db, &collection, &buffer, true)
.await
.context("Failed to flush documents")?;
} }
anyhow::Ok(count) anyhow::Ok(count)
} }