Skip to content

Commit

Permalink
fix(console): console log levels are not persistent across restarts (#…
Browse files Browse the repository at this point in the history
…6885)

Using local storage to save some of the console view settings across app restarts

Fixes #2806

## Checklist

- [ ] Title matches [Winglang's style guide](https://www.winglang.io/contributing/start-here/pull_requests#how-are-pull-request-titles-formatted)
- [ ] Description explains motivation and solution
- [ ] Tests added (always)
- [ ] Docs updated (only required for features)
- [ ] Added `pr/e2e-full` label if this feature requires end-to-end testing

*By submitting this pull request, I confirm that my contribution is made under the terms of the [Wing Cloud Contribution License](https://github.com/winglang/wing/blob/main/CONTRIBUTION_LICENSE.md)*.
  • Loading branch information
eladcon authored Jul 23, 2024
1 parent d6933b0 commit 93b81cd
Show file tree
Hide file tree
Showing 10 changed files with 261 additions and 86 deletions.
2 changes: 1 addition & 1 deletion apps/wing-console/console/server/src/router/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const createAppRouter = () => {
return ctx.appDetails();
}),
"app.wingfile": createProcedure.query(({ ctx }) => {
return ctx.wingfile.split("/").pop();
return ctx.wingfile;
}),
"app.layoutConfig": createProcedure.query(async ({ ctx }) => {
return {
Expand Down
40 changes: 26 additions & 14 deletions apps/wing-console/console/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import {
NotificationsProvider,
type Mode,
buildTheme,
Loader,
} from "@wingconsole/design-system";
import type { Trace } from "@wingconsole/server";
import { PersistentStateProvider } from "@wingconsole/use-persistent-state";
import { MotionConfig } from "framer-motion";
import { useMemo } from "react";

import type { LayoutType } from "./features/layout/layout-provider.js";
import { LayoutProvider } from "./features/layout/layout-provider.js";
import { AppLocalStorageProvider } from "./features/localstorage-context/localstorage-context.js";
import { SelectionContextProvider } from "./features/selection-context/selection-context.js";
import { TestsContextProvider } from "./features/tests-pane/tests-context.js";
import { trpc } from "./trpc.js";
Expand Down Expand Up @@ -45,25 +48,34 @@ export const App = ({ layout, theme, color, onTrace }: AppProps) => {
const layoutConfig = trpc["app.layoutConfig"].useQuery();
const appDetails = trpc["app.details"].useQuery();
const appState = trpc["app.state"].useQuery();
const wingfileQuery = trpc["app.wingfile"].useQuery();
const wingfile = useMemo(() => {
return wingfileQuery.data;
}, [wingfileQuery.data]);

return (
<ThemeProvider theme={buildTheme(color)}>
<NotificationsProvider>
<TestsContextProvider>
<SelectionContextProvider>
<PersistentStateProvider>
<MotionConfig transition={{ duration: 0.15 }}>
<LayoutProvider
layoutType={layout}
layoutProps={{
cloudAppState: appState.data ?? "compiling",
wingVersion: appDetails.data?.wingVersion,
layoutConfig: layoutConfig.data?.config,
}}
/>
</MotionConfig>
</PersistentStateProvider>
</SelectionContextProvider>
<AppLocalStorageProvider storageKey={wingfile}>
{!wingfile && <Loader />}
{wingfile && (
<SelectionContextProvider>
<PersistentStateProvider>
<MotionConfig transition={{ duration: 0.15 }}>
<LayoutProvider
layoutType={layout}
layoutProps={{
cloudAppState: appState.data ?? "compiling",
wingVersion: appDetails.data?.wingVersion,
layoutConfig: layoutConfig.data?.config,
}}
/>
</MotionConfig>
</PersistentStateProvider>
</SelectionContextProvider>
)}
</AppLocalStorageProvider>
</TestsContextProvider>
</NotificationsProvider>
</ThemeProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const Graph: FunctionComponent<PropsWithChildren<GraphProps>> = memo(
return;
}

if (initialZoomToFit) {
if (initialZoomToFit && zoomPaneRef.current?.shouldDoInitialZoomToFit) {
zoomPaneRef.current?.zoomToFit();
}
setInitialZoomToFit(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import type { DetailedHTMLProps, HTMLAttributes } from "react";
import type { ReactNode } from "react";
import { useEvent } from "react-use";

import { useAppLocalStorage } from "../localstorage-context/use-localstorage.js";

import { MapControls } from "./map-controls.js";
import { useRafThrottle } from "./use-raf-throttle.js";

Expand Down Expand Up @@ -86,6 +88,7 @@ export interface ZoomPaneProps

export interface ZoomPaneRef {
zoomToFit(viewport?: Viewport): void;
shouldDoInitialZoomToFit: boolean;
}

const context = createContext({
Expand All @@ -101,7 +104,8 @@ const boundaryPadding = 48;
export const ZoomPane = forwardRef<ZoomPaneRef, ZoomPaneProps>((props, ref) => {
const { boundingBox, children, className, onClick, ...divProps } = props;

const [viewTransform, setViewTransform] = useState(IDENTITY_TRANSFORM);
const [viewTransform, setViewTransform, viewTransformExists] =
useAppLocalStorage("zoomPane.viewTransform", IDENTITY_TRANSFORM);
const containerRef = useRef<HTMLDivElement>(null);
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
Expand Down Expand Up @@ -425,9 +429,10 @@ export const ZoomPane = forwardRef<ZoomPaneRef, ZoomPaneProps>((props, ref) => {
() => {
return {
zoomToFit,
shouldDoInitialZoomToFit: !viewTransformExists,
};
},
[zoomToFit],
[viewTransformExists, zoomToFit],
);

// Whether the bounding box is out of bounds of the transform view.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const useLayout = ({ cloudAppState }: UseLayoutProps) => {
if (!wingfile) {
return "Wing Console";
}
return `${wingfile} - Wing Console`;
return `${wingfile.split("/").pop()} - Wing Console`;
}, [wingfile]);

const { loading, setLoading } = useLoading({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { PropsWithChildren } from "react";
import { createContext, useMemo } from "react";

const hashCode = (value: string) => {
let hash = 0;
if (value.length === 0) {
return hash.toString();
}
for (let index = 0; index < value.length; index++) {
const chr = value.codePointAt(index);
hash = (hash << 5) - hash + chr!;
hash = Math.trunc(hash);
}
return hash.toString();
};

export const AppLocalStorageContext = createContext<{
storageKey: string | undefined;
}>({
storageKey: undefined,
});

export interface AppLocalStorageProviderProps extends PropsWithChildren {
storageKey: string | undefined;
}

export const AppLocalStorageProvider = (
props: AppLocalStorageProviderProps,
) => {
const key = useMemo(() => {
if (props.storageKey) {
const hash = hashCode(props.storageKey);
return hash;
}
}, [props.storageKey]);
return (
<AppLocalStorageContext.Provider
value={{
storageKey: key,
}}
>
{props.children}
</AppLocalStorageContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Imported and modified from https://github.com/streamich/react-use/blob/master/src/useLocalStorage.ts
import type { Dispatch, SetStateAction } from "react";
import {
useCallback,
useState,
useRef,
useLayoutEffect,
useEffect,
useContext,
} from "react";

import { AppLocalStorageContext } from "./localstorage-context.js";

export const useLocalStorage = <T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>, boolean, () => void] => {
const [valueExists] = useState(() => {
try {
const localStorageValue = localStorage.getItem(key);
return localStorageValue !== null;
} catch {
return false;
}
});

const initializer = useRef((key: string) => {
try {
const localStorageValue = localStorage.getItem(key);
return localStorageValue === null
? initialValue
: JSON.parse(localStorageValue);
} catch {
// If user is in private mode or has storage restriction
// localStorage can throw. JSON.parse and JSON.stringify
// can throw, too.
return initialValue;
}
});

const [state, setState] = useState<T>(() => initializer.current(key));

useLayoutEffect(() => setState(initializer.current(key)), [key]);

useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(state));
} catch {
// If user is in private mode or has storage restriction
// localStorage can throw. Also JSON.stringify can throw.
}
}, [state, key]);

const remove = useCallback(() => {
try {
localStorage.removeItem(key);
setState(initialValue);
} catch {
// If user is in private mode or has storage restriction
// localStorage can throw.
}
}, [initialValue, key]);

return [state, setState, valueExists, remove];
};

export const useAppLocalStorage = <T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>, boolean, () => void] => {
const { storageKey } = useContext(AppLocalStorageContext);
if (!storageKey) {
throw new Error("AppLocalStorageContext.storageKey is not provided");
}

return useLocalStorage(`${storageKey}.${key}`, initialValue);
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import uniqby from "lodash.uniqby";
import { memo, useCallback, useEffect, useMemo, useState } from "react";

export const LOG_LEVELS: LogLevel[] = ["verbose", "info", "warn", "error"];
export const DEFAULT_LOG_LEVELS: LogLevel[] = ["info", "warn", "error"].sort();

const logLevelNames = {
verbose: "Verbose",
Expand Down Expand Up @@ -75,19 +76,14 @@ export const ConsoleLogsFilters = memo(
debouncedOnSearch(searchText);
}, [debouncedOnSearch, searchText]);

const [defaultLogTypeSelection] = useState(selectedLogTypeFilters.sort());
const resetFiltersDisabled = useMemo(() => {
return (
selectedLogTypeFilters === defaultLogTypeSelection &&
selectedLogTypeFilters.sort().toString() ===
DEFAULT_LOG_LEVELS.toString() &&
selectedResourceIds.length === 0 &&
selectedResourceTypes.length === 0
);
}, [
defaultLogTypeSelection,
selectedLogTypeFilters,
selectedResourceIds,
selectedResourceTypes,
]);
}, [selectedLogTypeFilters, selectedResourceIds, selectedResourceTypes]);

const renderResourceIdsLabel = useCallback(
(selected?: string[]) => {
Expand Down Expand Up @@ -177,7 +173,7 @@ export const ConsoleLogsFilters = memo(
return "All levels";
} else if (
selectedLogTypeFilters.sort().toString() ===
defaultLogTypeSelection.sort().toString()
DEFAULT_LOG_LEVELS.toString()
) {
return "Default levels";
} else if (selectedLogTypeFilters.length === 0) {
Expand All @@ -190,7 +186,7 @@ export const ConsoleLogsFilters = memo(
} else {
return "Custom levels";
}
}, [selectedLogTypeFilters, defaultLogTypeSelection]);
}, [selectedLogTypeFilters]);

const showIncompatibleResourceTypeWarning = useMemo(() => {
if (!resources || selectedResourceTypes.length === 0) {
Expand Down Expand Up @@ -231,7 +227,7 @@ export const ConsoleLogsFilters = memo(
}))}
selected={selectedLogTypeFilters}
onChange={setSelectedLogTypeFilters as any}
defaultSelection={defaultLogTypeSelection}
defaultSelection={DEFAULT_LOG_LEVELS}
/>

<Listbox
Expand Down
45 changes: 31 additions & 14 deletions apps/wing-console/console/ui/src/features/logs-pane/logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,44 @@ import {
USE_EXTERNAL_THEME_COLOR,
useTheme,
} from "@wingconsole/design-system";
import type { LogEntry, LogLevel } from "@wingconsole/server";
import type { LogEntry } from "@wingconsole/server";
import classNames from "classnames";
import { useState, useRef, useEffect, useCallback, memo } from "react";

import { trpc } from "../../trpc.js";
import { useAppLocalStorage } from "../localstorage-context/use-localstorage.js";

import { ConsoleLogsFilters } from "./console-logs-filters.js";
import {
ConsoleLogsFilters,
DEFAULT_LOG_LEVELS,
} from "./console-logs-filters.js";
import { ConsoleLogs } from "./console-logs.js";

const DEFAULT_LOG_LEVELS: LogLevel[] = ["info", "warn", "error"];

export interface LogsWidgetProps {
onResourceClick?: (path: string) => void;
}

export const LogsWidget = memo(({ onResourceClick }: LogsWidgetProps) => {
const { theme } = useTheme();

const [selectedLogTypeFilters, setSelectedLogTypeFilters] = useState(
() => DEFAULT_LOG_LEVELS,
const [selectedLogTypeFilters, setSelectedLogTypeFilters] =
useAppLocalStorage("logsWidget.selectedLogTypeFilters", DEFAULT_LOG_LEVELS);
const [searchText, setSearchText] = useAppLocalStorage(
"logsWidget.searchText",
"",
);
const [searchText, setSearchText] = useState("");

const [selectedResourceIds, setSelectedResourceIds] = useState<string[]>([]);
const [selectedResourceTypes, setSelectedResourceTypes] = useState<string[]>(
[],
);
const [selectedResourceIds, setSelectedResourceIds] = useAppLocalStorage<
string[]
>("logsWidget.selectedResourceIds", []);
const [selectedResourceTypes, setSelectedResourceTypes] = useAppLocalStorage<
string[]
>("logsWidget.selectedResourceTypes", []);

const [logsTimeFilter, setLogsTimeFilter] = useState(0);
const [logsTimeFilter, setLogsTimeFilter] = useAppLocalStorage(
"logsWidget.logsTimeFilter",
0,
);

const filters = trpc["app.logsFilters"].useQuery();

Expand Down Expand Up @@ -88,14 +97,22 @@ export const LogsWidget = memo(({ onResourceClick }: LogsWidgetProps) => {
[onResourceClick],
);

const clearLogs = useCallback(() => setLogsTimeFilter(Date.now()), []);
const clearLogs = useCallback(
() => setLogsTimeFilter(Date.now()),
[setLogsTimeFilter],
);

const resetFilters = useCallback(() => {
setSelectedLogTypeFilters(DEFAULT_LOG_LEVELS);
setSelectedResourceIds([]);
setSelectedResourceTypes([]);
setSearchText("");
}, []);
}, [
setSearchText,
setSelectedLogTypeFilters,
setSelectedResourceIds,
setSelectedResourceTypes,
]);

return (
<div className="relative h-full flex flex-col gap-2">
Expand Down
Loading

0 comments on commit 93b81cd

Please sign in to comment.