diff --git a/devtools/src/server.rs b/devtools/src/server.rs index c1b290b4..5e9ffcd6 100644 --- a/devtools/src/server.rs +++ b/devtools/src/server.rs @@ -5,6 +5,7 @@ use futures::{FutureExt, Stream, TryStreamExt}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tauri::http::header::HeaderValue; use tauri::{AppHandle, Runtime}; use tauri_devtools_wire_format as wire; @@ -12,7 +13,10 @@ use tauri_devtools_wire_format::instrument; use tauri_devtools_wire_format::instrument::instrument_server::InstrumentServer; use tauri_devtools_wire_format::instrument::{instrument_server, InstrumentRequest}; use tauri_devtools_wire_format::meta::metadata_server::MetadataServer; -use tauri_devtools_wire_format::meta::{metadata_server, AppMetadata, AppMetadataRequest}; +use tauri_devtools_wire_format::meta::{ + metadata_server, AppMetadata, AppMetadataRequest, InstrumentationMetadata, + InstrumentationMetadataRequest, +}; use tauri_devtools_wire_format::sources::sources_server::SourcesServer; use tauri_devtools_wire_format::sources::{Chunk, Entry, EntryRequest, FileType}; use tauri_devtools_wire_format::tauri::tauri_server::TauriServer; @@ -65,12 +69,14 @@ struct SourcesService { struct MetaService { app_handle: AppHandle, + publish_interval: Duration, } impl Server { pub fn new( cmd_tx: mpsc::Sender, app_handle: AppHandle, + publish_interval: Duration, metrics: Arc>, ) -> Self { let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); @@ -95,6 +101,7 @@ impl Server { }, // the TauriServer doesn't need a health_reporter. It can never fail. meta: MetaService { app_handle: app_handle.clone(), + publish_interval, }, sources: SourcesService { app_handle }, health: unsafe { std::mem::transmute(health_service) }, @@ -392,6 +399,21 @@ impl metadata_server::Metadata for MetaService { Ok(Response::new(meta)) } + + async fn get_instrumentation_metadata( + &self, + _req: Request, + ) -> Result, Status> { + let duration = prost_types::Duration { + seconds: self.publish_interval.as_secs() as i64, + nanos: self.publish_interval.subsec_nanos() as i32, + }; + + Ok(Response::new(InstrumentationMetadata { + version: env!("CARGO_PKG_VERSION").to_string(), + publish_interval: Some(duration), + })) + } } #[cfg(test)] diff --git a/devtools/src/tauri_plugin.rs b/devtools/src/tauri_plugin.rs index 982f9199..f1988c26 100644 --- a/devtools/src/tauri_plugin.rs +++ b/devtools/src/tauri_plugin.rs @@ -25,7 +25,7 @@ pub(crate) fn init( let m = metrics.clone(); tauri::plugin::Builder::new("probe") .setup(move |app_handle| { - let server = Server::new(cmd_tx, app_handle.clone(), m); + let server = Server::new(cmd_tx, app_handle.clone(), publish_interval, m); // spawn the server and aggregator in a separate thread // so we don't interfere with the application we're trying to instrument diff --git a/web-client/package.json b/web-client/package.json index 5e7ad261..2547e7a0 100644 --- a/web-client/package.json +++ b/web-client/package.json @@ -43,7 +43,9 @@ }, "dependencies": { "@crabnebula/file-icons": "^0.1.0", + "@iarna/toml": "^2.2.5", "@kobalte/core": "^0.11.2", + "@kobalte/tailwindcss": "^0.9.0", "@protobuf-ts/grpcweb-transport": "^2.9.1", "@protobuf-ts/plugin": "^2.9.1", "@protobuf-ts/runtime": "^2.9.1", diff --git a/web-client/pnpm-lock.yaml b/web-client/pnpm-lock.yaml index c6c1b3c4..bd48ab7c 100644 --- a/web-client/pnpm-lock.yaml +++ b/web-client/pnpm-lock.yaml @@ -8,9 +8,15 @@ dependencies: '@crabnebula/file-icons': specifier: ^0.1.0 version: 0.1.0 + '@iarna/toml': + specifier: ^2.2.5 + version: 2.2.5 '@kobalte/core': specifier: ^0.11.2 version: 0.11.2(solid-js@1.8.5) + '@kobalte/tailwindcss': + specifier: ^0.9.0 + version: 0.9.0(tailwindcss@3.3.5) '@protobuf-ts/grpcweb-transport': specifier: ^2.9.1 version: 2.9.1 @@ -786,6 +792,10 @@ packages: resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} dev: true + /@iarna/toml@2.2.5: + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + dev: false + /@internationalized/date@3.5.0: resolution: {integrity: sha512-nw0Q+oRkizBWMioseI8+2TeUPEyopJVz5YxoYVzR0W1v+2YytiYah7s/ot35F149q/xAg4F1gT/6eTd+tsUpFQ==} dependencies: @@ -874,6 +884,14 @@ packages: solid-js: 1.8.5 dev: false + /@kobalte/tailwindcss@0.9.0(tailwindcss@3.3.5): + resolution: {integrity: sha512-WbueJTVRiO4yrmfHIBwp07y3M5iibJ/gauEAQ7mOyg1tZulvpO7SM/UdgzX95a9a0KDt1mQFxwO7RmpOUXWOWA==} + peerDependencies: + tailwindcss: ^3.3.3 + dependencies: + tailwindcss: 3.3.5 + dev: false + /@kobalte/utils@0.9.0(solid-js@1.8.5): resolution: {integrity: sha512-TYVCpQcpqo1+0HBn3NXoGEBzxd4tH6Um1oc07nrYw1V7Qq0qbMaYAOnfBc1qhlh7sGV4XZldmb0j13Of0FrZQg==} peerDependencies: diff --git a/web-client/src/components/autoscroll-pane.tsx b/web-client/src/components/autoscroll-pane.tsx index 8c21abb2..ab748f08 100644 --- a/web-client/src/components/autoscroll-pane.tsx +++ b/web-client/src/components/autoscroll-pane.tsx @@ -1,4 +1,4 @@ -import { Accessor, JSX, createEffect, on } from "solid-js"; +import { Accessor, JSXElement, createEffect, on } from "solid-js"; function scrollEnd(ref?: HTMLElement, smooth?: boolean) { ref?.scroll({ @@ -14,7 +14,7 @@ function scrollEnd(ref?: HTMLElement, smooth?: boolean) { type AutoScrollPaneProps = { dataStream: unknown; shouldAutoScroll: Accessor; - children: JSX.Element; + children: JSXElement; }; export function AutoscrollPane(props: AutoScrollPaneProps) { diff --git a/web-client/src/components/boot-time.tsx b/web-client/src/components/boot-time.tsx index b8fc312a..12af1dfd 100644 --- a/web-client/src/components/boot-time.tsx +++ b/web-client/src/components/boot-time.tsx @@ -1,16 +1,16 @@ import { Show } from "solid-js"; -import { useMonitor } from "~/lib/connection/monitor"; +import { Loader } from "./loader"; +import { useMonitor } from "~/context/monitor-provider"; export function BootTime() { const { monitorData } = useMonitor(); - return ( - + }> {(e) => (
Loading time: - {Number(e().seconds) * 1000 + e().nanos / 1e6}ms + {(Number(e().seconds) * 1000 + e().nanos / 1e6).toFixed(2)}ms
)} diff --git a/web-client/src/components/disconnect-button.tsx b/web-client/src/components/disconnect-button.tsx index 647d6084..31bdb682 100644 --- a/web-client/src/components/disconnect-button.tsx +++ b/web-client/src/components/disconnect-button.tsx @@ -1,16 +1,19 @@ import { Button } from "@kobalte/core"; +import { useNavigate } from "@solidjs/router"; +import { useConnection } from "~/context/connection-provider"; -type DisconnectProps = { - closeSession: () => void; -}; - -export function DisconnectButton(props: DisconnectProps) { +export function DisconnectButton() { + const { connectionStore } = useConnection(); + const goto = useNavigate(); return ( { + connectionStore.abortController.abort(); + goto("/"); + }} > Close connection props.dismissible ?? true; + + return ( + + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion*/} + + +
+ +
+ {props.title} +
+ + {props.children} + +
+ { + window.location.reload(); + }} + > + Reload route + + + + Dismiss + + + + Reset App + +
+
+
+ + + ); +} diff --git a/web-client/src/components/error-root.tsx b/web-client/src/components/error-root.tsx new file mode 100644 index 00000000..5107f496 --- /dev/null +++ b/web-client/src/components/error-root.tsx @@ -0,0 +1,68 @@ +import * as pkg from "~/../package.json"; + +type Props = { + error: unknown; +}; + +export function ErrorRoot(props: Props) { + return ( + <> +
+ +
+
+
+

+ Irrecoverable Error +

+
+

Something terrible happened.

+

The log is on the way and we'll work on it!

+
+
+
+
+

System log

+
+              {props.error?.toString() || String(props.error)}
+            
+
    +
  • App version: {pkg.version}
  • +
  • Browser: {window.navigator.userAgent}
  • +
+
+
+ + Reset App + + +
+
+ +
+ + ); +} diff --git a/web-client/src/components/health-status.tsx b/web-client/src/components/health-status.tsx index ba88c362..7ebf3565 100644 --- a/web-client/src/components/health-status.tsx +++ b/web-client/src/components/health-status.tsx @@ -1,47 +1,88 @@ -import { useMonitor } from "~/lib/connection/monitor"; -import { Tooltip } from "@kobalte/core"; import { HealthCheckResponse_ServingStatus } from "~/lib/proto/health"; +import { Show, createEffect, createSignal, onMount } from "solid-js"; +import { ErrorDialog } from "./error-dialog"; +import { addStreamListneners, connect } from "~/lib/connection/transport"; +import { reconcile } from "solid-js/store"; +import { useConnection } from "~/context/connection-provider"; +import { useMonitor } from "~/context/monitor-provider"; + +const variant = (status: HealthCheckResponse_ServingStatus) => { + return [ + // unknown + { + style: "inline-block mr-3 w-3 h-3 bg-gray-200 rounded-full", + tooltip: "Disconnected", + }, + // serving + { + style: "inline-block mr-3 w-3 h-3 bg-green-500 rounded-full", + tooltip: "Connected", + }, + // not serving + { + style: "inline-block mr-3 w-3 h-3 bg-red-500 rounded-full", + tooltip: "Disconnected", + }, + ][status]; +}; export function HealthStatus() { - const { monitorData } = useMonitor(); - - const variant = (status: HealthCheckResponse_ServingStatus) => { - return [ - // unknown - { - style: "flex w-3 h-3 bg-gray-200 rounded-full", - tooltip: "Instrumentation is not connected", - }, - // serving - { - style: "flex w-3 h-3 bg-green-500 rounded-full", - tooltip: "Instrumentation is operating normally", - }, - // not serving - { - style: "flex w-3 h-3 bg-red-500 rounded-full", - tooltip: "Instrumentation not operational", - }, - ][status]; + const updateErrorHandler = () => { + console.error("an error happened on Updates Stream"); + // if (isConnectionDead()) { + /** + * we do nothing because it's not an instrumentation issue. + */ + return; + // } }; + const healthErrorHandler = () => { + console.error("an error happened on Health Stream"); + setMonitorData("health", 0); + setConnectionDead(true); + + /** cleanup possible connections */ + connectionStore.abortController.abort(); + reconnect(); + }; + + function reconnect() { + const newConnection = connect(connectionStore.serviceUrl); + setConnection(reconcile(newConnection, { merge: false })); + + addStreamListneners(connectionStore.stream.update, setMonitorData); + connectionStore.stream.health.responses.onError(healthErrorHandler); + connectionStore.stream.update.responses.onError(updateErrorHandler); + } + + const { connectionStore, setConnection } = useConnection(); + const { monitorData, setMonitorData } = useMonitor(); + const [isConnectionDead, setConnectionDead] = createSignal(false); + + onMount(() => { + connectionStore.stream.health.responses.onError(healthErrorHandler); + connectionStore.stream.update.responses.onError(updateErrorHandler); + }); + + createEffect(() => { + if (monitorData.health === 1 && isConnectionDead()) { + setConnectionDead(false); + } + }); + return (
- - - - - - - -

{variant(monitorData.health).tooltip}

-
-
-
+ + +

Streaming has stopped.

+

+ Waiting on new signal from your Tauri app. +

+
+
+ + {variant(monitorData.health).tooltip}
); } diff --git a/web-client/src/components/incompatible-instrumentation-version.tsx b/web-client/src/components/incompatible-instrumentation-version.tsx new file mode 100644 index 00000000..7e4eea0f --- /dev/null +++ b/web-client/src/components/incompatible-instrumentation-version.tsx @@ -0,0 +1,60 @@ +import {createResource, Show, Suspense} from "solid-js"; +import {createHighlighter, getHighlightedCode} from "~/lib/code-highlight.ts"; +import {Highlighter} from "shiki"; +import {getInstrumentationMetadata} from "~/lib/connection/getters.ts"; +import {useConnection} from "~/context/connection-provider.tsx"; +import {Loader} from "~/components/loader.tsx"; +import {ErrorDialog} from "~/components/error-dialog.tsx"; + +export default function IncompatibleInstrumentationVersion() { + const {connectionStore} = useConnection(); + const [instrumentationMetadata] = getInstrumentationMetadata(connectionStore.client.meta); + + // The used highlighter does not change at all atm so it does not need to be coupled + const [highlighter] = createResource(() => createHighlighter()); + + const html = (text: string | undefined, highlighter: Highlighter | undefined, highlightedLine?: number) => { + if (!text || !highlighter) return undefined; + return getHighlightedCode([text, highlighter, 'toml', highlightedLine]) + }; + + const expectedVersion = window.INSTRUMENTATION_VERSION; + + console.log(expectedVersion) + + const codeSnippet = `[dependencies] +# ... +tauri-devtools = "${expectedVersion}" +# ... +` + + return }> + + +

You are using an outdated version of the tauri-devtools Rust instrumentation.

+

Please update the crate to continue.

+

How to update:

+

+ Modify your Cargo.toml file, so that the tauri-devtools version + reads {expectedVersion}. +

+
+

After you have updated the version, restart your Tauri app and then click "Reset App" below.

+

+ If this error persists, feel free to reach out on the{" "} + + Tauri Discord: #CrabNebula + + . +

+ + + +} \ No newline at end of file diff --git a/web-client/src/components/sources/code-view.tsx b/web-client/src/components/sources/code-view.tsx index 205f0469..c443fcf1 100644 --- a/web-client/src/components/sources/code-view.tsx +++ b/web-client/src/components/sources/code-view.tsx @@ -1,7 +1,7 @@ +import { Highlighter } from "shiki"; import { Suspense, createResource } from "solid-js"; -import { Connection } from "~/lib/connection/transport.ts"; -import { useRouteData } from "@solidjs/router"; import { Loader } from "~/components/loader"; +import { useConnection } from "~/context/connection-provider"; import { HighlighterLang, createHighlighter, @@ -9,8 +9,6 @@ import { getText, } from "~/lib/code-highlight"; -import { Highlighter } from "shiki"; - type CodeViewProps = { path: string; size: number; @@ -19,13 +17,10 @@ type CodeViewProps = { }; export default function CodeView(props: CodeViewProps) { - const { client } = useRouteData(); - - // We split the computations into 3 steps. This decouples them from the reactive props they don't need to react to + const { connectionStore } = useConnection(); - // The text only needs to be computed when the the source changes const [text] = createResource( - () => [client.sources, props.path, props.size] as const, + () => [connectionStore.client.sources, props.path, props.size] as const, async (textProps) => getText(...textProps) ); diff --git a/web-client/src/components/sources/directory.tsx b/web-client/src/components/sources/directory.tsx index f6584eba..d43c839c 100644 --- a/web-client/src/components/sources/directory.tsx +++ b/web-client/src/components/sources/directory.tsx @@ -5,10 +5,10 @@ import { mergeProps, Show, Suspense, + untrack, } from "solid-js"; import { Entry } from "~/lib/proto/sources.ts"; -import { A, useRouteData } from "@solidjs/router"; -import { Connection } from "~/lib/connection/transport.ts"; +import { A } from "@solidjs/router"; import { Collapsible } from "@kobalte/core"; import CaretDown from "~/components/icons/caret-down.tsx"; import CaretRight from "~/components/icons/caret-right.tsx"; @@ -21,6 +21,7 @@ import { encodeFileName, } from "~/lib/sources/file-entries"; import { Loader } from "~/components/loader"; +import { useConnection } from "~/context/connection-provider"; type DirectoryProps = { parent?: Entry["path"]; @@ -40,11 +41,11 @@ type TreeEntryProps = { const liStyles = "hover:bg-gray-800 hover:text-white focus:bg-gray-800"; export function Directory(props: DirectoryProps) { - const { client } = useRouteData(); - const path = props.parent - ? `${props.parent}/${props.defaultPath}` - : props.defaultPath; - const [entries] = awaitEntries(client.sources, path); + const { connectionStore } = useConnection(); + const path = untrack(() => + props.parent ? `${props.parent}/${props.defaultPath}` : props.defaultPath + ); + const [entries] = awaitEntries(connectionStore.client.sources, path); const sortedEntries = () => entries()?.sort(sortByPath); return ( diff --git a/web-client/src/components/sources/image-view.tsx b/web-client/src/components/sources/image-view.tsx index 82740d25..c5e7f566 100644 --- a/web-client/src/components/sources/image-view.tsx +++ b/web-client/src/components/sources/image-view.tsx @@ -1,12 +1,11 @@ import { createResource, Show } from "solid-js"; -import { useRouteData } from "@solidjs/router"; -import { Connection } from "~/lib/connection/transport.ts"; +import { useConnection } from "~/context/connection-provider"; import { decodeFileName, getEntryBytes } from "~/lib/sources/file-entries.ts"; export function ImageView(props: { path: string; size: number; type: string }) { - const { client } = useRouteData(); + const { connectionStore } = useConnection(); const [bytes] = createResource( - () => [client.sources, props.path, props.size] as const, + () => [connectionStore.client.sources, props.path, props.size] as const, ([client, path, size]) => getEntryBytes(client, decodeFileName(path), size) ); diff --git a/web-client/src/components/span/span-detail.tsx b/web-client/src/components/span/span-detail.tsx index 61c1fd3d..f8f65462 100644 --- a/web-client/src/components/span/span-detail.tsx +++ b/web-client/src/components/span/span-detail.tsx @@ -1,5 +1,4 @@ import { For, createResource, Show } from "solid-js"; -import { useMonitor } from "~/lib/connection/monitor"; import { formatSpansForUi } from "~/lib/span/format-spans-for-ui"; import { getIpcRequestValues } from "~/lib/span/get-ipc-request-value"; import { createHighlighter, getHighlightedCode } from "~/lib/code-highlight"; @@ -9,6 +8,7 @@ import { getChildrenList } from "~/lib/span/get-children-list"; import { SpanDetailTrace } from "./span-detail-trace"; import { SpanDetailArgs } from "./span-detail-args"; import { isIpcSpanName } from "~/lib/span/isIpcSpanName"; +import { useMonitor } from "~/context/monitor-provider"; export function SpanDetail() { const [searchParams] = useSearchParams(); diff --git a/web-client/src/components/status-indicator.tsx b/web-client/src/components/status-indicator.tsx deleted file mode 100644 index d6041637..00000000 --- a/web-client/src/components/status-indicator.tsx +++ /dev/null @@ -1,12 +0,0 @@ -type Props = { - status: "on" | "off"; -}; -export const StatusIndicator = (props: Props) => { - let className = "bg-gray-500"; - - if (props.status === "on") { - className = "bg-emerald-500"; - } - - return
; -}; diff --git a/web-client/src/context/connection-provider.tsx b/web-client/src/context/connection-provider.tsx new file mode 100644 index 00000000..d591135d --- /dev/null +++ b/web-client/src/context/connection-provider.tsx @@ -0,0 +1,38 @@ +import { + JSXElement, + createContext, + onCleanup, + untrack, + useContext, +} from "solid-js"; +import { setup } from "~/lib/connection/transport"; + +type ProviderProps = { + host: string; + port: string; + children: JSXElement; +}; + +const ConnectionContext = createContext>(); + +export function useConnection() { + const ctx = useContext(ConnectionContext); + + if (!ctx) throw new Error("can not find ConnectionContext.Provider"); + return ctx; +} + +export function ConnectionProvider(props: ProviderProps) { + const url = untrack(() => `http://${props.host}:${props.port}`); + const connection = setup(url); + + onCleanup(() => { + connection.connectionStore.abortController.abort(); + }); + + return ( + + {props.children} + + ); +} diff --git a/web-client/src/context/monitor-provider.tsx b/web-client/src/context/monitor-provider.tsx new file mode 100644 index 00000000..684ab5eb --- /dev/null +++ b/web-client/src/context/monitor-provider.tsx @@ -0,0 +1,56 @@ +import { + type JSXElement, + createEffect, + Show, + createContext, + useContext, +} from "solid-js"; +import { SetStoreFunction, createStore } from "solid-js/store"; +import { getTauriConfig, getTauriMetrics } from "~/lib/connection/getters"; +import { MonitorData, initialMonitorData } from "~/lib/connection/monitor"; +import { addStreamListneners } from "~/lib/connection/transport"; +import { useConnection } from "~/context/connection-provider"; + +type ProviderProps = { + children: JSXElement; +}; + +const MonitorContext = createContext<{ + monitorData: MonitorData; + setMonitorData: SetStoreFunction; +}>(); + +export function useMonitor() { + const ctx = useContext(MonitorContext); + + if (!ctx) throw new Error("can not find context"); + return ctx; +} + +export function MonitorProvider(props: ProviderProps) { + const { connectionStore } = useConnection(); + const [monitorData, setMonitorData] = createStore(initialMonitorData); + const [tauriMetrics] = getTauriMetrics(connectionStore.client.tauri); + const [tauriConfig] = getTauriConfig(connectionStore.client.tauri); + + createEffect(() => { + setMonitorData("tauriConfig", tauriConfig()); + }); + + createEffect(() => { + if (tauriMetrics()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setMonitorData("perf", tauriMetrics()!); + } + }); + + addStreamListneners(connectionStore.stream.update, setMonitorData); + + return ( + + + {props.children} + + + ); +} diff --git a/web-client/src/entry.tsx b/web-client/src/entry.tsx index ad32aae3..b4315bce 100644 --- a/web-client/src/entry.tsx +++ b/web-client/src/entry.tsx @@ -1,7 +1,7 @@ import { type RouteDefinition, useNavigate, useRoutes } from "@solidjs/router"; -import { lazy } from "solid-js"; -import { connect } from "./lib/connection/transport.ts"; +import { lazy, ErrorBoundary } from "solid-js"; import { setCDN } from "@crabnebula/file-icons"; +import { ErrorRoot } from "./components/error-root.tsx"; const ROUTES: RouteDefinition[] = [ { @@ -11,12 +11,6 @@ const ROUTES: RouteDefinition[] = [ { path: "/dash/:host/:port", component: lazy(() => import("./views/dashboard/layout.tsx")), - data: ({ params }) => { - const { host, port } = params; - const connection = connect(`http://${host}:${port}`); - - return connection; - }, children: [ { path: "/", @@ -51,5 +45,9 @@ export default function Entry() { setCDN("/icons"); - return ; + return ( + }> + + + ); } diff --git a/web-client/src/lib/connection/getters.ts b/web-client/src/lib/connection/getters.ts index 09f1dde2..25a75e2b 100644 --- a/web-client/src/lib/connection/getters.ts +++ b/web-client/src/lib/connection/getters.ts @@ -56,3 +56,14 @@ export function getHealthStatus(res: HealthCheckResponse) { } return res.status; } + +export function getInstrumentationMetadata(client: MetadataClient) { + return createResource(client, async () => { + try { + const a = await client.getInstrumentationMetadata({}); + return a.response; + } catch (e) { + throw new Error("failed parsing instrumentation metadata"); + } + }); +} \ No newline at end of file diff --git a/web-client/src/lib/connection/monitor.ts b/web-client/src/lib/connection/monitor.ts index 688f5463..8ff15954 100644 --- a/web-client/src/lib/connection/monitor.ts +++ b/web-client/src/lib/connection/monitor.ts @@ -1,4 +1,3 @@ -import { createContext, useContext } from "solid-js"; import { HealthCheckResponse_ServingStatus } from "~/lib/proto/health"; import { LogEvent } from "~/lib/proto/logs"; import { Field, Metadata } from "~/lib/proto/common"; @@ -8,6 +7,8 @@ import { timestampToDate } from "~/lib/formatters"; import { AppMetadata } from "../proto/meta"; import { Versions } from "../proto/tauri"; +export type HealthStatus = keyof typeof HealthCheckResponse_ServingStatus; + export type Span = { id: bigint; parentId?: bigint; @@ -34,6 +35,8 @@ export type MonitorData = { perfStartDate: Date | null; perfReadyDate: Date | null; perfElapsed: Timestamp | null; + + connectionStatus: HealthStatus; }; export const initialMonitorData: MonitorData = { @@ -51,6 +54,10 @@ export const initialMonitorData: MonitorData = { readyAt: undefined, }, + get connectionStatus() { + return HealthCheckResponse_ServingStatus[this.health] as HealthStatus; + }, + get perfStartDate() { return this.perf.initializedAt ? timestampToDate(this.perf.initializedAt) @@ -72,14 +79,3 @@ export const initialMonitorData: MonitorData = { } }, }; - -export const MonitorContext = createContext<{ - monitorData: MonitorData; -}>(); - -export function useMonitor() { - const ctx = useContext(MonitorContext); - - if (!ctx) throw new Error("can not find context"); - return ctx; -} diff --git a/web-client/src/lib/connection/transport.ts b/web-client/src/lib/connection/transport.ts deleted file mode 100644 index 55989e21..00000000 --- a/web-client/src/lib/connection/transport.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createContext, useContext } from "solid-js"; -import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; -import { HealthClient } from "~/lib/proto/health.client"; -import { InstrumentClient } from "~/lib/proto/instrument.client"; -import { TauriClient } from "~/lib/proto/tauri.client"; -import { SourcesClient } from "~/lib/proto/sources.client.ts"; -import { MetadataClient } from "../proto/meta.client"; - -export function connect(url: string) { - const abortController = new AbortController(); - const transport = new GrpcWebFetchTransport({ - format: "binary", - baseUrl: url, - abort: abortController.signal, - }); - - const instrumentClient = new InstrumentClient(transport); - const tauriClient = new TauriClient(transport); - const healthClient = new HealthClient(transport); - const sourcesClient = new SourcesClient(transport); - const metaClient = new MetadataClient(transport); - - return { - abortController, - client: { - tauri: tauriClient, - health: healthClient, - instrument: instrumentClient, - sources: sourcesClient, - meta: metaClient, - }, - }; -} - -export type Connection = ReturnType; - -export function disconnect(controller: AbortController) { - controller.abort(); -} - -export const TransportContext = createContext<{ - transport: GrpcWebFetchTransport; - abort: AbortController; -}>(); - -export function useTransport() { - const ctx = useContext(TransportContext); - - if (!ctx) throw new Error("can not find TransportContext.Provider"); - return ctx; -} diff --git a/web-client/src/lib/connection/transport.tsx b/web-client/src/lib/connection/transport.tsx new file mode 100644 index 00000000..ca43d139 --- /dev/null +++ b/web-client/src/lib/connection/transport.tsx @@ -0,0 +1,91 @@ +import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; +import { HealthClient } from "~/lib/proto/health.client"; +import { InstrumentClient } from "~/lib/proto/instrument.client"; +import { TauriClient } from "~/lib/proto/tauri.client"; +import { SourcesClient } from "~/lib/proto/sources.client.ts"; +import { MetadataClient } from "../proto/meta.client"; +import { SetStoreFunction, createStore, produce } from "solid-js/store"; +import { HealthCheckRequest } from "../proto/health"; +import { InstrumentRequest } from "../proto/instrument"; +import { updateSpanMetadata } from "../span/update-span-metadata"; +import { updatedSpans } from "../span/update-spans"; +import { MonitorData } from "./monitor"; +import { createResource } from "solid-js"; + +export function connect(url: string) { + const abortController = new AbortController(); + const transport = new GrpcWebFetchTransport({ + format: "binary", + baseUrl: url, + abort: abortController.signal, + }); + + const instrumentClient = new InstrumentClient(transport); + const tauriClient = new TauriClient(transport); + const healthClient = new HealthClient(transport); + const sourcesClient = new SourcesClient(transport); + const metaClient = new MetadataClient(transport); + + const healthStream = healthClient.watch( + /** + * empty string means all services. + */ + HealthCheckRequest.create({ service: "" }) + ); + const updateStream = instrumentClient.watchUpdates( + InstrumentRequest.create({}) + ); + + const connectionStore = { + serviceUrl: url, + abortController, + client: { + tauri: tauriClient, + health: healthClient, + instrument: instrumentClient, + sources: sourcesClient, + meta: metaClient, + }, + stream: { + health: healthStream, + update: updateStream, + }, + }; + + return connectionStore; +} + +export function setup(url: string) { + const [connectionStore, setConnection] = createStore(connect(url)); + + return { connectionStore, setConnection }; +} + +type UpdateStream = ReturnType["stream"]["update"]; + +export function addStreamListneners( + stream: UpdateStream, + setMonitorData: SetStoreFunction +) { + stream.responses.onMessage((update) => { + setMonitorData("health", 1); + if (update.newMetadata.length > 0) { + setMonitorData("metadata", (prev) => updateSpanMetadata(prev, update)); + } + + const logsUpdate = update.logsUpdate; + if (logsUpdate && logsUpdate.logEvents.length > 0) { + setMonitorData("logs", (prev) => [...prev, ...logsUpdate.logEvents]); + } + + const spansUpdate = update.spansUpdate; + if (spansUpdate && spansUpdate.spanEvents.length > 0) { + setMonitorData( + "spans", + produce((clonedSpans) => + updatedSpans(clonedSpans, spansUpdate.spanEvents) + ) + ); + } + }); +} diff --git a/web-client/src/lib/tauri/tauri-conf-schema.ts b/web-client/src/lib/tauri/tauri-conf-schema.ts index 816a1a90..6030a0ed 100644 --- a/web-client/src/lib/tauri/tauri-conf-schema.ts +++ b/web-client/src/lib/tauri/tauri-conf-schema.ts @@ -3,12 +3,12 @@ import tauriConfigSchemaV2 from "./tauri-conf-schema-v2.json"; import { Draft07, JsonSchema, JsonPointer } from "json-schema-library"; import { createResource, Signal } from "solid-js"; import { useRouteData, useLocation, useParams } from "@solidjs/router"; -import { Connection } from "~/lib/connection/transport.ts"; import { awaitEntries, getEntryBytes } from "~/lib/sources/file-entries"; import { useConfiguration } from "~/components/tauri/configuration-context"; import { unwrap, reconcile } from "solid-js/store"; -import { useMonitor } from "../connection/monitor"; import { bytesToText } from "../code-highlight"; +import { useConnection } from "~/context/connection-provider"; +import { useMonitor } from "~/context/monitor-provider"; export type configurationStore = { configs?: configurationObject[]; @@ -74,8 +74,8 @@ export function retrieveConfigurations() { if (configurations.configs) return createResource(() => configurations.configs); - const { client } = useRouteData(); - const [entries] = awaitEntries(client.sources, ""); + const { connectionStore } = useConnection(); + const [entries] = awaitEntries(connectionStore.client.sources, ""); return createResource( entries, @@ -85,7 +85,7 @@ export function retrieveConfigurations() { return await Promise.all( filteredEntries.map(async (e): Promise => { const bytes = await getEntryBytes( - client.sources, + connectionStore.client.sources, e.path, Number(e.size) ); diff --git a/web-client/src/views/connect.tsx b/web-client/src/views/connect.tsx index caba4db6..2d8411d9 100644 --- a/web-client/src/views/connect.tsx +++ b/web-client/src/views/connect.tsx @@ -21,13 +21,7 @@ export default function Connect() { --inspect flag. Then, paste the appropriate connection values below.

-
{ - e.preventDefault(); - navigate(`/dash/${host()}/${port()}/`); - }} - class="grid gap-8 border-neutral-800 p-4 rounded" - > +
web socket URL host
{ + e.preventDefault(); + navigate(`/dash/${host()}/${port()}/`); + }} > Inspect diff --git a/web-client/src/views/dashboard/console.tsx b/web-client/src/views/dashboard/console.tsx index 764efb31..a0ad9917 100644 --- a/web-client/src/views/dashboard/console.tsx +++ b/web-client/src/views/dashboard/console.tsx @@ -2,9 +2,9 @@ import { For, Show, createSignal } from "solid-js"; import { AutoscrollPane } from "~/components/autoscroll-pane"; import { FilterToggle } from "~/components/filter-toggle"; import { formatTimestamp, timestampToDate } from "~/lib/formatters"; -import { useMonitor } from "~/lib/connection/monitor"; import { Toolbar } from "~/components/toolbar"; import { Metadata_Level, MetaId } from "~/lib/proto/common"; +import { useMonitor } from "~/context/monitor-provider"; const levelStyles = (level: Metadata_Level | undefined) => { switch (level) { diff --git a/web-client/src/views/dashboard/layout.tsx b/web-client/src/views/dashboard/layout.tsx index a5630887..51b7af2f 100644 --- a/web-client/src/views/dashboard/layout.tsx +++ b/web-client/src/views/dashboard/layout.tsx @@ -1,163 +1,44 @@ -import { createEffect, onCleanup } from "solid-js"; -import { createStore, produce } from "solid-js/store"; -import { Outlet, useRouteData } from "@solidjs/router"; +import { Outlet, useParams } from "@solidjs/router"; import { Navigation } from "~/components/navigation"; import { BootTime } from "~/components/boot-time"; import { HealthStatus } from "~/components/health-status.tsx"; -import { initialMonitorData, MonitorContext } from "~/lib/connection/monitor"; -import { InstrumentRequest } from "~/lib/proto/instrument"; -import { - getHealthStatus, - getTauriConfig, - getTauriMetrics, - getVersions, - getMetadata, -} from "~/lib/connection/getters"; -import { - HealthCheckRequest, - HealthCheckResponse, - HealthCheckResponse_ServingStatus, -} from "~/lib/proto/health"; -import { Connection, disconnect } from "~/lib/connection/transport"; import { Logo } from "~/components/crabnebula-logo"; -import { useNavigate } from "@solidjs/router"; import { DisconnectButton } from "~/components/disconnect-button"; -import { updatedSpans } from "~/lib/span/update-spans"; -import { updateSpanMetadata } from "~/lib/span/update-span-metadata"; -import { returnLatestSchemaForVersion } from "~/lib/tauri/tauri-conf-schema"; +import { MonitorProvider } from "~/context/monitor-provider"; +import { ConnectionProvider } from "~/context/connection-provider"; +import IncompatibleInstrumentationVersion from "~/components/incompatible-instrumentation-version.tsx"; -export default function Layout() { - const { abortController, client } = useRouteData(); - - const [monitorData, setMonitorData] = createStore(initialMonitorData); - const [tauriMetrics] = getTauriMetrics(client.tauri); - const [tauriConfig] = getTauriConfig(client.tauri); - const [tauriVersions] = getVersions(client.tauri); - const [appMetaData] = getMetadata(client.meta); - - const healthStream = client.health.watch( - HealthCheckRequest.create({ service: "" }) - ); - - const navigate = useNavigate(); - - function closeSession() { - // Clean up all the listeners to make sure we don't try to close the session multiple times. - removeListeners.forEach((removeListener) => removeListener()); - - setMonitorData("health", HealthCheckResponse_ServingStatus.UNKNOWN); - disconnect(abortController); - navigate("/"); - } - - healthStream.responses.onMessage((res: HealthCheckResponse) => { - const status = getHealthStatus(res); - - setMonitorData("health", status); - }); - - createEffect(() => { - setMonitorData("tauriConfig", tauriConfig()); - }); - - createEffect(() => { - const versions = tauriVersions(); - if (versions) { - const schema = returnLatestSchemaForVersion(versions.tauri); - setMonitorData("schema", schema); - } - setMonitorData("tauriVersions", versions); - }); +type RouteParams = Record<"host" | "port", string>; - createEffect(() => { - setMonitorData("appMetaData", appMetaData()); - }); - - createEffect(() => { - if (tauriMetrics()) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - setMonitorData("perf", tauriMetrics()!); - } - }); - - const updateStream = client.instrument.watchUpdates( - InstrumentRequest.create({}) - ); - - const removeListeners = [ - healthStream.responses.onError(() => { - closeSession(); - }), - healthStream.responses.onComplete(() => { - closeSession(); - }), - updateStream.responses.onError(() => { - closeSession(); - }), - updateStream.responses.onComplete(() => { - closeSession(); - }), - ]; - - updateStream.responses.onMessage((update) => { - if (update.newMetadata.length > 0) { - setMonitorData("metadata", (prev) => updateSpanMetadata(prev, update)); - } - - if (update.logsUpdate && update.spansUpdate) { - console.assert( - update.logsUpdate.droppedEvents == 0n, - "Dropped log events because the internal event buffer was at capacity. This is a bug, please report!" - ); - console.assert( - update.spansUpdate.droppedEvents == 0n, - "Dropped span events because the internal event buffer was at capacity. This is a bug, please report!" - ); - } - - const logsUpdate = update.logsUpdate; - if (logsUpdate && logsUpdate.logEvents.length > 0) { - setMonitorData("logs", (prev) => [...prev, ...logsUpdate.logEvents]); - } - - const spansUpdate = update.spansUpdate; - if (spansUpdate && spansUpdate.spanEvents.length > 0) { - setMonitorData( - "spans", - produce((clonedSpans) => - updatedSpans(clonedSpans, spansUpdate.spanEvents) - ) - ); - } - }); - - onCleanup(() => { - abortController.abort(); - }); +export default function Layout() { + const { host, port } = useParams(); return ( - -
-
- - - + + +
+
+ + + + +
+ +
+
+ +
+
+ Built by CrabNebula +
+
+
- -
-
- -
-
- Built by CrabNebula -
-
- -
-
+ + ); } diff --git a/web-client/src/views/dashboard/span-waterfall.tsx b/web-client/src/views/dashboard/span-waterfall.tsx index 70bbcb3d..4601a47b 100644 --- a/web-client/src/views/dashboard/span-waterfall.tsx +++ b/web-client/src/views/dashboard/span-waterfall.tsx @@ -1,4 +1,3 @@ -import { useMonitor } from "~/lib/connection/monitor"; import { Toolbar } from "~/components/toolbar"; import { createEffect, createSignal } from "solid-js"; import { formatSpansForUi } from "~/lib/span/format-spans-for-ui"; @@ -7,6 +6,7 @@ import { SplitPane } from "~/components/split-pane"; import { SpanDetailPanel } from "~/components/span/span-detail-panel"; import { ColumnSort, SpanList } from "~/components/span/span-list"; import { SpanScaleSlider } from "~/components/span/span-scale-slider"; +import { useMonitor } from "~/context/monitor-provider"; export default function SpanWaterfall() { const { monitorData } = useMonitor(); diff --git a/web-client/tailwind.config.ts b/web-client/tailwind.config.ts index 5a7bbc68..b7f8ba2d 100644 --- a/web-client/tailwind.config.ts +++ b/web-client/tailwind.config.ts @@ -1,5 +1,6 @@ import type { Config } from "tailwindcss"; import scrollbar from "tailwind-scrollbar"; +import kobalte from "@kobalte/tailwindcss"; import defaultTheme from "tailwindcss/defaultTheme"; export default { @@ -35,5 +36,5 @@ export default { }, }, }, - plugins: [scrollbar({ nocompatible: true })], + plugins: [scrollbar({ nocompatible: true }), kobalte({ prefix: "kb" })], } satisfies Config; diff --git a/web-client/vite.config.ts b/web-client/vite.config.ts index 4769f1d2..93f39a02 100644 --- a/web-client/vite.config.ts +++ b/web-client/vite.config.ts @@ -8,11 +8,18 @@ import wasm from "vite-plugin-wasm"; import topLevelAwait from "vite-plugin-top-level-await"; import { viteStaticCopy } from "vite-plugin-static-copy"; import { normalizePath } from "vite"; +import fs from "fs"; +import * as toml from "@iarna/toml"; + +const rustPkg = toml.parse(fs.readFileSync("../Cargo.toml", "utf-8")); export default defineConfig({ server: { strictPort: true, }, + define: { + "INSTRUMENTATION_VERSION": JSON.stringify(rustPkg.workspace.package.version), + }, plugins: [ wasm(), topLevelAwait(), diff --git a/wire/proto/meta.proto b/wire/proto/meta.proto index e76918f7..f8406ef0 100644 --- a/wire/proto/meta.proto +++ b/wire/proto/meta.proto @@ -2,8 +2,11 @@ syntax = "proto3"; package rs.devtools.meta; +import "google/protobuf/duration.proto"; + service Metadata { - rpc GetAppMetadata(AppMetadataRequest) returns (AppMetadata) {} + rpc GetAppMetadata(AppMetadataRequest) returns (AppMetadata) {} + rpc GetInstrumentationMetadata(InstrumentationMetadataRequest) returns (InstrumentationMetadata) {} } message AppMetadataRequest {} @@ -36,4 +39,15 @@ message AppMetadata { string arch = 6; /// Whether the app was compiled with debug assertions enabled. bool debug_assertions = 7; +} + +message InstrumentationMetadataRequest {} + +message InstrumentationMetadata { + // the version of the instrumentation crate used + string version = 1; + // the publish interval configured for the instrumentation + // NOTE: This is not a stable property and might be removed at any time + // DO NOT RELY ON THIS + optional google.protobuf.Duration publish_interval = 99; } \ No newline at end of file diff --git a/wire/src/generated/rs.devtools.meta.rs b/wire/src/generated/rs.devtools.meta.rs index e16351af..5ceb5321 100644 --- a/wire/src/generated/rs.devtools.meta.rs +++ b/wire/src/generated/rs.devtools.meta.rs @@ -39,6 +39,21 @@ pub struct AppMetadata { #[prost(bool, tag = "7")] pub debug_assertions: bool, } +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct InstrumentationMetadataRequest {} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct InstrumentationMetadata { + /// the version of the instrumentation crate used + #[prost(string, tag = "1")] + pub version: ::prost::alloc::string::String, + /// the publish interval configured for the instrumentation + /// NOTE: This is not a stable property and might be removed at any time + /// DO NOT RELY ON THIS + #[prost(message, optional, tag = "99")] + pub publish_interval: ::core::option::Option<::prost_types::Duration>, +} /// Generated server implementations. #[allow(clippy::all)] pub mod metadata_server { @@ -51,6 +66,13 @@ pub mod metadata_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + async fn get_instrumentation_metadata( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct MetadataServer { @@ -177,6 +199,58 @@ pub mod metadata_server { }; Box::pin(fut) } + "/rs.devtools.meta.Metadata/GetInstrumentationMetadata" => { + #[allow(non_camel_case_types)] + struct GetInstrumentationMetadataSvc(pub Arc); + impl< + T: Metadata, + > tonic::server::UnaryService + for GetInstrumentationMetadataSvc { + type Response = super::InstrumentationMetadata; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::InstrumentationMetadataRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_instrumentation_metadata( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let inner = inner.0; + let method = GetInstrumentationMetadataSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { Ok(