summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormarios8543 <marios8543@gmail.com>2024-02-22 14:07:59 +0200
committermarios8543 <marios8543@gmail.com>2024-02-22 14:07:59 +0200
commit6b5f7c8642062906ecb36d905e52d0fcc6172783 (patch)
treef5e843caf4806f710351e528a462b08113687efe
parent922d0c4153113b917722cae018ca6e35b6c0e9bd (diff)
downloaddecky-loader-6b5f7c8642062906ecb36d905e52d0fcc6172783.tar.gz
decky-loader-6b5f7c8642062906ecb36d905e52d0fcc6172783.zip
Added log viewer as side-tab in settings
-rw-r--r--backend/src/utilities.py244
-rw-r--r--frontend/src/components/logviewer/LogList.tsx48
-rw-r--r--frontend/src/components/logviewer/LogViewModal.tsx45
-rw-r--r--frontend/src/components/logviewer/LoggedPlugin.tsx35
-rw-r--r--frontend/src/components/logviewer/ScrollableWindow.tsx107
-rw-r--r--frontend/src/components/logviewer/index.tsx20
-rw-r--r--frontend/src/components/settings/index.tsx15
7 files changed, 403 insertions, 111 deletions
diff --git a/backend/src/utilities.py b/backend/src/utilities.py
index edde682d..92715645 100644
--- a/backend/src/utilities.py
+++ b/backend/src/utilities.py
@@ -1,11 +1,11 @@
from __future__ import annotations
-from os import stat_result
+from os import stat_result, listdir
import uuid
from json.decoder import JSONDecodeError
from os.path import splitext
import re
from traceback import format_exc
-from stat import FILE_ATTRIBUTE_HIDDEN # type: ignore
+from stat import FILE_ATTRIBUTE_HIDDEN # type: ignore
from asyncio import StreamReader, StreamWriter, start_server, gather, open_connection
from aiohttp import ClientSession, web
@@ -15,6 +15,7 @@ from logging import getLogger
from pathlib import Path
from .browser import PluginInstallRequest, PluginInstallType
+
if TYPE_CHECKING:
from .main import PluginManager
from .injector import inject_to_tab, get_gamepadui_tab, close_old_tabs, get_tab
@@ -22,11 +23,13 @@ from .localplatform import ON_WINDOWS
from . import helpers
from .localplatform import service_stop, service_start, get_home_path, get_username
+
class FilePickerObj(TypedDict):
file: Path
filest: stat_result
is_dir: bool
+
class Utilities:
def __init__(self, context: PluginManager) -> None:
self.context = context
@@ -50,6 +53,10 @@ class Utilities:
"enable_rdt": self.enable_rdt,
"get_tab_id": self.get_tab_id,
"get_user_info": self.get_user_info,
+ "get_plugin_logs": self.get_plugin_logs,
+ "get_plugins_with_logs": self.get_plugins_with_logs,
+ "get_plugin_log_text": self.get_plugin_log_text,
+ "upload_log": self.upload_log
}
self.logger = getLogger("Utilities")
@@ -59,9 +66,9 @@ class Utilities:
self.rdt_proxy_task = None
if context:
- context.web_app.add_routes([
- web.post("/methods/{method_name}", self._handle_server_method_call)
- ])
+ context.web_app.add_routes(
+ [web.post("/methods/{method_name}", self._handle_server_method_call)]
+ )
async def _handle_server_method_call(self, request: web.Request):
method_name = request.match_info["method_name"]
@@ -79,13 +86,20 @@ class Utilities:
res["success"] = False
return web.json_response(res)
- async def install_plugin(self, artifact: str="", name: str="No name", version: str="dev", hash: str="", install_type: PluginInstallType=PluginInstallType.INSTALL):
+ async def install_plugin(
+ self,
+ artifact: str = "",
+ name: str = "No name",
+ version: str = "dev",
+ hash: str = "",
+ install_type: PluginInstallType = PluginInstallType.INSTALL,
+ ):
return await self.context.plugin_browser.request_plugin_install(
artifact=artifact,
name=name,
version=version,
hash=hash,
- install_type=install_type
+ install_type=install_type,
)
async def install_plugins(self, requests: List[PluginInstallRequest]):
@@ -102,15 +116,13 @@ class Utilities:
async def uninstall_plugin(self, name: str):
return await self.context.plugin_browser.uninstall_plugin(name)
- async def http_request(self, method: str="", url: str="", **kwargs: Any):
+ async def http_request(self, method: str = "", url: str = "", **kwargs: Any):
async with ClientSession() as web:
- res = await web.request(method, url, ssl=helpers.get_ssl_context(), **kwargs)
+ res = await web.request(
+ method, url, ssl=helpers.get_ssl_context(), **kwargs
+ )
text = await res.text()
- return {
- "status": res.status,
- "headers": dict(res.headers),
- "body": text
- }
+ return {"status": res.status, "headers": dict(res.headers), "body": text}
async def ping(self, **kwargs: Any):
return "pong"
@@ -120,26 +132,18 @@ class Utilities:
result = await inject_to_tab(tab, code, run_async)
assert result
if "exceptionDetails" in result["result"]:
- return {
- "success": False,
- "result": result["result"]
- }
-
- return {
- "success": True,
- "result": result["result"]["result"].get("value")
- }
+ return {"success": False, "result": result["result"]}
+
+ return {"success": True, "result": result["result"]["result"].get("value")}
except Exception as e:
- return {
- "success": False,
- "result": e
- }
+ return {"success": False, "result": e}
async def inject_css_into_tab(self, tab: str, style: str):
try:
css_id = str(uuid.uuid4())
- result = await inject_to_tab(tab,
+ result = await inject_to_tab(
+ tab,
f"""
(function() {{
const style = document.createElement('style');
@@ -147,27 +151,21 @@ class Utilities:
document.head.append(style);
style.textContent = `{style}`;
}})()
- """, False)
+ """,
+ False,
+ )
if result and "exceptionDetails" in result["result"]:
- return {
- "success": False,
- "result": result["result"]
- }
-
- return {
- "success": True,
- "result": css_id
- }
+ return {"success": False, "result": result["result"]}
+
+ return {"success": True, "result": css_id}
except Exception as e:
- return {
- "success": False,
- "result": e
- }
+ return {"success": False, "result": e}
async def remove_css_from_tab(self, tab: str, css_id: str):
try:
- result = await inject_to_tab(tab,
+ result = await inject_to_tab(
+ tab,
f"""
(function() {{
let style = document.getElementById("{css_id}");
@@ -175,22 +173,16 @@ class Utilities:
if (style.nodeName.toLowerCase() == 'style')
style.parentNode.removeChild(style);
}})()
- """, False)
+ """,
+ False,
+ )
if result and "exceptionDetails" in result["result"]:
- return {
- "success": False,
- "result": result
- }
+ return {"success": False, "result": result}
- return {
- "success": True
- }
+ return {"success": True}
except Exception as e:
- return {
- "success": False,
- "result": e
- }
+ return {"success": False, "result": e}
async def get_setting(self, key: str, default: Any):
return self.context.settings.getSetting(key, default)
@@ -206,17 +198,19 @@ class Utilities:
await service_stop(helpers.REMOTE_DEBUGGER_UNIT)
return True
- async def filepicker_ls(self,
- path : str | None = None,
- include_files: bool = True,
- include_folders: bool = True,
- include_ext: list[str] = [],
- include_hidden: bool = False,
- order_by: str = "name_asc",
- filter_for: str | None = None,
- page: int = 1,
- max: int = 1000):
-
+ async def filepicker_ls(
+ self,
+ path: str | None = None,
+ include_files: bool = True,
+ include_folders: bool = True,
+ include_ext: list[str] = [],
+ include_hidden: bool = False,
+ order_by: str = "name_asc",
+ filter_for: str | None = None,
+ page: int = 1,
+ max: int = 1000,
+ ):
+
if path == None:
path = get_home_path()
@@ -225,13 +219,13 @@ class Utilities:
files: List[FilePickerObj] = []
folders: List[FilePickerObj] = []
- #Resolving all files/folders in the requested directory
+ # Resolving all files/folders in the requested directory
for file in path_obj.iterdir():
if file.exists():
filest = file.stat()
- is_hidden = file.name.startswith('.')
+ is_hidden = file.name.startswith(".")
if ON_WINDOWS and not is_hidden:
- is_hidden = bool(filest.st_file_attributes & FILE_ATTRIBUTE_HIDDEN) # type: ignore
+ is_hidden = bool(filest.st_file_attributes & FILE_ATTRIBUTE_HIDDEN) # type: ignore
if include_folders and file.is_dir():
if (is_hidden and include_hidden) or not is_hidden:
folders.append({"file": file, "filest": filest, "is_dir": True})
@@ -240,53 +234,65 @@ class Utilities:
if len(include_ext) == 0 or 'all_files' in include_ext \
or splitext(file.name)[1].lstrip('.').upper() in (ext.upper() for ext in include_ext):
if (is_hidden and include_hidden) or not is_hidden:
- files.append({"file": file, "filest": filest, "is_dir": False})
+ files.append(
+ {"file": file, "filest": filest, "is_dir": False}
+ )
# Filter logic
if filter_for is not None:
try:
if re.compile(filter_for):
- files = list(filter(lambda file: re.search(filter_for, file["file"].name) != None, files))
+ files = list(
+ filter(
+ lambda file: re.search(filter_for, file["file"].name)
+ != None,
+ files,
+ )
+ )
except re.error:
- files = list(filter(lambda file: file["file"].name.find(filter_for) != -1, files))
-
+ files = list(
+ filter(lambda file: file["file"].name.find(filter_for) != -1, files)
+ )
+
# Ordering logic
ord_arg = order_by.split("_")
ord = ord_arg[0]
rev = True if ord_arg[1] == "asc" else False
match ord:
- case 'name':
- files.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
- folders.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
- case 'modified':
- files.sort(key=lambda x: x['filest'].st_mtime, reverse = not rev)
- folders.sort(key=lambda x: x['filest'].st_mtime, reverse = not rev)
- case 'created':
- files.sort(key=lambda x: x['filest'].st_ctime, reverse = not rev)
- folders.sort(key=lambda x: x['filest'].st_ctime, reverse = not rev)
- case 'size':
- files.sort(key=lambda x: x['filest'].st_size, reverse = not rev)
+ case "name":
+ files.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
+ folders.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
+ case "modified":
+ files.sort(key=lambda x: x["filest"].st_mtime, reverse=not rev)
+ folders.sort(key=lambda x: x["filest"].st_mtime, reverse=not rev)
+ case "created":
+ files.sort(key=lambda x: x["filest"].st_ctime, reverse=not rev)
+ folders.sort(key=lambda x: x["filest"].st_ctime, reverse=not rev)
+ case "size":
+ files.sort(key=lambda x: x["filest"].st_size, reverse=not rev)
# Folders has no file size, order by name instead
- folders.sort(key=lambda x: x['file'].name.casefold())
+ folders.sort(key=lambda x: x["file"].name.casefold())
case _:
- files.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
- folders.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
-
- #Constructing the final file list, folders first
- all = [{
- "isdir": x['is_dir'],
- "name": str(x['file'].name),
- "realpath": str(x['file']),
- "size": x['filest'].st_size,
- "modified": x['filest'].st_mtime,
- "created": x['filest'].st_ctime,
- } for x in folders + files ]
+ files.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
+ folders.sort(key=lambda x: x["file"].name.casefold(), reverse=rev)
+
+ # Constructing the final file list, folders first
+ all = [
+ {
+ "isdir": x["is_dir"],
+ "name": str(x["file"].name),
+ "realpath": str(x["file"]),
+ "size": x["filest"].st_size,
+ "modified": x["filest"].st_mtime,
+ "created": x["filest"].st_ctime,
+ }
+ for x in folders + files
+ ]
return {
"realpath": str(path),
- "files": all[(page-1)*max:(page)*max],
+ "files": all[(page - 1) * max : (page) * max],
"total": len(all),
}
-
# Based on https://stackoverflow.com/a/46422554/13174603
def start_rdt_proxy(self, ip: str, port: int):
@@ -296,10 +302,10 @@ class Utilities:
writer.write(await reader.read(2048))
finally:
writer.close()
+
async def handle_client(local_reader: StreamReader, local_writer: StreamWriter):
try:
- remote_reader, remote_writer = await open_connection(
- ip, port)
+ remote_reader, remote_writer = await open_connection(ip, port)
pipe1 = pipe(local_reader, remote_writer)
pipe2 = pipe(remote_reader, local_writer)
await gather(pipe1, pipe2)
@@ -324,8 +330,11 @@ class Utilities:
if ip != None:
self.logger.info("Connecting to React DevTools at " + ip)
async with ClientSession() as web:
- res = await web.request("GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context())
- script = """
+ res = await web.request(
+ "GET", "http://" + ip + ":8097", ssl=helpers.get_ssl_context()
+ )
+ script = (
+ """
if (!window.deckyHasConnectedRDT) {
window.deckyHasConnectedRDT = true;
// This fixes the overlay when hovering over an element in RDT
@@ -336,7 +345,10 @@ class Utilities:
return (GamepadNavTree?.m_context?.m_controller || FocusNavController)?.m_ActiveContext?.ActiveWindow || window;
}
});
- """ + await res.text() + "\n}"
+ """
+ + await res.text()
+ + "\n}"
+ )
if res.status != 200:
self.logger.error("Failed to connect to React DevTools at " + ip)
return False
@@ -364,10 +376,24 @@ class Utilities:
self.logger.info("React DevTools disabled")
async def get_user_info(self) -> Dict[str, str]:
- return {
- "username": get_username(),
- "path": get_home_path()
- }
-
+ return {"username": get_username(), "path": get_home_path()}
+
async def get_tab_id(self, name: str):
return (await get_tab(name)).id
+
+ async def get_plugins_with_logs(self):
+ return list(self.context.plugin_loader.plugins.keys())
+
+ async def get_plugin_logs(self, plugin_name: str):
+ log_path = Path(helpers.get_homebrew_path()) / "logs" / \
+ self.context.plugin_loader.plugins[plugin_name].plugin_directory
+ return listdir(log_path)
+
+ async def get_plugin_log_text(self, plugin_name: str, log_name: str):
+ log_path = Path(helpers.get_homebrew_path()) / "logs" / \
+ self.context.plugin_loader.plugins[plugin_name].plugin_directory / log_name
+ with open(log_path, "r") as log_file:
+ return log_file.read()
+
+ async def upload_log(self, plugin_name: str, log_name: str):
+ raise RuntimeError("Not Implemented") \ No newline at end of file
diff --git a/frontend/src/components/logviewer/LogList.tsx b/frontend/src/components/logviewer/LogList.tsx
new file mode 100644
index 00000000..b536fd02
--- /dev/null
+++ b/frontend/src/components/logviewer/LogList.tsx
@@ -0,0 +1,48 @@
+import {
+ DialogButton,
+ Focusable,
+ showModal,
+} from "decky-frontend-lib";
+import { FC, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import LogViewModal from "./LogViewModal";
+
+const LogList: FC<{ plugin: string }> = ({ plugin }) => {
+ const [logList, setLogList] = useState([]);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ window.DeckyPluginLoader.callServerMethod("get_plugin_logs", {
+ plugin_name: plugin,
+ }).then((log_list) => {
+ setLogList(log_list.result || []);
+ });
+ }, []);
+
+ return (
+ <Focusable>
+ {logList.map((log_file) => (
+ <DialogButton
+ style={{ marginBottom: "0.5rem" }}
+ onOKActionDescription={t("LogViewer.viewLog", "View Log")}
+ onOKButton={() =>
+ showModal(
+ <LogViewModal name={log_file} plugin={plugin}></LogViewModal>,
+ )
+ }
+ onClick={() =>
+ showModal(
+ <LogViewModal name={log_file} plugin={plugin}></LogViewModal>,
+ )
+ }
+ >
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
+ <div>{log_file}</div>
+ </div>
+ </DialogButton>
+ ))}
+ </Focusable>
+ );
+};
+
+export default LogList;
diff --git a/frontend/src/components/logviewer/LogViewModal.tsx b/frontend/src/components/logviewer/LogViewModal.tsx
new file mode 100644
index 00000000..beda50a3
--- /dev/null
+++ b/frontend/src/components/logviewer/LogViewModal.tsx
@@ -0,0 +1,45 @@
+import { Focusable } from "decky-frontend-lib";
+import { VFC, useEffect, useState } from "react";
+import { ScrollableWindowRelative } from "./ScrollableWindow";
+
+interface LogFileProps {
+ plugin: string;
+ name: string;
+ closeModal?: () => void;
+}
+
+const LogViewModal: VFC<LogFileProps> = ({ name, plugin, closeModal }) => {
+ const [logText, setLogText] = useState("Loading text....");
+ useEffect(() => {
+ window.DeckyPluginLoader.callServerMethod("get_plugin_log_text", {
+ plugin_name: plugin,
+ log_name: name,
+ }).then((text) => {
+ setLogText(text.result || "Error loading text");
+ });
+ }, []);
+
+ return (
+ <Focusable
+ style={{
+ padding: "0 15px",
+ display: "flex",
+ position: "absolute",
+ top: "var(--basicui-header-height)",
+ bottom: "var(--gamepadui-current-footer-height)",
+ left: 0,
+ right: 0,
+ }}
+ onSecondaryActionDescription={"Upload Log"}
+ onSecondaryButton={() => console.log("Uploading...")}
+ >
+ <ScrollableWindowRelative alwaysFocus={true} onCancel={closeModal}>
+ <div style={{ whiteSpace: "pre-wrap", padding: "12px 0" }}>
+ {logText}
+ </div>
+ </ScrollableWindowRelative>
+ </Focusable>
+ );
+};
+
+export default LogViewModal;
diff --git a/frontend/src/components/logviewer/LoggedPlugin.tsx b/frontend/src/components/logviewer/LoggedPlugin.tsx
new file mode 100644
index 00000000..af7564e2
--- /dev/null
+++ b/frontend/src/components/logviewer/LoggedPlugin.tsx
@@ -0,0 +1,35 @@
+import { Focusable } from "decky-frontend-lib";
+import { VFC, useState } from "react";
+import { FaArrowDown, FaArrowUp } from "react-icons/fa";
+import LogList from "./LogList";
+
+interface LoggedPluginProps {
+ plugin: string;
+}
+
+const focusableStyle = {
+ background: "rgba(255,255,255,.15)",
+ borderRadius: "var(--round-radius-size)",
+ padding: "10px 24px",
+ marginBottom: "0.5rem",
+};
+
+const LoggedPlugin: VFC<LoggedPluginProps> = ({ plugin }) => {
+ const [isOpen, setOpen] = useState<boolean>(false);
+
+ return (
+ <div style={focusableStyle}>
+ <Focusable onOKButton={() => setOpen(!isOpen)}>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div style={{ flexGrow: 1, textAlign: "left" }}>{plugin}</div>
+ <div style={{ textAlign: "right" }}>
+ {isOpen ? <FaArrowUp /> : <FaArrowDown />}
+ </div>
+ </div>
+ </Focusable>
+ {isOpen && <LogList plugin={plugin} />}
+ </div>
+ );
+};
+
+export default LoggedPlugin; \ No newline at end of file
diff --git a/frontend/src/components/logviewer/ScrollableWindow.tsx b/frontend/src/components/logviewer/ScrollableWindow.tsx
new file mode 100644
index 00000000..c1d5e5b4
--- /dev/null
+++ b/frontend/src/components/logviewer/ScrollableWindow.tsx
@@ -0,0 +1,107 @@
+/*
+Big thanks to @jessebofil for this
+https://discord.com/channels/960281551428522045/960284327445418044/1209253688363716648
+*/
+
+import { Focusable, ModalPosition, GamepadButton, ScrollPanelGroup, gamepadDialogClasses, scrollPanelClasses, FooterLegendProps } from "decky-frontend-lib";
+import { FC, useLayoutEffect, useRef, useState } from "react";
+
+export interface ScrollableWindowProps extends FooterLegendProps {
+ height: string;
+ fadeAmount?: string;
+ scrollBarWidth?: string;
+ alwaysFocus?: boolean;
+ noScrollDescription?: boolean;
+
+ onActivate?: (e: CustomEvent) => void;
+ onCancel?: (e: CustomEvent) => void;
+}
+
+const ScrollableWindow: FC<ScrollableWindowProps> = ({ height, fadeAmount, scrollBarWidth, alwaysFocus, noScrollDescription, children, actionDescriptionMap, ...focusableProps }) => {
+ const fade = fadeAmount === undefined || fadeAmount === '' ? '10px' : fadeAmount;
+ const barWidth = scrollBarWidth === undefined || scrollBarWidth === '' ? '4px' : scrollBarWidth;
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const scrollPanelRef = useRef<HTMLElement>();
+
+ useLayoutEffect(() => {
+ const { current } = scrollPanelRef;
+ const trigger = () => {
+ if (current) {
+ const hasOverflow = current.scrollHeight > current.clientHeight;
+ setIsOverflowing(hasOverflow);
+ }
+ };
+ if (current) trigger();
+ }, [children, height]);
+
+ const panel = (
+ <ScrollPanelGroup
+ //@ts-ignore
+ ref={scrollPanelRef} focusable={false} style={{ flex: 1, minHeight: 0 }}>
+ <Focusable
+ //@ts-ignore
+ focusable={alwaysFocus || isOverflowing}
+ key={'scrollable-window-focusable-element'}
+ noFocusRing={true}
+ actionDescriptionMap={Object.assign(noScrollDescription ? {} :
+ {
+ [GamepadButton.DIR_UP]: 'Scroll Up',
+ [GamepadButton.DIR_DOWN]: 'Scroll Down'
+ },
+ actionDescriptionMap ?? {}
+ )}
+ {...focusableProps}
+ >
+ {children}
+ </Focusable>
+ </ScrollPanelGroup>
+ );
+
+ return (
+ <>
+ <style>
+ {`.modal-position-container .${gamepadDialogClasses.ModalPosition} {
+ top: 0;
+ bottom: 0;
+ padding: 0;
+ }
+ .modal-position-container .${scrollPanelClasses.ScrollPanel}::-webkit-scrollbar {
+ display: initial !important;
+ width: ${barWidth};
+ }
+ .modal-position-container .${scrollPanelClasses.ScrollPanel}::-webkit-scrollbar-thumb {
+ border: 0;
+ }`}
+ </style>
+ <div
+ className='modal-position-container'
+ style={{
+ position: 'relative',
+ height: height,
+ WebkitMask: `linear-gradient(to right , transparent, transparent calc(100% - ${barWidth}), white calc(100% - ${barWidth})), linear-gradient(to bottom, transparent, black ${fade}, black calc(100% - ${fade}), transparent 100%)`
+ }}>
+ {isOverflowing ? (
+ <ModalPosition key={'scrollable-window-modal-position'}>
+ {panel}
+ </ModalPosition>
+ ) : (
+ <div className={`${gamepadDialogClasses.ModalPosition} ${gamepadDialogClasses.WithStandardPadding} Panel`} key={'modal-position'}>
+ {panel}
+ </div>
+ )}
+ </div>
+ </>
+ );
+};
+
+interface ScrollableWindowAutoProps extends Omit<ScrollableWindowProps, 'height'> {
+ heightPercent?: number;
+}
+
+export const ScrollableWindowRelative: FC<ScrollableWindowAutoProps> = ({ heightPercent, ...props }) => {
+ return (
+ <div style={{ flex: 'auto' }}>
+ <ScrollableWindow height={`${heightPercent ?? 100}%`} {...props} />
+ </div>
+ );
+}; \ No newline at end of file
diff --git a/frontend/src/components/logviewer/index.tsx b/frontend/src/components/logviewer/index.tsx
new file mode 100644
index 00000000..6e9baae0
--- /dev/null
+++ b/frontend/src/components/logviewer/index.tsx
@@ -0,0 +1,20 @@
+import { DialogBody } from 'decky-frontend-lib';
+import { FC, useEffect, useState } from 'react';
+
+import LoggedPlugin from './LoggedPlugin';
+
+const LogViewerPage: FC<{}> = () => {
+ const [plugins, setPlugins] = useState([]);
+ useEffect(() => {
+ window.DeckyPluginLoader.callServerMethod('get_plugins_with_logs').then((plugins) => {
+ setPlugins(plugins.result || []);
+ });
+ }, []);
+ return (
+ <DialogBody>
+ {plugins.map((plugin) => <LoggedPlugin plugin={plugin} />)}
+ </DialogBody>
+ )
+};
+
+export default LogViewerPage; \ No newline at end of file
diff --git a/frontend/src/components/settings/index.tsx b/frontend/src/components/settings/index.tsx
index 80400058..8b7ef8df 100644
--- a/frontend/src/components/settings/index.tsx
+++ b/frontend/src/components/settings/index.tsx
@@ -1,13 +1,14 @@
import { SidebarNavigation } from 'decky-frontend-lib';
import { lazy } from 'react';
import { useTranslation } from 'react-i18next';
-import { FaCode, FaFlask, FaPlug } from 'react-icons/fa';
+import { FaCode, FaFileCode, FaFlask, FaPlug } from 'react-icons/fa';
import { useSetting } from '../../utils/hooks/useSetting';
import DeckyIcon from '../DeckyIcon';
import WithSuspense from '../WithSuspense';
import GeneralSettings from './pages/general';
import PluginList from './pages/plugin_list';
+import LogViewerPage from '../logviewer';
const DeveloperSettings = lazy(() => import('./pages/developer'));
const TestingMenu = lazy(() => import('./pages/testing'));
@@ -30,6 +31,16 @@ export default function SettingsPage() {
icon: <FaPlug />,
},
{
+ title: t('SettingsIndex.log_viewer', "Log Viewer"),
+ content: (
+ <WithSuspense>
+ <LogViewerPage/>
+ </WithSuspense>
+ ),
+ route: '/decky/settings/logs',
+ icon: <FaFileCode />
+ },
+ {
title: t('SettingsIndex.developer_title'),
content: (
<WithSuspense>
@@ -50,7 +61,7 @@ export default function SettingsPage() {
route: '/decky/settings/testing',
icon: <FaFlask />,
visible: isDeveloper,
- },
+ }
];
return <SidebarNavigation pages={pages} />;