mirror of
https://github.com/uroni/urbackup_backend.git
synced 2025-10-26 11:36:50 +00:00
commit
2aee029f80
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
116
urbackupserver/www2/src/features/logs/ClientLog.tsx
Normal file
116
urbackupserver/www2/src/features/logs/ClientLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
139
urbackupserver/www2/src/features/logs/LogTable.tsx
Normal file
139
urbackupserver/www2/src/features/logs/LogTable.tsx
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user