Skip to content

Commit eda9426

Browse files
authored
Merge pull request #126 from cptKNJO/ui
UI
2 parents 2aee029 + aa4b02f commit eda9426

File tree

4 files changed

+418
-25
lines changed

4 files changed

+418
-25
lines changed

urbackupserver/www2/src/css/global.css

+24
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
}
3636

3737
@layer composition {
38+
/*
39+
Based on https://github.com/Set-Creative-Studio/cube-boilerplate/tree/main/src/css/compositions
40+
*/
41+
3842
/*
3943
FLOW COMPOSITION
4044
Like the Every Layout stack: https://every-layout.dev/layouts/stack/
@@ -75,6 +79,26 @@
7579
.cluster[data-spacing="s"] {
7680
--gutter: var(--spacingS);
7781
}
82+
83+
/*
84+
REPEL
85+
A little layout that pushes items away from each other where
86+
there is space in the viewport and stacks on small viewports
87+
88+
CUSTOM PROPERTIES AND CONFIGURATION
89+
--gutter: This defines the space
90+
between each item.
91+
92+
--repel-vertical-alignment How items should align vertically.
93+
Can be any acceptable flexbox alignment value.
94+
*/
95+
.repel {
96+
display: flex;
97+
flex-wrap: wrap;
98+
justify-content: space-between;
99+
align-items: var(--repel-vertical-alignment, center);
100+
gap: var(--gutter);
101+
}
78102
}
79103

80104
/* Table */
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,46 @@
11
import { Suspense, useState } from "react";
2-
import { Spinner, Select } from "@fluentui/react-components";
2+
import {
3+
Spinner,
4+
Select,
5+
makeStyles,
6+
mergeClasses,
7+
tokens,
8+
} from "@fluentui/react-components";
39
import { useSuspenseQuery } from "@tanstack/react-query";
410

511
import { LOG_LEVELS, type ClientIdType } from "../../api/urbackupserver";
612
import { urbackupServer } from "../../App";
713
import { SelectClientCombobox } from "../../components/SelectClientCombobox";
814
import { LogsTable } from "./LogsTable";
915
import { TableWrapper } from "../../components/TableWrapper";
16+
import { LiveLog } from "./LiveLog";
17+
import { LogReports } from "./LogReports";
1018

1119
const FORMATTED_LOG_LEVELS = {
1220
INFO: "All",
1321
WARNING: "Warnings",
1422
ERROR: "Errors",
1523
} as const;
1624

25+
const useStyles = makeStyles({
26+
root: {
27+
display: "grid",
28+
gap: tokens.spacingVerticalXXL,
29+
gridTemplateColumns: "1fr 320px",
30+
alignItems: "start",
31+
// Adjust height to match client log tables with breadcrumbs
32+
marginBlockStart: "-7px",
33+
},
34+
reports: {
35+
display: "flex",
36+
flexDirection: "column",
37+
background: tokens.colorNeutralCardBackground,
38+
padding: tokens.spacingHorizontalL,
39+
borderRadius: tokens.borderRadiusLarge,
40+
height: "min-content",
41+
},
42+
});
43+
1744
export function ClientLogs() {
1845
const [selectedClientId, setSelectedClientId] = useState<
1946
ClientIdType | undefined
@@ -23,6 +50,8 @@ export function ClientLogs() {
2350
(typeof LOG_LEVELS)[keyof typeof LOG_LEVELS]
2451
>(LOG_LEVELS.ERROR);
2552

53+
const classes = useStyles();
54+
2655
// Used for fetching clients list for logs
2756
const logsResult = useSuspenseQuery({
2857
queryKey: ["logs"],
@@ -32,31 +61,45 @@ export function ClientLogs() {
3261
const { clients } = logsResult.data;
3362

3463
return (
35-
<TableWrapper>
36-
<h3>Logs</h3>
37-
<div className="cluster">
38-
<SelectClientCombobox
39-
clients={clients}
40-
onSelect={(id) => setSelectedClientId(Number(id))}
41-
/>
42-
<div className="cluster" data-spacing="s">
43-
Filter
44-
<Select
45-
id="log-level"
46-
defaultValue={logLevel}
47-
onChange={(_, data) => setLogLevel(+data.value as typeof logLevel)}
48-
>
49-
{Object.entries(LOG_LEVELS).map(([k, v]) => (
50-
<option key={k} value={v}>
51-
{FORMATTED_LOG_LEVELS[k as keyof typeof LOG_LEVELS]}
52-
</option>
53-
))}
54-
</Select>
64+
<div className={classes.root}>
65+
<TableWrapper>
66+
<div className="repel">
67+
<h3>Logs</h3>
68+
<LiveLog clients={clients}>Open Live Log</LiveLog>
69+
</div>
70+
<div className="cluster">
71+
<SelectClientCombobox
72+
clients={clients}
73+
onSelect={(id) => setSelectedClientId(Number(id))}
74+
/>
75+
<div className="cluster" data-spacing="s">
76+
Filter
77+
<Select
78+
id="log-level"
79+
defaultValue={logLevel}
80+
onChange={(_, data) =>
81+
setLogLevel(+data.value as typeof logLevel)
82+
}
83+
>
84+
{Object.entries(LOG_LEVELS).map(([k, v]) => (
85+
<option key={k} value={v}>
86+
{FORMATTED_LOG_LEVELS[k as keyof typeof LOG_LEVELS]}
87+
</option>
88+
))}
89+
</Select>
90+
</div>
5591
</div>
92+
<Suspense fallback={<Spinner />}>
93+
<LogsTable selectedClientId={selectedClientId} logLevel={logLevel} />
94+
</Suspense>
95+
</TableWrapper>
96+
<div className={mergeClasses(classes.reports, "flow")}>
97+
<h4>Reports</h4>
98+
<p>Automatically send reports to emails.</p>
99+
<Suspense fallback={<Spinner />}>
100+
<LogReports />
101+
</Suspense>
56102
</div>
57-
<Suspense fallback={<Spinner />}>
58-
<LogsTable selectedClientId={selectedClientId} logLevel={logLevel} />
59-
</Suspense>
60-
</TableWrapper>
103+
</div>
61104
);
62105
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
Button,
3+
Combobox,
4+
ComboboxProps,
5+
makeStyles,
6+
Popover,
7+
PopoverSurface,
8+
PopoverTrigger,
9+
tokens,
10+
useComboboxFilter,
11+
} from "@fluentui/react-components";
12+
import { useId, useState } from "react";
13+
import { Open16Regular } from "@fluentui/react-icons";
14+
15+
import type { LogClient } from "../../api/urbackupserver";
16+
17+
const useStyles = makeStyles({
18+
combobox: {
19+
display: "grid",
20+
justifyItems: "start",
21+
gap: tokens.spacingVerticalXS,
22+
},
23+
listbox: {
24+
translate: `0 ${tokens.spacingVerticalS}`,
25+
},
26+
});
27+
28+
export function LiveLog({
29+
clients,
30+
children,
31+
}: {
32+
clients: LogClient[];
33+
children: React.ReactNode;
34+
}) {
35+
const styles = useStyles();
36+
const id = useId();
37+
38+
const [open, setOpen] = useState(false);
39+
40+
const options = clients.map((client) => ({
41+
children: client.name,
42+
value: client.name,
43+
}));
44+
45+
const comboId = useId();
46+
47+
const [query, setQuery] = useState<string>("");
48+
const comboBoxChildren = useComboboxFilter(query, options, {
49+
noOptionsMessage: `No results matched "${query}"`,
50+
});
51+
52+
const onOptionSelect: ComboboxProps["onOptionSelect"] = (_, data) => {
53+
const text = data.optionValue;
54+
const selectedClient = clients.find((client) => client.name === text);
55+
56+
if (!selectedClient) {
57+
return;
58+
}
59+
60+
window.open("", "_blank")?.focus();
61+
62+
setOpen(false);
63+
setQuery("");
64+
};
65+
66+
return (
67+
<Popover
68+
trapFocus
69+
open={open}
70+
onOpenChange={(_, data) => {
71+
const state = data.open ?? false;
72+
setOpen(state);
73+
74+
if (state === false) {
75+
setQuery("");
76+
}
77+
}}
78+
>
79+
<PopoverTrigger disableButtonEnhancement>
80+
<Button icon={<Open16Regular />}>{children}</Button>
81+
</PopoverTrigger>
82+
83+
<PopoverSurface aria-labelledby={id}>
84+
<div className={styles.combobox}>
85+
<label id={comboId}>Select client</label>
86+
<Combobox
87+
defaultOpen
88+
onOptionSelect={onOptionSelect}
89+
aria-labelledby={comboId}
90+
onChange={(ev) => setQuery(ev.target.value)}
91+
value={query}
92+
listbox={{
93+
className: styles.listbox,
94+
}}
95+
>
96+
{comboBoxChildren}
97+
</Combobox>
98+
</div>
99+
</PopoverSurface>
100+
</Popover>
101+
);
102+
}

0 commit comments

Comments
 (0)