Merge pull request #125 from cptKNJO/ui

UI
This commit is contained in:
Martin Raiber 2025-03-13 19:55:48 +01:00 committed by GitHub
commit 2aee029f80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 297 additions and 24 deletions

View File

@ -14,6 +14,7 @@ import {
Spinner,
Toaster,
mergeClasses,
Link,
} from "@fluentui/react-components";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useStackStyles } from "./components/StackStyles";
@ -25,9 +26,11 @@ import { BackupsPage } from "./pages/Backups";
import { ClientBackupsTable } from "./features/backups/ClientBackupsTable";
import { BackupsTable } from "./features/backups/BackupsTable";
import { BackupContentTable } from "./features/backups/BackupContentTable";
import BackupErrorPage from "./features/backups/BackupsError";
import { ErrorPage } from "./components/ErrorPage";
import { StatisticsPage } from "./pages/Statistics";
import { LogsPage } from "./pages/Logs";
import { ClientLogs } from "./features/logs/ClientLogs";
import { ClientLog } from "./features/logs/ClientLog";
import "./css/global.css";
const initialDark =
@ -131,7 +134,9 @@ export const router = createHashRouter([
await jumpToLoginPageIfNeccessary();
return null;
},
errorElement: <BackupErrorPage />,
errorElement: (
<ErrorPage returnToLink={<Link href="/#/backups">Backups</Link>} />
),
children: [
{
index: true,
@ -164,6 +169,17 @@ export const router = createHashRouter([
await jumpToLoginPageIfNeccessary();
return null;
},
errorElement: <ErrorPage returnToLink={<Link href="/#/logs">Logs</Link>} />,
children: [
{
index: true,
element: <ClientLogs />,
},
{
path: ":logId",
element: <ClientLog />,
},
],
},
]);

View File

@ -1,8 +1,8 @@
import { useRouteError } from "react-router-dom";
import { BackupsAccessDeniedError } from "../../api/urbackupserver";
import { Link } from "@fluentui/react-components";
export default function BackupErrorPage() {
import { BackupsAccessDeniedError } from "../api/urbackupserver";
export function ErrorPage({ returnToLink }: { returnToLink: React.ReactNode }) {
const error = useRouteError();
if (error instanceof BackupsAccessDeniedError) {
@ -12,9 +12,7 @@ export default function BackupErrorPage() {
<p>
<i>{error.statusText || error.message}</i>
</p>
<p>
Return to <Link href="/#/backups">Backups</Link>.
</p>
<p>Return to {returnToLink}</p>
</article>
);
}
@ -25,9 +23,7 @@ export default function BackupErrorPage() {
<p>
<i>{error.statusText || error.message}</i>
</p>
<p>
Return to <Link href="/#/backups">Backups</Link>.
</p>
<p>Return to {returnToLink}</p>
</article>
);
}

View File

@ -0,0 +1,116 @@
import { useState } from "react";
import {
Select,
Text,
makeStyles,
tokens,
mergeClasses,
} from "@fluentui/react-components";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { LOG_LEVELS } from "../../api/urbackupserver";
import { urbackupServer } from "../../App";
import { TableWrapper } from "../../components/TableWrapper";
import {
BASE_HREF,
BreadcrumbItem,
Breadcrumbs,
} from "../../components/Breadcrumbs";
import { FORMATTED_LOG_TABLE_LEVELS, LogTable } from "./LogTable";
const logsUrl = `${BASE_HREF}/logs`;
const useStyles = makeStyles({
heading: {
display: "flex",
gap: tokens.spacingHorizontalM,
alignItems: "center",
// Negate the margin top and left values
// to keep breadcrumbs aligned to initial top-left position
marginInlineStart: "-7px",
marginBlockStart: "-7px",
},
filters: {
marginTop: `calc(1em + 3px)`,
},
});
export function ClientLog() {
const { logId } = useParams();
const [logLevel, setLogLevel] = useState<
(typeof LOG_LEVELS)[keyof typeof LOG_LEVELS]
>(LOG_LEVELS.ERROR);
// Used for fetching clients list for logs
const logResult = useSuspenseQuery({
queryKey: ["log", logId],
queryFn: async () => {
try {
return await urbackupServer.getLog(Number(logId));
} catch (e) {
throw new Error(`No such log with ID: ${logId}`);
}
},
});
const classes = useStyles();
const { log } = logResult.data;
const { clientname, data } = log;
const breadcrumbItems: BreadcrumbItem[] = [
{
key: "logs",
text: "Logs",
itemProps: {
href: logsUrl,
},
},
{
key: `log-${logId}`,
text: clientname,
itemProps: {
href: "",
},
},
];
const filteredData = data.filter((d) => d.level >= logLevel);
return (
<TableWrapper>
<div className={classes.heading}>
<Breadcrumbs items={breadcrumbItems} wrapper={"h3"} />
<Text
font="numeric"
style={{
color: "var(--colorNeutralForeground3)",
}}
>
ID #{logId}
</Text>
</div>
<div
className={mergeClasses(classes.filters, "cluster")}
data-spacing="s"
>
Filter
<Select
id="log-level"
defaultValue={logLevel}
onChange={(_, data) => setLogLevel(+data.value as typeof logLevel)}
>
{Object.entries(LOG_LEVELS).map(([k, v]) => (
<option key={k} value={v}>
{FORMATTED_LOG_TABLE_LEVELS[k as keyof typeof LOG_LEVELS]}
</option>
))}
</Select>
</div>
<LogTable data={filteredData} />
</TableWrapper>
);
}

View File

@ -6,6 +6,7 @@ import { LOG_LEVELS, type ClientIdType } from "../../api/urbackupserver";
import { urbackupServer } from "../../App";
import { SelectClientCombobox } from "../../components/SelectClientCombobox";
import { LogsTable } from "./LogsTable";
import { TableWrapper } from "../../components/TableWrapper";
const FORMATTED_LOG_LEVELS = {
INFO: "All",
@ -31,7 +32,8 @@ export function ClientLogs() {
const { clients } = logsResult.data;
return (
<div className="flow">
<TableWrapper>
<h3>Logs</h3>
<div className="cluster">
<SelectClientCombobox
clients={clients}
@ -55,6 +57,6 @@ export function ClientLogs() {
<Suspense fallback={<Spinner />}>
<LogsTable selectedClientId={selectedClientId} logLevel={logLevel} />
</Suspense>
</div>
</TableWrapper>
);
}

View File

@ -0,0 +1,139 @@
import {
DataGrid,
DataGridHeader,
DataGridRow,
DataGridHeaderCell,
DataGridBody,
DataGridCell,
TableCellLayout,
TableColumnDefinition,
createTableColumn,
tokens,
TableColumnId,
} from "@fluentui/react-components";
import {
DismissCircle16Filled,
Info16Filled,
Warning16Filled,
} from "@fluentui/react-icons";
import { LOG_LEVELS, type LogDataRow } from "../../api/urbackupserver";
import { formatDatetime } from "../../utils/format";
export const FORMATTED_LOG_TABLE_LEVELS = {
INFO: "Info",
WARNING: "Warnings",
ERROR: "Errors",
} as const;
const LOG_LEVELS_MAP = new Map(
(Object.keys(LOG_LEVELS) as (keyof typeof LOG_LEVELS)[]).map((k) => [
LOG_LEVELS[k],
k,
]),
);
const columns: TableColumnDefinition<LogDataRow>[] = [
createTableColumn<LogDataRow>({
columnId: "level",
renderHeaderCell: () => {
return "Level";
},
renderCell: (item) => {
return (
<TableCellLayout>
<div className="cluster" data-spacing="s">
{LOG_LEVELS_MAP.get(item.level) === "INFO" && (
<Info16Filled
style={{
color: tokens.colorBrandForeground1,
}}
/>
)}
{LOG_LEVELS_MAP.get(item.level) === "WARNING" && (
<Warning16Filled
style={{
color: tokens.colorStatusWarningForeground1,
}}
/>
)}
{LOG_LEVELS_MAP.get(item.level) === "ERROR" && (
<DismissCircle16Filled
style={{
color: tokens.colorStatusDangerForeground1,
}}
/>
)}
{FORMATTED_LOG_TABLE_LEVELS[LOG_LEVELS_MAP.get(item.level)!]}
</div>
</TableCellLayout>
);
},
}),
createTableColumn<LogDataRow>({
columnId: "time",
renderHeaderCell: () => {
return "Time";
},
renderCell: (item) => {
return <TableCellLayout>{formatDatetime(item.time)}</TableCellLayout>;
},
}),
createTableColumn<LogDataRow>({
columnId: "message",
renderHeaderCell: () => {
return "Message";
},
renderCell: (item) => {
return <TableCellLayout>{item.message}</TableCellLayout>;
},
}),
];
export function LogTable({ data }: { data: LogDataRow[] }) {
return (
<DataGrid items={data} getRowId={(item) => item.id} columns={columns}>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell, columnId }) => (
<DataGridHeaderCell style={getNarrowColumnStyles(columnId)}>
{renderHeaderCell()}
</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<LogDataRow>>
{({ item }) => (
<DataGridRow<LogDataRow>>
{({ renderCell, columnId }) => (
<DataGridCell style={getNarrowColumnStyles(columnId)}>
{renderCell(item)}
</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
);
}
/**
* Style some columns to take up less space.
*/
function getNarrowColumnStyles(columnId: TableColumnId) {
const stringId = columnId.toString();
const widths = {
level: "12ch",
time: "20ch",
} as const;
const widthKeys = Object.keys(widths);
return {
flexGrow: widthKeys.includes(stringId) ? "0" : "1",
flexBasis: widthKeys.includes(stringId)
? widths[stringId as keyof typeof widths]
: "0",
};
}

View File

@ -11,8 +11,9 @@ import {
tokens,
TableColumnId,
} from "@fluentui/react-components";
import { Link } from "react-router-dom";
import { useSuspenseQuery } from "@tanstack/react-query";
import { ErrorCircle16Filled, Warning16Filled } from "@fluentui/react-icons";
import { DismissCircle16Filled, Warning16Filled } from "@fluentui/react-icons";
import {
LOG_LEVELS,
@ -20,6 +21,7 @@ import {
type LogInfo,
} from "../../api/urbackupserver";
import { formatDatetime } from "../../utils/format";
import { getCellFocusMode } from "../../utils/table";
import { urbackupServer } from "../../App";
import { getActionFromBackup } from "../../utils/getActionFromBackup";
@ -61,7 +63,7 @@ const columns: TableColumnDefinition<LogInfo>[] = [
<TableCellLayout>
<div className="cluster" data-spacing="s">
{item.errors > 0 && (
<ErrorCircle16Filled
<DismissCircle16Filled
style={{
color: tokens.colorStatusDangerForeground1,
}}
@ -134,8 +136,13 @@ export function LogsTable({
{({ item }) => (
<DataGridRow<LogInfo> key={item.id}>
{({ renderCell, columnId }) => (
<DataGridCell style={getNarrowColumnStyles(columnId)}>
{renderCell(item)}
<DataGridCell
focusMode={getCellFocusMode(columnId, {
none: ["name", "backuptime", "action", "errors", "warnings"],
})}
style={getNarrowColumnStyles(columnId)}
>
<Link to={String(item.id)}>{renderCell(item)}</Link>
</DataGridCell>
)}
</DataGridRow>

View File

@ -1,16 +1,13 @@
import { Suspense } from "react";
import { Spinner } from "@fluentui/react-components";
import { TableWrapper } from "../components/TableWrapper";
import { ClientLogs } from "../features/logs/ClientLogs";
import { Outlet } from "react-router-dom";
export const LogsPage = () => {
return (
<Suspense fallback={<Spinner />}>
<TableWrapper>
<h3>Logs</h3>
<ClientLogs />
</TableWrapper>
<div>
<Outlet />
</div>
</Suspense>
);
};