Add debug log inspector / rageshake
This commit is contained in:
		
					parent
					
						
							
								91366585ff
							
						
					
				
			
			
				commit
				
					
						76b2e8b29e
					
				
			
		
					 9 changed files with 320 additions and 28 deletions
				
			
		
							
								
								
									
										3
									
								
								.env
									
										
									
									
									
								
							
							
						
						
									
										3
									
								
								.env
									
										
									
									
									
								
							|  | @ -7,6 +7,9 @@ | |||
| # Used for determining the homeserver to use for short urls etc. | ||||
| # VITE_DEFAULT_HOMESERVER=http://localhost:8008 | ||||
| 
 | ||||
| # Used for submitting debug logs to an external rageshake server | ||||
| # VITE_RAGESHAKE_SUBMIT_URL=http://localhost:9110/api/submit | ||||
| 
 | ||||
| # The Sentry DSN to use for error reporting. Leave undefined to disable. | ||||
| # VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 | ||||
| 
 | ||||
|  |  | |||
|  | @ -32,6 +32,7 @@ | |||
|     "matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call", | ||||
|     "mermaid": "^8.13.8", | ||||
|     "normalize.css": "^8.0.1", | ||||
|     "pako": "^2.0.4", | ||||
|     "postcss-preset-env": "^6.7.0", | ||||
|     "re-resizable": "^6.9.0", | ||||
|     "react": "^17.0.0", | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ import { RoomPage } from "./room/RoomPage"; | |||
| import { RoomRedirect } from "./room/RoomRedirect"; | ||||
| import { ClientProvider } from "./ClientContext"; | ||||
| import { usePageFocusStyle } from "./usePageFocusStyle"; | ||||
| import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage"; | ||||
| 
 | ||||
| const SentryRoute = Sentry.withSentryRouting(Route); | ||||
| 
 | ||||
|  | @ -48,6 +49,9 @@ export default function App({ history }) { | |||
|             <SentryRoute path="/room/:roomId?"> | ||||
|               <RoomPage /> | ||||
|             </SentryRoute> | ||||
|             <SentryRoute path="/inspector"> | ||||
|               <SequenceDiagramViewerPage /> | ||||
|             </SentryRoute> | ||||
|             <SentryRoute path="*"> | ||||
|               <RoomRedirect /> | ||||
|             </SentryRoute> | ||||
|  |  | |||
							
								
								
									
										38
									
								
								src/SequenceDiagramViewerPage.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/SequenceDiagramViewerPage.jsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | |||
| import React, { useCallback, useState } from "react"; | ||||
| import { SequenceDiagramViewer } from "./room/GroupCallInspector"; | ||||
| import { FieldRow, InputField } from "./input/Input"; | ||||
| 
 | ||||
| export function SequenceDiagramViewerPage() { | ||||
|   const [debugLog, setDebugLog] = useState(); | ||||
|   const [selectedUserId, setSelectedUserId] = useState(); | ||||
|   const onChangeDebugLog = useCallback((e) => { | ||||
|     if (e.target.files && e.target.files.length > 0) { | ||||
|       e.target.files[0].text().then((text) => { | ||||
|         setDebugLog(JSON.parse(text)); | ||||
|       }); | ||||
|     } | ||||
|   }, []); | ||||
| 
 | ||||
|   return ( | ||||
|     <div style={{ marginTop: 20 }}> | ||||
|       <FieldRow> | ||||
|         <InputField | ||||
|           type="file" | ||||
|           id="debugLog" | ||||
|           name="debugLog" | ||||
|           label="Debug Log" | ||||
|           onChange={onChangeDebugLog} | ||||
|         /> | ||||
|       </FieldRow> | ||||
|       {debugLog && ( | ||||
|         <SequenceDiagramViewer | ||||
|           localUserId={debugLog.localUserId} | ||||
|           selectedUserId={selectedUserId} | ||||
|           onSelectUserId={setSelectedUserId} | ||||
|           remoteUserIds={debugLog.remoteUserIds} | ||||
|           events={debugLog.eventsByUserId[selectedUserId]} | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | @ -22,6 +22,10 @@ import App from "./App"; | |||
| import * as Sentry from "@sentry/react"; | ||||
| import { Integrations } from "@sentry/tracing"; | ||||
| import { ErrorView } from "./FullScreenView"; | ||||
| import * as rageshake from "matrix-react-sdk/src/rageshake/rageshake"; | ||||
| import { InspectorContextProvider } from "./room/GroupCallInspector"; | ||||
| 
 | ||||
| rageshake.init(); | ||||
| 
 | ||||
| if (import.meta.env.VITE_CUSTOM_THEME) { | ||||
|   const style = document.documentElement.style; | ||||
|  | @ -59,7 +63,9 @@ Sentry.init({ | |||
| ReactDOM.render( | ||||
|   <React.StrictMode> | ||||
|     <Sentry.ErrorBoundary fallback={ErrorView}> | ||||
|       <InspectorContextProvider> | ||||
|         <App history={history} /> | ||||
|       </InspectorContextProvider> | ||||
|     </Sentry.ErrorBoundary> | ||||
|   </React.StrictMode>, | ||||
|   document.getElementById("root") | ||||
|  |  | |||
|  | @ -1,5 +1,12 @@ | |||
| import { Resizable } from "re-resizable"; | ||||
| import React, { useEffect, useState, useReducer, useRef } from "react"; | ||||
| import React, { | ||||
|   useEffect, | ||||
|   useState, | ||||
|   useReducer, | ||||
|   useRef, | ||||
|   createContext, | ||||
|   useContext, | ||||
| } from "react"; | ||||
| import ReactJson from "react-json-view"; | ||||
| import mermaid from "mermaid"; | ||||
| import styles from "./GroupCallInspector.module.css"; | ||||
|  | @ -90,7 +97,18 @@ function formatTimestamp(timestamp) { | |||
|   return dateFormatter.format(timestamp); | ||||
| } | ||||
| 
 | ||||
| function SequenceDiagramViewer({ | ||||
| export const InspectorContext = createContext(); | ||||
| 
 | ||||
| export function InspectorContextProvider({ children }) { | ||||
|   const context = useState({}); | ||||
|   return ( | ||||
|     <InspectorContext.Provider value={context}> | ||||
|       {children} | ||||
|     </InspectorContext.Provider> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export function SequenceDiagramViewer({ | ||||
|   localUserId, | ||||
|   remoteUserIds, | ||||
|   selectedUserId, | ||||
|  | @ -168,16 +186,16 @@ function reducer(state, action) { | |||
|         const fromId = event.getStateKey(); | ||||
| 
 | ||||
|         remoteUserIds = | ||||
|           fromId === state.localUserId || eventsByUserId.has(fromId) | ||||
|           fromId === state.localUserId || eventsByUserId[fromId] | ||||
|             ? state.remoteUserIds | ||||
|             : [...state.remoteUserIds, fromId]; | ||||
| 
 | ||||
|         eventsByUserId = new Map(state.eventsByUserId); | ||||
|         eventsByUserId = { ...state.eventsByUserId }; | ||||
| 
 | ||||
|         if (event.getStateKey() === state.localUserId) { | ||||
|           for (const userId in eventsByUserId) { | ||||
|             eventsByUserId.set(userId, [ | ||||
|               ...(eventsByUserId.get(userId) || []), | ||||
|             eventsByUserId[userId] = [ | ||||
|               ...(eventsByUserId[userId] || []), | ||||
|               { | ||||
|                 from: fromId, | ||||
|                 to: "Room", | ||||
|  | @ -186,11 +204,11 @@ function reducer(state, action) { | |||
|                 timestamp: event.getTs() || Date.now(), | ||||
|                 ignored: false, | ||||
|               }, | ||||
|             ]); | ||||
|             ]; | ||||
|           } | ||||
|         } else { | ||||
|           eventsByUserId.set(fromId, [ | ||||
|             ...(eventsByUserId.get(fromId) || []), | ||||
|           eventsByUserId[fromId] = [ | ||||
|             ...(eventsByUserId[fromId] || []), | ||||
|             { | ||||
|               from: fromId, | ||||
|               to: "Room", | ||||
|  | @ -199,7 +217,7 @@ function reducer(state, action) { | |||
|               timestamp: event.getTs() || Date.now(), | ||||
|               ignored: false, | ||||
|             }, | ||||
|           ]); | ||||
|           ]; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|  | @ -215,17 +233,17 @@ function reducer(state, action) { | |||
|     } | ||||
|     case "receive_to_device_event": { | ||||
|       const event = action.event; | ||||
|       const eventsByUserId = new Map(state.eventsByUserId); | ||||
|       const eventsByUserId = { ...state.eventsByUserId }; | ||||
|       const fromId = event.getSender(); | ||||
|       const toId = state.localUserId; | ||||
|       const content = event.getContent(); | ||||
| 
 | ||||
|       const remoteUserIds = eventsByUserId.has(fromId) | ||||
|       const remoteUserIds = eventsByUserId[fromId] | ||||
|         ? state.remoteUserIds | ||||
|         : [...state.remoteUserIds, fromId]; | ||||
| 
 | ||||
|       eventsByUserId.set(fromId, [ | ||||
|         ...(eventsByUserId.get(fromId) || []), | ||||
|       eventsByUserId[fromId] = [ | ||||
|         ...(eventsByUserId[fromId] || []), | ||||
|         { | ||||
|           from: fromId, | ||||
|           to: toId, | ||||
|  | @ -234,22 +252,22 @@ function reducer(state, action) { | |||
|           timestamp: event.getTs() || Date.now(), | ||||
|           ignored: state.localSessionId !== content.dest_session_id, | ||||
|         }, | ||||
|       ]); | ||||
|       ]; | ||||
| 
 | ||||
|       return { ...state, eventsByUserId, remoteUserIds }; | ||||
|     } | ||||
|     case "send_voip_event": { | ||||
|       const event = action.event; | ||||
|       const eventsByUserId = new Map(state.eventsByUserId); | ||||
|       const eventsByUserId = { ...state.eventsByUserId }; | ||||
|       const fromId = state.localUserId; | ||||
|       const toId = event.userId; | ||||
| 
 | ||||
|       const remoteUserIds = eventsByUserId.has(toId) | ||||
|       const remoteUserIds = eventsByUserId[toId] | ||||
|         ? state.remoteUserIds | ||||
|         : [...state.remoteUserIds, toId]; | ||||
| 
 | ||||
|       eventsByUserId.set(toId, [ | ||||
|         ...(eventsByUserId.get(toId) || []), | ||||
|       eventsByUserId[toId] = [ | ||||
|         ...(eventsByUserId[toId] || []), | ||||
|         { | ||||
|           from: fromId, | ||||
|           to: toId, | ||||
|  | @ -258,7 +276,7 @@ function reducer(state, action) { | |||
|           timestamp: Date.now(), | ||||
|           ignored: false, | ||||
|         }, | ||||
|       ]); | ||||
|       ]; | ||||
| 
 | ||||
|       return { ...state, eventsByUserId, remoteUserIds }; | ||||
|     } | ||||
|  | @ -271,7 +289,7 @@ function useGroupCallState(client, groupCall, pollCallStats) { | |||
|   const [state, dispatch] = useReducer(reducer, { | ||||
|     localUserId: client.getUserId(), | ||||
|     localSessionId: client.getSessionId(), | ||||
|     eventsByUserId: new Map(), | ||||
|     eventsByUserId: {}, | ||||
|     remoteUserIds: [], | ||||
|     callStateEvent: null, | ||||
|     memberStateEvents: {}, | ||||
|  | @ -399,6 +417,12 @@ export function GroupCallInspector({ client, groupCall, show }) { | |||
|   const [selectedUserId, setSelectedUserId] = useState(); | ||||
|   const state = useGroupCallState(client, groupCall, show); | ||||
| 
 | ||||
|   const [_, setState] = useContext(InspectorContext); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setState({ json: state }); | ||||
|   }, [setState, state]); | ||||
| 
 | ||||
|   if (!show) { | ||||
|     return null; | ||||
|   } | ||||
|  | @ -421,16 +445,13 @@ export function GroupCallInspector({ client, groupCall, show }) { | |||
|           selectedUserId={selectedUserId} | ||||
|           onSelectUserId={setSelectedUserId} | ||||
|           remoteUserIds={state.remoteUserIds} | ||||
|           events={state.eventsByUserId.get(selectedUserId)} | ||||
|           events={state.eventsByUserId[selectedUserId]} | ||||
|         /> | ||||
|       )} | ||||
|       {currentTab === "inspector" && ( | ||||
|         <ReactJson | ||||
|           theme="monokai" | ||||
|           src={{ | ||||
|             ...state, | ||||
|             eventsByUserId: Object.fromEntries(state.eventsByUserId), | ||||
|           }} | ||||
|           src={state} | ||||
|           name={null} | ||||
|           indentWidth={2} | ||||
|           shouldCollapse={shouldCollapse} | ||||
|  |  | |||
|  | @ -9,6 +9,8 @@ import { SelectInput } from "../input/SelectInput"; | |||
| import { Item } from "@react-stately/collections"; | ||||
| import { useMediaHandler } from "./useMediaHandler"; | ||||
| import { FieldRow, InputField } from "../input/Input"; | ||||
| import { Button } from "../button"; | ||||
| import { useSubmitRageshake } from "./useSubmitRageshake"; | ||||
| 
 | ||||
| export function SettingsModal({ | ||||
|   client, | ||||
|  | @ -25,6 +27,8 @@ export function SettingsModal({ | |||
|     setVideoInput, | ||||
|   } = useMediaHandler(client); | ||||
| 
 | ||||
|   const { submitRageshake, downloadDebugLog } = useSubmitRageshake(); | ||||
| 
 | ||||
|   return ( | ||||
|     <Modal | ||||
|       title="Settings" | ||||
|  | @ -88,6 +92,12 @@ export function SettingsModal({ | |||
|               onChange={(e) => setShowInspector(e.target.checked)} | ||||
|             /> | ||||
|           </FieldRow> | ||||
|           <FieldRow> | ||||
|             <Button onPress={submitRageshake}>Send Debug Logs</Button> | ||||
|           </FieldRow> | ||||
|           <FieldRow> | ||||
|             <Button onPress={downloadDebugLog}>Download Debug Logs</Button> | ||||
|           </FieldRow> | ||||
|         </TabItem> | ||||
|       </TabContainer> | ||||
|     </Modal> | ||||
|  |  | |||
							
								
								
									
										209
									
								
								src/settings/useSubmitRageshake.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								src/settings/useSubmitRageshake.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,209 @@ | |||
| import { useCallback, useContext } from "react"; | ||||
| import * as rageshake from "matrix-react-sdk/src/rageshake/rageshake"; | ||||
| import pako from "pako"; | ||||
| import { useClient } from "../ClientContext"; | ||||
| import { InspectorContext } from "../room/GroupCallInspector"; | ||||
| 
 | ||||
| export function useSubmitRageshake() { | ||||
|   const { client } = useClient(); | ||||
|   const [{ json, svg }] = useContext(InspectorContext); | ||||
| 
 | ||||
|   const submitRageshake = useCallback( | ||||
|     async (opts) => { | ||||
|       let userAgent = "UNKNOWN"; | ||||
|       if (window.navigator && window.navigator.userAgent) { | ||||
|         userAgent = window.navigator.userAgent; | ||||
|       } | ||||
| 
 | ||||
|       let touchInput = "UNKNOWN"; | ||||
|       try { | ||||
|         // MDN claims broad support across browsers
 | ||||
|         touchInput = String(window.matchMedia("(pointer: coarse)").matches); | ||||
|       } catch (e) {} | ||||
| 
 | ||||
|       const body = new FormData(); | ||||
|       body.append( | ||||
|         "text", | ||||
|         opts.description || "User did not supply any additional text." | ||||
|       ); | ||||
|       body.append("app", "matrix-video-chat"); | ||||
|       body.append("version", "dev"); | ||||
|       body.append("user_agent", userAgent); | ||||
|       body.append("installed_pwa", false); | ||||
|       body.append("touch_input", touchInput); | ||||
| 
 | ||||
|       if (client) { | ||||
|         body.append("user_id", client.credentials.userId); | ||||
|         body.append("device_id", client.deviceId); | ||||
| 
 | ||||
|         if (client.isCryptoEnabled()) { | ||||
|           const keys = [`ed25519:${client.getDeviceEd25519Key()}`]; | ||||
|           if (client.getDeviceCurve25519Key) { | ||||
|             keys.push(`curve25519:${client.getDeviceCurve25519Key()}`); | ||||
|           } | ||||
|           body.append("device_keys", keys.join(", ")); | ||||
|           body.append("cross_signing_key", client.getCrossSigningId()); | ||||
| 
 | ||||
|           // add cross-signing status information
 | ||||
|           const crossSigning = client.crypto.crossSigningInfo; | ||||
|           const secretStorage = client.crypto.secretStorage; | ||||
| 
 | ||||
|           body.append( | ||||
|             "cross_signing_ready", | ||||
|             String(await client.isCrossSigningReady()) | ||||
|           ); | ||||
|           body.append( | ||||
|             "cross_signing_supported_by_hs", | ||||
|             String( | ||||
|               await client.doesServerSupportUnstableFeature( | ||||
|                 "org.matrix.e2e_cross_signing" | ||||
|               ) | ||||
|             ) | ||||
|           ); | ||||
|           body.append("cross_signing_key", crossSigning.getId()); | ||||
|           body.append( | ||||
|             "cross_signing_privkey_in_secret_storage", | ||||
|             String( | ||||
|               !!(await crossSigning.isStoredInSecretStorage(secretStorage)) | ||||
|             ) | ||||
|           ); | ||||
| 
 | ||||
|           const pkCache = client.getCrossSigningCacheCallbacks(); | ||||
|           body.append( | ||||
|             "cross_signing_master_privkey_cached", | ||||
|             String( | ||||
|               !!(pkCache && (await pkCache.getCrossSigningKeyCache("master"))) | ||||
|             ) | ||||
|           ); | ||||
|           body.append( | ||||
|             "cross_signing_self_signing_privkey_cached", | ||||
|             String( | ||||
|               !!( | ||||
|                 pkCache && | ||||
|                 (await pkCache.getCrossSigningKeyCache("self_signing")) | ||||
|               ) | ||||
|             ) | ||||
|           ); | ||||
|           body.append( | ||||
|             "cross_signing_user_signing_privkey_cached", | ||||
|             String( | ||||
|               !!( | ||||
|                 pkCache && | ||||
|                 (await pkCache.getCrossSigningKeyCache("user_signing")) | ||||
|               ) | ||||
|             ) | ||||
|           ); | ||||
| 
 | ||||
|           body.append( | ||||
|             "secret_storage_ready", | ||||
|             String(await client.isSecretStorageReady()) | ||||
|           ); | ||||
|           body.append( | ||||
|             "secret_storage_key_in_account", | ||||
|             String(!!(await secretStorage.hasKey())) | ||||
|           ); | ||||
| 
 | ||||
|           body.append( | ||||
|             "session_backup_key_in_secret_storage", | ||||
|             String(!!(await client.isKeyBackupKeyStored())) | ||||
|           ); | ||||
|           const sessionBackupKeyFromCache = | ||||
|             await client.crypto.getSessionBackupPrivateKey(); | ||||
|           body.append( | ||||
|             "session_backup_key_cached", | ||||
|             String(!!sessionBackupKeyFromCache) | ||||
|           ); | ||||
|           body.append( | ||||
|             "session_backup_key_well_formed", | ||||
|             String(sessionBackupKeyFromCache instanceof Uint8Array) | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (opts.label) { | ||||
|         body.append("label", opts.label); | ||||
|       } | ||||
| 
 | ||||
|       // add storage persistence/quota information
 | ||||
|       if (navigator.storage && navigator.storage.persisted) { | ||||
|         try { | ||||
|           body.append( | ||||
|             "storageManager_persisted", | ||||
|             String(await navigator.storage.persisted()) | ||||
|           ); | ||||
|         } catch (e) {} | ||||
|       } else if (document.hasStorageAccess) { | ||||
|         // Safari
 | ||||
|         try { | ||||
|           body.append( | ||||
|             "storageManager_persisted", | ||||
|             String(await document.hasStorageAccess()) | ||||
|           ); | ||||
|         } catch (e) {} | ||||
|       } | ||||
| 
 | ||||
|       if (navigator.storage && navigator.storage.estimate) { | ||||
|         try { | ||||
|           const estimate = await navigator.storage.estimate(); | ||||
|           body.append("storageManager_quota", String(estimate.quota)); | ||||
|           body.append("storageManager_usage", String(estimate.usage)); | ||||
|           if (estimate.usageDetails) { | ||||
|             Object.keys(estimate.usageDetails).forEach((k) => { | ||||
|               body.append( | ||||
|                 `storageManager_usage_${k}`, | ||||
|                 String(estimate.usageDetails[k]) | ||||
|               ); | ||||
|             }); | ||||
|           } | ||||
|         } catch (e) {} | ||||
|       } | ||||
| 
 | ||||
|       const logs = await rageshake.getLogsForReport(); | ||||
| 
 | ||||
|       for (const entry of logs) { | ||||
|         // encode as UTF-8
 | ||||
|         let buf = new TextEncoder().encode(entry.lines); | ||||
| 
 | ||||
|         // compress
 | ||||
|         buf = pako.gzip(buf); | ||||
| 
 | ||||
|         body.append("compressed-log", new Blob([buf]), entry.id); | ||||
|       } | ||||
| 
 | ||||
|       if (json) { | ||||
|         body.append( | ||||
|           "file", | ||||
|           new Blob([JSON.stringify(json)], { type: "text/plain" }), | ||||
|           "groupcall.txt" | ||||
|         ); | ||||
|       } | ||||
| 
 | ||||
|       await fetch( | ||||
|         import.meta.env.VITE_RAGESHAKE_SUBMIT_URL || | ||||
|           "https://element.io/bugreports/submit", | ||||
|         { | ||||
|           method: "POST", | ||||
|           body, | ||||
|         } | ||||
|       ); | ||||
|     }, | ||||
|     [client] | ||||
|   ); | ||||
| 
 | ||||
|   const downloadDebugLog = useCallback(() => { | ||||
|     const blob = new Blob([JSON.stringify(json)], { type: "application/json" }); | ||||
|     const url = URL.createObjectURL(blob); | ||||
|     const el = document.createElement("a"); | ||||
|     el.href = url; | ||||
|     el.download = "groupcall.json"; | ||||
|     el.style.display = "none"; | ||||
|     document.body.appendChild(el); | ||||
|     el.click(); | ||||
|     setTimeout(() => { | ||||
|       URL.revokeObjectURL(url); | ||||
|       el.parentNode.removeChild(el); | ||||
|     }, 0); | ||||
|   }); | ||||
| 
 | ||||
|   return { submitRageshake, downloadDebugLog }; | ||||
| } | ||||
|  | @ -9273,7 +9273,7 @@ p-try@^2.0.0: | |||
|   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" | ||||
|   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== | ||||
| 
 | ||||
| pako@^2.0.3: | ||||
| pako@^2.0.3, pako@^2.0.4: | ||||
|   version "2.0.4" | ||||
|   resolved "https://registry.yarnpkg.com/pako/-/pako-2.0.4.tgz#6cebc4bbb0b6c73b0d5b8d7e8476e2b2fbea576d" | ||||
|   integrity sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg== | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue