diff --git a/new-ui/package.json b/new-ui/package.json index e8fe0db5..dfb53612 100644 --- a/new-ui/package.json +++ b/new-ui/package.json @@ -23,6 +23,8 @@ "@tanstack/router-plugin": "^1.168.18", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-os": "^2.3.2", diff --git a/new-ui/pnpm-lock.yaml b/new-ui/pnpm-lock.yaml index 9793cab8..9154cc38 100644 --- a/new-ui/pnpm-lock.yaml +++ b/new-ui/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@tauri-apps/plugin-clipboard-manager': specifier: ^2.3.2 version: 2.3.2 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.1 + version: 2.7.1 + '@tauri-apps/plugin-fs': + specifier: ^2.5.1 + version: 2.5.1 '@tauri-apps/plugin-http': specifier: ^2.5.9 version: 2.5.9 @@ -857,6 +863,12 @@ packages: '@tauri-apps/plugin-clipboard-manager@2.3.2': resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + + '@tauri-apps/plugin-fs@2.5.1': + resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} + '@tauri-apps/plugin-http@2.5.9': resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} @@ -2642,6 +2654,14 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-fs@2.5.1': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-http@2.5.9': dependencies: '@tauri-apps/api': 2.11.0 diff --git a/new-ui/src/pages/full/LogPage/LogPage.tsx b/new-ui/src/pages/full/LogPage/LogPage.tsx new file mode 100644 index 00000000..b36b0d7c --- /dev/null +++ b/new-ui/src/pages/full/LogPage/LogPage.tsx @@ -0,0 +1,217 @@ +import './style.scss'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import * as clipboard from '@tauri-apps/plugin-clipboard-manager'; +import { save } from '@tauri-apps/plugin-dialog'; +import { writeTextFile } from '@tauri-apps/plugin-fs'; +import { error } from '@tauri-apps/plugin-log'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Subject } from 'rxjs'; +import { ButtonVariant } from '../../../shared/components/Button/types'; +import { FullPageTitle } from '../../../shared/components/FullPageTitle/FullPageTitle'; +import { Select } from '../../../shared/components/Select/Select'; +import type { SelectOption } from '../../../shared/components/Select/types'; +import { SizedBox } from '../../../shared/components/SizedBox/SizedBox'; +import { TooltipButton } from '../../../shared/components/TooltipButton/TooltipButton'; +import { FullPage } from '../../../shared/layouts/FullPage/FullPage'; +import { api } from '../../../shared/rust-api/api'; +import { + type LogItem, + type LogLevel, + LogSource, + TauriEvent, +} from '../../../shared/rust-api/types'; +import { ThemeSpacing } from '../../../shared/types'; + +type GlobalLogLevel = Extract; + +const logLevelOptions: SelectOption[] = [ + { key: 'error', label: 'Error', value: 'ERROR' }, + { key: 'info', label: 'Info', value: 'INFO' }, + { key: 'debug', label: 'Debug', value: 'DEBUG' }, +]; + +const logSourceOptions: SelectOption[] = [ + { key: 'all', label: 'All', value: LogSource.All }, + { key: 'client', label: 'Client', value: LogSource.Client }, + { key: 'vpn', label: 'VPN', value: LogSource.Vpn }, +]; + +const filterLogByLevel = (target: GlobalLogLevel, log: LogLevel): boolean => { + switch (target) { + case 'ERROR': + return log === 'ERROR'; + case 'INFO': + return ['INFO', 'ERROR', 'WARN'].includes(log); + case 'DEBUG': + return ['ERROR', 'INFO', 'DEBUG', 'WARN'].includes(log); + default: + return true; + } +}; + +const filterLogBySource = (target: LogSource, log: LogSource): boolean => + target === LogSource.All || target === log; + +const createLogLineElement = (content: string): HTMLParagraphElement => { + const element = document.createElement('p'); + element.classList.add('log-line'); + element.textContent = content; + return element; +}; + +export const LogPage = () => { + const logsContainerElement = useRef(null); + const logLevelRef = useRef('INFO'); + const logSourceRef = useRef(LogSource.All); + const [level, setLevel] = useState(logLevelOptions[1]); + const [source, setSource] = useState(logSourceOptions[0]); + + const clearLogs = useCallback(() => { + if (logsContainerElement.current) { + logsContainerElement.current.innerHTML = ''; + } + }, []); + + const restartLogWatcher = useCallback(() => { + clearLogs(); + api.stopGlobalLogWatcher(); + api.startGlobalLogWatcher(); + }, [clearLogs]); + + const getAllLogs = useCallback(() => { + let logs = ''; + logsContainerElement.current?.childNodes.forEach((item) => { + logs += `${item.textContent}\n`; + }); + return logs; + }, []); + + const clipboardSub = useRef(new Subject()); + const downloadSub = useRef(new Subject()); + + const handleLogsCopy = useCallback(() => { + const logs = getAllLogs(); + if (logs) { + clipboard.writeText(logs).then(() => { + clipboardSub.current.next(); + }); + } + }, [getAllLogs]); + + const handleLogsDownload = useCallback(async () => { + try { + const path = await save({ + filters: [ + { + name: 'Logs', + extensions: ['txt', 'log'], + }, + ], + }); + if (path) { + await writeTextFile(path, getAllLogs()); + downloadSub.current.next(); + } + } catch (e) { + error(`Failed to save logs to file: ${String(e)}`); + } + }, [getAllLogs]); + + useEffect(() => { + let eventUnlisten: UnlistenFn; + const startLogListen = async () => { + eventUnlisten = await listen( + TauriEvent.GlobalLogUpdate, + ({ payload: logItems }) => { + const container = logsContainerElement.current; + if (!container) return; + for (const item of logItems) { + if ( + filterLogByLevel(logLevelRef.current, item.level) && + filterLogBySource(logSourceRef.current, item.source) + ) { + const utcTimestamp = item.timestamp.endsWith('Z') + ? item.timestamp + : `${item.timestamp}Z`; + const dateTime = new Date(utcTimestamp).toLocaleString(); + const element = createLogLineElement( + `[${dateTime}][${item.level}][${item.source}] ${item.fields.message}`, + ); + // stick to bottom unless the user scrolled up to read + const scrollAfterAppend = + container.scrollHeight - container.scrollTop - container.clientHeight < + 10; + container.appendChild(element); + // auto scroll to bottom if user didn't scroll up + if (scrollAfterAppend) { + container.scrollTo({ top: container.scrollHeight }); + } + } + } + }, + ); + }; + startLogListen(); + api.startGlobalLogWatcher(); + + return () => { + api.stopGlobalLogWatcher(); + eventUnlisten?.(); + clearLogs(); + }; + }, [clearLogs]); + + return ( + + +

+ The source of the logs. Logs can come from the Defguard client or the VPN + service/extension that manages VPN connections at the network level. +

+ +
+ { + setSource(option); + logSourceRef.current = option.value; + restartLogWatcher(); + }} + /> +
+ + +
+ +
+ + ); +}; diff --git a/new-ui/src/pages/full/LogPage/style.scss b/new-ui/src/pages/full/LogPage/style.scss new file mode 100644 index 00000000..1424363a --- /dev/null +++ b/new-ui/src/pages/full/LogPage/style.scss @@ -0,0 +1,36 @@ +#log-page-view { + display: flex; + flex-direction: column; + overflow: hidden; + + .page-description { + font: var(--t-body-xs-400); + color: var(--fg-white-60); + } + + .controls { + display: flex; + align-items: center; + gap: var(--spacing-md); + + .spacer { + flex: 1; + } + } + + .log-container { + flex: 1; + border-radius: var(--radius-lg); + border: 1px solid var(--border-disabled); + background-color: var(--bg-white-5); + padding: var(--spacing-lg); + overflow: auto; + + .log-line { + font: var(--t-log-line); + color: var(--fg-white-100); + white-space: pre-wrap; + overflow-wrap: anywhere; + } + } +} diff --git a/new-ui/src/routeTree.gen.ts b/new-ui/src/routeTree.gen.ts index 1322f58d..cad051fa 100644 --- a/new-ui/src/routeTree.gen.ts +++ b/new-ui/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as FullDefaultRouteImport } from './routes/full/_default' import { Route as FullDefaultUpdateRouteImport } from './routes/full/_default/update' import { Route as FullDefaultSupportRouteImport } from './routes/full/_default/support' import { Route as FullDefaultOverviewRouteImport } from './routes/full/_default/overview' +import { Route as FullDefaultLogRouteImport } from './routes/full/_default/log' import { Route as FullDefaultAddIndexRouteImport } from './routes/full/_default/add/index' import { Route as FullDefaultAddInstanceRouteImport } from './routes/full/_default/add/instance' @@ -83,6 +84,11 @@ const FullDefaultOverviewRoute = FullDefaultOverviewRouteImport.update({ path: '/overview', getParentRoute: () => FullDefaultRoute, } as any) +const FullDefaultLogRoute = FullDefaultLogRouteImport.update({ + id: '/log', + path: '/log', + getParentRoute: () => FullDefaultRoute, +} as any) const FullDefaultAddIndexRoute = FullDefaultAddIndexRouteImport.update({ id: '/add/', path: '/add/', @@ -103,6 +109,7 @@ export interface FileRoutesByFullPath { '/compact/': typeof CompactIndexRoute '/full/': typeof FullIndexRoute '/playground/': typeof PlaygroundIndexRoute + '/full/log': typeof FullDefaultLogRoute '/full/overview': typeof FullDefaultOverviewRoute '/full/support': typeof FullDefaultSupportRoute '/full/update': typeof FullDefaultUpdateRoute @@ -117,6 +124,7 @@ export interface FileRoutesByTo { '/full/session-timeout': typeof FullSessionTimeoutRoute '/compact': typeof CompactIndexRoute '/playground': typeof PlaygroundIndexRoute + '/full/log': typeof FullDefaultLogRoute '/full/overview': typeof FullDefaultOverviewRoute '/full/support': typeof FullDefaultSupportRoute '/full/update': typeof FullDefaultUpdateRoute @@ -134,6 +142,7 @@ export interface FileRoutesById { '/compact/': typeof CompactIndexRoute '/full/': typeof FullIndexRoute '/playground/': typeof PlaygroundIndexRoute + '/full/_default/log': typeof FullDefaultLogRoute '/full/_default/overview': typeof FullDefaultOverviewRoute '/full/_default/support': typeof FullDefaultSupportRoute '/full/_default/update': typeof FullDefaultUpdateRoute @@ -151,6 +160,7 @@ export interface FileRouteTypes { | '/compact/' | '/full/' | '/playground/' + | '/full/log' | '/full/overview' | '/full/support' | '/full/update' @@ -165,6 +175,7 @@ export interface FileRouteTypes { | '/full/session-timeout' | '/compact' | '/playground' + | '/full/log' | '/full/overview' | '/full/support' | '/full/update' @@ -181,6 +192,7 @@ export interface FileRouteTypes { | '/compact/' | '/full/' | '/playground/' + | '/full/_default/log' | '/full/_default/overview' | '/full/_default/support' | '/full/_default/update' @@ -282,6 +294,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FullDefaultOverviewRouteImport parentRoute: typeof FullDefaultRoute } + '/full/_default/log': { + id: '/full/_default/log' + path: '/log' + fullPath: '/full/log' + preLoaderRoute: typeof FullDefaultLogRouteImport + parentRoute: typeof FullDefaultRoute + } '/full/_default/add/': { id: '/full/_default/add/' path: '/add' @@ -300,6 +319,7 @@ declare module '@tanstack/react-router' { } interface FullDefaultRouteChildren { + FullDefaultLogRoute: typeof FullDefaultLogRoute FullDefaultOverviewRoute: typeof FullDefaultOverviewRoute FullDefaultSupportRoute: typeof FullDefaultSupportRoute FullDefaultUpdateRoute: typeof FullDefaultUpdateRoute @@ -308,6 +328,7 @@ interface FullDefaultRouteChildren { } const FullDefaultRouteChildren: FullDefaultRouteChildren = { + FullDefaultLogRoute: FullDefaultLogRoute, FullDefaultOverviewRoute: FullDefaultOverviewRoute, FullDefaultSupportRoute: FullDefaultSupportRoute, FullDefaultUpdateRoute: FullDefaultUpdateRoute, diff --git a/new-ui/src/routes/full/_default/log.tsx b/new-ui/src/routes/full/_default/log.tsx new file mode 100644 index 00000000..cabb9796 --- /dev/null +++ b/new-ui/src/routes/full/_default/log.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { LogPage } from '../../../pages/full/LogPage/LogPage'; + +export const Route = createFileRoute('/full/_default/log')({ + component: LogPage, +}); diff --git a/new-ui/src/routes/full/_default/overview.tsx b/new-ui/src/routes/full/_default/overview.tsx index 54bc2b33..71169251 100644 --- a/new-ui/src/routes/full/_default/overview.tsx +++ b/new-ui/src/routes/full/_default/overview.tsx @@ -14,7 +14,7 @@ export const Route = createFileRoute('/full/_default/overview')({ ]); if (instances.length === 0 && tunnels.length === 0) { - throw redirect({ to: '/full/add' }); + throw redirect({ to: '/empty' }); } const stored = useAppStore.getState().compactViewSelection; diff --git a/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/FullViewNavigation.tsx b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/FullViewNavigation.tsx index ce3602a6..0949b1d1 100644 --- a/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/FullViewNavigation.tsx +++ b/new-ui/src/shared/layouts/FullPageLayout/components/FullViewNavigation/FullViewNavigation.tsx @@ -43,6 +43,10 @@ export const FullViewNavigation = () => { icon: IconKind.PlusCircle, to: '/full/add', }, + { + icon: IconKind.ActivityNotes, + to: '/full/log', + }, ], [isEmpty], ); diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index 2cc593b0..67f019b0 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -25,6 +25,26 @@ export const LogLevel = { export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; +export const LogSource = { + All: 'All', + Client: 'Client', + Vpn: 'VPN', +} as const; + +export type LogSource = (typeof LogSource)[keyof typeof LogSource]; + +export type LogItem = { + // datetime UTC + timestamp: string; + level: LogLevel; + target: string; + fields: { + message: string; + interface_name?: string; + }; + source: LogSource; +}; + export const ClientTrafficPolicy = { None: 'none', DisableAllTraffic: 'disable_all_traffic', @@ -121,6 +141,7 @@ export const TauriEvent = { MfaTrigger: 'mfa-trigger', VersionMismatch: 'version-mismatch', UuidMismatch: 'uuid-mismatch', + GlobalLogUpdate: 'log-update-global', } as const; export type TauriEventValue = (typeof TauriEvent)[keyof typeof TauriEvent]; diff --git a/new-ui/src/shared/scss/_shared_tokens.scss b/new-ui/src/shared/scss/_shared_tokens.scss index dcea8f42..a46acbc6 100644 --- a/new-ui/src/shared/scss/_shared_tokens.scss +++ b/new-ui/src/shared/scss/_shared_tokens.scss @@ -240,6 +240,9 @@ $jetbrains: --input-spacing-sm: var(--spacing-sm); --input-spacing-lg: var(--spacing-md); + // Log view + --t-log-line: normal 400 12px/20px #{$jetbrains}; + // Tooltip --t-tooltip: normal 400 12px / 16px #{$geist}; --tooltip-letter-spacing: 0.3; diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 78b6aae9..c093e9e4 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -2,3 +2,6 @@ # will have compiled files and executables /target/ defguard.db + +# Downloaded build artifacts +resources-macos/binaries/