diff --git a/apps/zui/jest.config.js b/apps/zui/jest.config.js index 91dd992191..8590394ae8 100644 --- a/apps/zui/jest.config.js +++ b/apps/zui/jest.config.js @@ -7,11 +7,21 @@ const moduleNameMapper = pathsToModuleNameMapper(config.compilerOptions.paths, { prefix: "/../../", }) +const esModules = [ + "bullet", + "@reduxjs/toolkit", + "immer", + "redux", + "lodash-es", +].join("|") +// https://github.com/gravitational/teleport/issues/33810 + module.exports = { transform: { "^.+\\.(t|j)sx?$": ["@swc/jest"], ".+\\.(css|styl|less|sass|scss)$": "jest-css-modules-transform", }, + transformIgnorePatterns: [`/node_modules/(?!${esModules})`], setupFiles: ["./src/test/unit/setup/before-env.ts"], setupFilesAfterEnv: ["./src/test/unit/setup/after-env.ts"], testEnvironmentOptions: { diff --git a/apps/zui/package.json b/apps/zui/package.json index 119e773e41..b24ba0a1fa 100644 --- a/apps/zui/package.json +++ b/apps/zui/package.json @@ -74,6 +74,7 @@ "ajv": "^6.9.1", "animejs": "^3.2.0", "brimcap": "brimdata/brimcap#0c3a564771dd204e9ce93e4600de2dbcf97bd446", + "bullet": "^0.0.2", "chalk": "^4.1.0", "chevrotain": "^10.5.0", "chrono-node": "^2.5.0", diff --git a/apps/zui/src/components/drag-anchor.tsx b/apps/zui/src/components/drag-anchor.tsx index d1c8cf92de..39d3fa9dd6 100644 --- a/apps/zui/src/components/drag-anchor.tsx +++ b/apps/zui/src/components/drag-anchor.tsx @@ -142,7 +142,6 @@ export default class DragAnchor extends React.Component { className={classNames( `align-${this.props.position}`, this.props.className, - { debug: this.props.debug, showOnHover: this.props.showOnHover, diff --git a/apps/zui/src/components/icon/index.tsx b/apps/zui/src/components/icon/index.tsx index 6ba171106f..ae58cbb952 100644 --- a/apps/zui/src/components/icon/index.tsx +++ b/apps/zui/src/components/icon/index.tsx @@ -23,7 +23,7 @@ export function Icon(props: Props) { return ( diff --git a/apps/zui/src/css/_utilities.scss b/apps/zui/src/css/_utilities.scss index 92acbfd981..e81bf0a3d0 100644 --- a/apps/zui/src/css/_utilities.scss +++ b/apps/zui/src/css/_utilities.scss @@ -265,3 +265,7 @@ overflow: hidden; text-overflow: ellipsis; } + +.list-none { + list-style-type: none; +} diff --git a/apps/zui/src/css/blocks/_sidebar-item.scss b/apps/zui/src/css/blocks/_sidebar-item.scss index c066d5c747..cd29b47312 100644 --- a/apps/zui/src/css/blocks/_sidebar-item.scss +++ b/apps/zui/src/css/blocks/_sidebar-item.scss @@ -1,4 +1,5 @@ .sidebar-item { + list-style-type: none; border-radius: 6px; width: 100%; cursor: default; @@ -15,14 +16,13 @@ --selected-border: var(--primary-color-dark); } -.sidebar-item:hover { - background-color: inherit; - filter: var(--hover-filter); +.sidebar-item:hover, +.sidebar-item:active { + background-color: var(--emphasis-bg-less); } .sidebar-item:active { - background-color: inherit; - filter: var(--active-filter); + transform: scale(0.995); } .sidebar-item[aria-selected="true"] { @@ -35,5 +35,5 @@ .white-selection { --selected-bg: white; --selected-color: var(--fg-color); - --selected-border: var(--border-color); + --selected-border: transparent; } diff --git a/apps/zui/src/css/compositions/_gutter.scss b/apps/zui/src/css/compositions/_gutter.scss index 4a6f8ff195..807a716db9 100644 --- a/apps/zui/src/css/compositions/_gutter.scss +++ b/apps/zui/src/css/compositions/_gutter.scss @@ -13,3 +13,7 @@ .gutter-inline-end { padding-inline-end: var(--gutter); } + +.gutter-inline-start { + padding-inline-start: var(--gutter); +} diff --git a/apps/zui/src/css/utilities/_flex.scss b/apps/zui/src/css/utilities/_flex.scss index 4df120ce95..eb1016ae71 100644 --- a/apps/zui/src/css/utilities/_flex.scss +++ b/apps/zui/src/css/utilities/_flex.scss @@ -14,7 +14,7 @@ align-items: center; } -.flex-shrink-0 { +.shrink-0 { flex-shrink: 0; } diff --git a/apps/zui/src/js/initializers/init-domain-models.ts b/apps/zui/src/js/initializers/init-domain-models.ts index cd29ca2296..106c22f512 100644 --- a/apps/zui/src/js/initializers/init-domain-models.ts +++ b/apps/zui/src/js/initializers/init-domain-models.ts @@ -1,6 +1,8 @@ import {DomainModel} from "src/core/domain-model" import {Store} from "../state/types" +import {Entity} from "bullet" export function initDomainModels(args: {store: Store}) { DomainModel.store = args.store + Entity.store = args.store } diff --git a/apps/zui/src/js/state/Appearance/types.ts b/apps/zui/src/js/state/Appearance/types.ts index b1d25e4435..37e2db3966 100644 --- a/apps/zui/src/js/state/Appearance/types.ts +++ b/apps/zui/src/js/state/Appearance/types.ts @@ -1,3 +1,3 @@ export type HistoryView = "tree" | "linear" -export type SectionName = "pools" | "queries" | "history" +export type SectionName = "pools" | "queries" | "sessions" export type OpenMap = {[id: string]: boolean} diff --git a/apps/zui/src/js/state/QueryVersions/reducer.ts b/apps/zui/src/js/state/QueryVersions/reducer.ts index e9d40a344d..e9cacef7a8 100644 --- a/apps/zui/src/js/state/QueryVersions/reducer.ts +++ b/apps/zui/src/js/state/QueryVersions/reducer.ts @@ -3,12 +3,13 @@ import { createNestedEntitySlice, initialState, } from "../entity-slice/create-entity-slice" -import {actions as tabs} from "../Tabs/reducer" import {State} from "../types" import {QueryVersion} from "./types" type VersionMeta = {queryId: string} +const initial = initialState() + export const versionSlice = createNestedEntitySlice< QueryVersion, {queryId: string}, @@ -19,8 +20,8 @@ export const versionSlice = createNestedEntitySlice< sort: (a, b) => (a.ts > b.ts ? 1 : -1), meta: (queryId) => ({queryId}), select: (state: State, meta) => { - if (!state.queryVersions) return initialState() - return state.queryVersions[meta.queryId] ?? initialState() + if (!state.queryVersions) return initial + return state.queryVersions[meta.queryId] ?? initial }, }) @@ -33,11 +34,4 @@ export const reducer = createReducer({}, (builder) => { if (!state[id].ids.length) delete state[id] } ) - - builder.addMatcher( - ({type}) => type == tabs.remove.toString(), - (s, a: PayloadAction) => { - delete s[a.payload] - } - ) }) diff --git a/apps/zui/src/js/state/SessionHistories/reducer.ts b/apps/zui/src/js/state/SessionHistories/reducer.ts index 49f7cb3ecb..f48c46a03e 100644 --- a/apps/zui/src/js/state/SessionHistories/reducer.ts +++ b/apps/zui/src/js/state/SessionHistories/reducer.ts @@ -1,9 +1,8 @@ import {createSlice, PayloadAction} from "@reduxjs/toolkit" import {SessionHistoriesState, SessionHistoryEntry} from "./types" -import {actions as tabs} from "../Tabs/reducer" const slice = createSlice({ - name: "sessionHistories", + name: "$sessionHistories", initialState: {} as SessionHistoriesState, reducers: { replaceById( @@ -33,11 +32,6 @@ const slice = createSlice({ } }, }, - extraReducers: (builder) => { - builder.addCase(tabs.remove, (s, a: ReturnType) => { - delete s[a.payload] - }) - }, }) export const reducer = slice.reducer diff --git a/apps/zui/src/js/state/SessionQueries/reducer.ts b/apps/zui/src/js/state/SessionQueries/reducer.ts index 21ae11d411..be921644ca 100644 --- a/apps/zui/src/js/state/SessionQueries/reducer.ts +++ b/apps/zui/src/js/state/SessionQueries/reducer.ts @@ -1,5 +1,4 @@ import {createSlice} from "@reduxjs/toolkit" -import {actions as tabs} from "../Tabs/reducer" const slice = createSlice({ name: "$sessionQueries", @@ -9,11 +8,6 @@ const slice = createSlice({ s[a.payload.id] = a.payload }, }, - extraReducers: (builder) => { - builder.addCase(tabs.remove, (s, a: ReturnType) => { - delete s[a.payload] - }) - }, }) export const reducer = slice.reducer diff --git a/apps/zui/src/js/state/TabHistories/index.ts b/apps/zui/src/js/state/TabHistories/index.ts index 879ba86ab9..dbc92743e7 100644 --- a/apps/zui/src/js/state/TabHistories/index.ts +++ b/apps/zui/src/js/state/TabHistories/index.ts @@ -1,5 +1,4 @@ import {createEntityAdapter, createSlice} from "@reduxjs/toolkit" -import {actions as tabs} from "../Tabs/reducer" import {State} from "../types" import {SerializedHistory} from "./types" @@ -11,15 +10,6 @@ const slice = createSlice({ reducers: { save: adapter.setAll, }, - extraReducers: (builder) => { - builder.addCase( - tabs.remove, - (state, action: ReturnType) => { - global.tabHistories.delete(action.payload) - return state - } - ) - }, }) export default { diff --git a/apps/zui/src/js/state/Tabs/flows.ts b/apps/zui/src/js/state/Tabs/flows.ts index c781d0e8a6..f93ef3f07c 100644 --- a/apps/zui/src/js/state/Tabs/flows.ts +++ b/apps/zui/src/js/state/Tabs/flows.ts @@ -5,13 +5,21 @@ import {Thunk} from "../types" import Tabs from "./" import {findTabById, findTabByUrl} from "./find" import {invoke} from "src/core/invoke" +import {QuerySession} from "src/models/query-session" export const create = (url = "/", id = nanoid()): Thunk => (dispatch) => { dispatch(SessionQueries.init(id)) dispatch(Tabs.add(id)) - global.tabHistories.create(id, [{pathname: url}], 0) + // move to tabHistories.restore(id, url) + const history = global.tabHistories.get(id) + if (history) { + if (history.location.pathname !== url) history.push(url) + } else { + global.tabHistories.create(id, [{pathname: url}], 0) + } + // end dispatch(Tabs.activate(id)) return id } @@ -19,10 +27,12 @@ export const create = export const createQuerySession = (): Thunk => (dispatch, getState, {api}) => { - const sessionId = nanoid() + const session = QuerySession.create() + const sessionId = session.id const version = "0" api.queries.createEditorSnapshot(sessionId, {version, value: "", pins: []}) const url = queryPath(sessionId, version) + return dispatch(create(url, sessionId)) } diff --git a/apps/zui/src/js/state/Tabs/history.test.ts b/apps/zui/src/js/state/Tabs/history.test.ts index db34cabc85..aa346c70ac 100644 --- a/apps/zui/src/js/state/Tabs/history.test.ts +++ b/apps/zui/src/js/state/Tabs/history.test.ts @@ -6,29 +6,30 @@ import tabHistory from "src/app/router/tab-history" import initTestStore from "src/test/unit/helpers/initTestStore" import Current from "../Current" import Tabs from "./" +import Histories from "src/modules/histories" let store beforeEach(async () => { + global.tabHistories = new Histories() store = await initTestStore() - store.dispatch(Tabs.closeActive()) }) const currentPathnames = () => Current.getHistory(store.getState(), "search").entries.map((e) => e.pathname) test("creating a tab creates a history entry", () => { - expect(global.tabHistories.count()).toBe(0) - store.dispatch(Tabs.create("/url")) expect(global.tabHistories.count()).toBe(1) + store.dispatch(Tabs.create("/url")) + expect(global.tabHistories.count()).toBe(2) }) test("activate sets the global.tabHistory", () => { - expect(global.tabHistories.count()).toBe(0) + expect(global.tabHistories.count()).toBe(1) store.dispatch(tabHistory.push("/url-for-tab-1")) - expect(currentPathnames()).toEqual(["/", "/url-for-tab-1"]) + expect(currentPathnames()).toEqual(["/welcome", "/url-for-tab-1"]) store.dispatch(Tabs.add("2")) - expect(currentPathnames()).toEqual(["/", "/url-for-tab-1"]) + expect(currentPathnames()).toEqual(["/welcome", "/url-for-tab-1"]) store.dispatch(Tabs.activate("2")) store.dispatch(tabHistory.push("/url-for-tab-2")) @@ -36,12 +37,15 @@ test("activate sets the global.tabHistory", () => { expect(global.tabHistories.count()).toBe(2) }) -test("removing a tab removes the history too", () => { +test("removing the tab does not removes the history too", () => { + expect(global.tabHistories.count()).toBe(1) store.dispatch(tabHistory.push("/url-for-tab-1")) store.dispatch(Tabs.add("2")) store.dispatch(Tabs.activate("2")) - store.dispatch(Tabs.remove("2")) + store.dispatch(tabHistory.push("/url-for-tab-2")) + expect(global.tabHistories.count()).toBe(2) - expect(global.tabHistories.count()).toBe(1) - expect(currentPathnames()).toEqual(["/", "/url-for-tab-1"]) + store.dispatch(Tabs.remove("2")) + expect(global.tabHistories.count()).toBe(2) + expect(currentPathnames()).toEqual(["/welcome", "/url-for-tab-1"]) }) diff --git a/apps/zui/src/js/state/Tabs/reducer.ts b/apps/zui/src/js/state/Tabs/reducer.ts index dbda1d553f..bcdd1c7b18 100644 --- a/apps/zui/src/js/state/Tabs/reducer.ts +++ b/apps/zui/src/js/state/Tabs/reducer.ts @@ -28,6 +28,7 @@ const slice = createSlice({ remove(s, a: PayloadAction) { const id = a.payload const index = findTabIndex(s, id) + if (index === -1) return const isLast = index === s.data.length - 1 s.data.splice(index, 1) if (id === s.active) { diff --git a/apps/zui/src/js/state/Tabs/test.ts b/apps/zui/src/js/state/Tabs/test.ts index c20a0d3ce8..e77042db92 100644 --- a/apps/zui/src/js/state/Tabs/test.ts +++ b/apps/zui/src/js/state/Tabs/test.ts @@ -38,6 +38,11 @@ test("remove tab", () => { expect(Tabs.getCount(state)).toBe(0) }) +test("remove the id of a tab that doesn't exist", () => { + const state = dispatchAll(store, [Tabs.add("1"), Tabs.remove("999999")]) + expect(Tabs.getCount(state)).toBe(1) +}) + test("remove last, active tab", () => { const state = dispatchAll(store, [ Tabs.add("1"), diff --git a/apps/zui/src/js/state/migrations/202407221450_populateSessions.test.ts b/apps/zui/src/js/state/migrations/202407221450_populateSessions.test.ts new file mode 100644 index 0000000000..577de3809a --- /dev/null +++ b/apps/zui/src/js/state/migrations/202407221450_populateSessions.test.ts @@ -0,0 +1,64 @@ +import {migrate} from "src/test/unit/helpers/migrate" +import {getAllStates} from "./utils/getTestState" + +test("migrating 202407221450_populateSessions", async () => { + const next = await migrate({state: "v1.17.0", to: "202407221450"}) + + const sessions = { + entities: { + KbZNe9FuSHnKKfB398B0Z: { + id: "KbZNe9FuSHnKKfB398B0Z", + name: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + Zf8vsxTZ4mqT7IK1OtvRf: { + id: "Zf8vsxTZ4mqT7IK1OtvRf", + name: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + sIpManYfhNgo6gWdu10bA: { + id: "sIpManYfhNgo6gWdu10bA", + name: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }, + }, + ids: [ + "KbZNe9FuSHnKKfB398B0Z", + "sIpManYfhNgo6gWdu10bA", + "Zf8vsxTZ4mqT7IK1OtvRf", + ], + } + + const histories = { + KbZNe9FuSHnKKfB398B0Z: [ + { + queryId: "KbZNe9FuSHnKKfB398B0Z", + version: "mDbO6knM4vakcKQ0u4CLv", + }, + ], + Zf8vsxTZ4mqT7IK1OtvRf: [ + { + queryId: "Zf8vsxTZ4mqT7IK1OtvRf", + version: "88LzM379511XaOMGPKkHB", + }, + { + queryId: "Zf8vsxTZ4mqT7IK1OtvRf", + version: "YXX7LtHVgCJRgJPNR-USP", + }, + ], + sIpManYfhNgo6gWdu10bA: [ + { + queryId: "sIpManYfhNgo6gWdu10bA", + version: "BQS4GdEC5JcgV1fankooP", + }, + ], + } + + for (const state of getAllStates(next)) { + expect(state.querySessions).toEqual(sessions) + expect(state.sessionHistories).toEqual(histories) + } +}) diff --git a/apps/zui/src/js/state/migrations/202407221450_populateSessions.ts b/apps/zui/src/js/state/migrations/202407221450_populateSessions.ts new file mode 100644 index 0000000000..683aa09db5 --- /dev/null +++ b/apps/zui/src/js/state/migrations/202407221450_populateSessions.ts @@ -0,0 +1,53 @@ +import {getAllRendererStates, getAllStates} from "./utils/getTestState" + +export default function populateSessions(state: any) { + let sessions = [] + let histories = {} + + // First gather all the tab histories, + for (const win of getAllRendererStates(state)) { + if (!win.tabHistories) break + + const entities = win.tabHistories.ids.map( + (id) => win.tabHistories.entities[id] + ) + // filter the ones that are on a query session url + const sessionTabs = entities.filter((history) => + /queries\/.*\/versions\/.*/.test(history.entries[history.index]) + ) + // Push those tab ides to the session + sessions = sessions.concat( + sessionTabs.map((tab) => { + return { + id: tab.id, + name: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + }) + ) + + // Now gather the session histories to make them global + histories = { + ...histories, + ...win.sessionHistories, + } + } + + // Put these in the entity state shape + const querySessions = { + ids: sessions.map((session) => session.id), + entities: sessions.reduce((entities, session) => { + entities[session.id] = session + return entities + }, {}), + } + + // Next add all those sessions to each state object, since these will be global + for (const s of getAllStates(state)) { + s.querySessions = querySessions + s.sessionHistories = histories + } + + return state +} diff --git a/apps/zui/src/js/state/migrations/index.ts b/apps/zui/src/js/state/migrations/index.ts index 5a6cd47505..388a46e639 100644 --- a/apps/zui/src/js/state/migrations/index.ts +++ b/apps/zui/src/js/state/migrations/index.ts @@ -44,3 +44,4 @@ export * as v202302131226 from "./202302131226_renameDelimeterToDelimiter" export * as v202302161437 from "./202302161437_addLayoutDefaults" export * as v202307101053 from "./202307101053_migrateLakeTabs" export * as v202307141454 from "./202307141454_moveSecondarySidebarState" +export * as v202407221450 from "./202407221450_populateSessions" diff --git a/apps/zui/src/js/state/stores/get-persistable.ts b/apps/zui/src/js/state/stores/get-persistable.ts index 10783ad9f7..697aa3f813 100644 --- a/apps/zui/src/js/state/stores/get-persistable.ts +++ b/apps/zui/src/js/state/stores/get-persistable.ts @@ -14,11 +14,12 @@ export const GLOBAL_PERSIST: StateKey[] = [ "queryVersions", "sessionQueries", "poolSettings", + "querySessions", + "sessionHistories", ] export const WINDOW_PERSIST: StateKey[] = [ "appearance", - "sessionHistories", "tabHistories", "window", ] diff --git a/apps/zui/src/js/state/stores/root-reducer.ts b/apps/zui/src/js/state/stores/root-reducer.ts index 64e9c0bb43..c0405e9f3c 100644 --- a/apps/zui/src/js/state/stores/root-reducer.ts +++ b/apps/zui/src/js/state/stores/root-reducer.ts @@ -21,6 +21,7 @@ import PoolSettings from "../PoolSettings" import Window from "../Window" import LoadDataForm from "../LoadDataForm" import Updates from "../Updates" +import {QuerySession} from "src/models/query-session" const rootReducer = combineReducers({ appearance: Appearance.reducer, @@ -44,8 +45,8 @@ const rootReducer = combineReducers({ url: Url.reducer, window: Window.reducer, updates: Updates.reducer, + ...QuerySession.slice, }) - // A proof of concept. This would be a much nicer way to go // once we have time to convert to it. // type RootState = ReturnType diff --git a/apps/zui/src/js/state/types.ts b/apps/zui/src/js/state/types.ts index 9ec54ac243..71d8c61a54 100644 --- a/apps/zui/src/js/state/types.ts +++ b/apps/zui/src/js/state/types.ts @@ -22,6 +22,7 @@ import {WindowState} from "./Window/types" import {LoadDataFormState} from "./LoadDataForm/types" import {UpdatesState} from "./Updates/types" import {EnhancedStore} from "@reduxjs/toolkit" +import {QuerySessionState} from "src/models/query-session" export type ThunkExtraArg = { api: ZuiApi @@ -56,4 +57,5 @@ export type State = { toolbars: ToolbarsState window: WindowState updates: UpdatesState + querySessions: QuerySessionState } diff --git a/apps/zui/src/models/active.ts b/apps/zui/src/models/active.ts index c80575c3d1..bfec5d82ae 100644 --- a/apps/zui/src/models/active.ts +++ b/apps/zui/src/models/active.ts @@ -1,12 +1,12 @@ import {DomainModel} from "src/core/domain-model" import {Session} from "./session" import Current from "src/js/state/Current" -import {EditorSnapshot} from "./editor-snapshot" import {BrowserTab} from "./browser-tab" import {Frame} from "./frame" import {getActiveTab} from "src/js/state/Tabs/selectors" import Editor from "src/js/state/Editor" import {Lake} from "./lake" +import {EditorSnapshot} from "./editor-snapshot" export class Active extends DomainModel { static get tab() { diff --git a/apps/zui/src/models/application-entity.ts b/apps/zui/src/models/application-entity.ts new file mode 100644 index 0000000000..a3c792e3c8 --- /dev/null +++ b/apps/zui/src/models/application-entity.ts @@ -0,0 +1,6 @@ +import {Entity} from "bullet" +import {useSelector} from "react-redux" + +export class ApplicationEntity extends Entity { + static useSelector = useSelector +} diff --git a/apps/zui/src/models/browser-tab.ts b/apps/zui/src/models/browser-tab.ts index f6b9797f64..a72e82196b 100644 --- a/apps/zui/src/models/browser-tab.ts +++ b/apps/zui/src/models/browser-tab.ts @@ -22,6 +22,10 @@ export class BrowserTab extends DomainModel { }) } + static get count() { + return this.all.length + } + static orderBy(attr: keyof Attrs, direction: "asc" | "desc") { return orderBy(this.all, [(tab) => tab.attrs[attr]], [direction]) } diff --git a/apps/zui/src/models/query-session.ts b/apps/zui/src/models/query-session.ts new file mode 100644 index 0000000000..61b6e53072 --- /dev/null +++ b/apps/zui/src/models/query-session.ts @@ -0,0 +1,81 @@ +import {AttributeTypes} from "bullet" +import {ApplicationEntity} from "./application-entity" +import {EntityState} from "@reduxjs/toolkit" +import {BrowserTab} from "./browser-tab" +import Tabs from "src/js/state/Tabs" +import {actions} from "src/js/state/SessionHistories/reducer" +import {getById} from "src/js/state/SessionHistories/selectors" +import {queryPath} from "src/app/router/utils/paths" +import {last} from "lodash" +import {EditorSnapshot} from "./editor-snapshot" + +const schema = { + name: {type: String, default: null as string}, +} + +type Attributes = AttributeTypes + +export type QuerySessionState = EntityState + +export class QuerySession extends ApplicationEntity { + static schema = schema + static actionPrefix = "$querySessions" + static sliceName = "querySessions" + + activate() { + if (this.tab) this.tab.activate() + else this.restore() + } + + get tab() { + return BrowserTab.find(this.id) + } + + get history() { + return this.select(getById(this.id)) || [] + } + + get lastSnapshot() { + const entry = last(this.history) + if (!entry) return null + return EditorSnapshot.find(entry.queryId, entry.version) + } + + get displayName() { + const snapshot = this.lastSnapshot + if (snapshot) return snapshot.toQueryText() + else return "(New Session)" + } + + get isActive() { + return this.select(Tabs.getActive) === this.id + } + + restore() { + const history = this.history + const entry = history && last(history) + if (entry) { + const url = queryPath(entry.queryId, entry.version) + this.dispatch(Tabs.create(url, this.id)) + } else { + const snapshot = new EditorSnapshot({ + pins: [], + value: "", + parentId: this.id, + }) + snapshot.save() + this.dispatch(Tabs.create(snapshot.pathname, this.id)) + } + } + + destroy() { + super.destroy() + this.dispatch(actions.deleteById({sessionId: this.id})) + global.tabHistories.delete(this.id) + if (this.isActive) { + this.dispatch(Tabs.closeActive()) + } else { + this.dispatch(Tabs.remove(this.id)) + } + } +} diff --git a/apps/zui/src/models/session.ts b/apps/zui/src/models/session.ts index 85ba6ce78c..0f07cebb70 100644 --- a/apps/zui/src/models/session.ts +++ b/apps/zui/src/models/session.ts @@ -8,8 +8,8 @@ import {SessionHistory} from "./session-history" import {NamedQuery} from "./named-query" import {BrowserTab} from "./browser-tab" import SessionQueries from "src/js/state/SessionQueries" -import {nanoid} from "@reduxjs/toolkit" import {queryVersion} from "src/app/router/routes" +import {QuerySession} from "./query-session" type Attrs = { id: string @@ -30,7 +30,7 @@ export class Session extends DomainModel { } static create() { - const id = nanoid() + const {id} = QuerySession.create() const now = new Date().toISOString() this.dispatch(SessionQueries.init(id)) BrowserTab.create({id, lastFocused: now}) diff --git a/apps/zui/src/test/unit/states/v1.17.0.json b/apps/zui/src/test/unit/states/v1.17.0.json new file mode 100644 index 0000000000..dc53216a1d --- /dev/null +++ b/apps/zui/src/test/unit/states/v1.17.0.json @@ -0,0 +1 @@ +{"version":202307141454,"data":{"order":["J5iEOzXPZM4-eB8qluufU","Vku4UEe1D-dEZLJGu5eSm"],"windows":{"J5iEOzXPZM4-eB8qluufU":{"name":"search","state":{"appearance":{"sidebarIsOpen":true,"sidebarWidth":250,"secondarySidebarIsOpen":true,"secondarySidebarWidth":400,"currentSectionName":"pools","historyView":"linear","poolsOpenState":{},"queriesOpenState":{}},"sessionHistories":{"KbZNe9FuSHnKKfB398B0Z":[{"queryId":"KbZNe9FuSHnKKfB398B0Z","version":"mDbO6knM4vakcKQ0u4CLv"}]},"tabHistories":{"ids":["4r9pCPQD_yYkS8Is0x5ab","KbZNe9FuSHnKKfB398B0Z"],"entities":{"4r9pCPQD_yYkS8Is0x5ab":{"id":"4r9pCPQD_yYkS8Is0x5ab","entries":["/welcome"],"index":0},"KbZNe9FuSHnKKfB398B0Z":{"id":"KbZNe9FuSHnKKfB398B0Z","entries":["/queries/KbZNe9FuSHnKKfB398B0Z/versions/0","/queries/KbZNe9FuSHnKKfB398B0Z/versions/mDbO6knM4vakcKQ0u4CLv"],"index":1}}},"window":{"tabs":{"localhost:9867":{"active":"KbZNe9FuSHnKKfB398B0Z","preview":null,"data":[{"editor":{"value":"","pins":[],"pinEditIndex":null,"pinHoverIndex":null,"markers":[]},"id":"4r9pCPQD_yYkS8Is0x5ab","lastFocused":"2024-07-22T21:55:14.669Z","layout":{"columnHeadersView":"AUTO","resultsView":"TABLE","currentPaneName":"history","isEditingTitle":false,"titleFormAction":"create","showHistogram":true,"editorHeight":100,"chartHeight":100}},{"editor":{"value":"","pins":[{"type":"from","value":"Zeek"}],"pinEditIndex":null,"pinHoverIndex":null,"markers":[]},"id":"KbZNe9FuSHnKKfB398B0Z","lastFocused":"2024-07-22T21:55:20.353Z","layout":{"columnHeadersView":"AUTO","resultsView":"TABLE","currentPaneName":"history","isEditingTitle":false,"titleFormAction":"create","showHistogram":true,"editorHeight":100,"chartHeight":100}}]}},"lakeId":"localhost:9867"},"query_sessions":{"ids":["KbZNe9FuSHnKKfB398B0Z"],"entities":{"KbZNe9FuSHnKKfB398B0Z":{"id":"KbZNe9FuSHnKKfB398B0Z","createdAt":"2024-07-22T21:55:18.535Z","updatedAt":"2024-07-22T21:55:18.535Z"}}}},"size":[1216,703],"position":[286,172]},"Vku4UEe1D-dEZLJGu5eSm":{"name":"search","state":{"appearance":{"sidebarIsOpen":true,"sidebarWidth":250,"secondarySidebarIsOpen":true,"secondarySidebarWidth":400,"currentSectionName":"pools","historyView":"linear","poolsOpenState":{},"queriesOpenState":{}},"sessionHistories":{"sIpManYfhNgo6gWdu10bA":[{"queryId":"sIpManYfhNgo6gWdu10bA","version":"BQS4GdEC5JcgV1fankooP"}],"Zf8vsxTZ4mqT7IK1OtvRf":[{"queryId":"Zf8vsxTZ4mqT7IK1OtvRf","version":"88LzM379511XaOMGPKkHB"},{"queryId":"Zf8vsxTZ4mqT7IK1OtvRf","version":"YXX7LtHVgCJRgJPNR-USP"}]},"tabHistories":{"ids":["KXQPhZmeCuydNXfaHaUT1","kdDEVovYVHS21tjnETGPf","aTXdJfbdZv72OllBUSrPH","sIpManYfhNgo6gWdu10bA","Zf8vsxTZ4mqT7IK1OtvRf"],"entities":{"KXQPhZmeCuydNXfaHaUT1":{"id":"KXQPhZmeCuydNXfaHaUT1","entries":["/welcome"],"index":0},"kdDEVovYVHS21tjnETGPf":{"id":"kdDEVovYVHS21tjnETGPf","entries":["/release-notes"],"index":0},"aTXdJfbdZv72OllBUSrPH":{"id":"aTXdJfbdZv72OllBUSrPH","entries":["/pools/0x131b2e19f15fa17462f0c00ae61c4d6b9df0e03a"],"index":0},"sIpManYfhNgo6gWdu10bA":{"id":"sIpManYfhNgo6gWdu10bA","entries":["/queries/sIpManYfhNgo6gWdu10bA/versions/0","/queries/sIpManYfhNgo6gWdu10bA/versions/BQS4GdEC5JcgV1fankooP"],"index":1},"Zf8vsxTZ4mqT7IK1OtvRf":{"id":"Zf8vsxTZ4mqT7IK1OtvRf","entries":["/queries/Zf8vsxTZ4mqT7IK1OtvRf/versions/0","/queries/Zf8vsxTZ4mqT7IK1OtvRf/versions/88LzM379511XaOMGPKkHB","/queries/Zf8vsxTZ4mqT7IK1OtvRf/versions/F8SPMf-AioseJa3F7P4P4","/queries/Zf8vsxTZ4mqT7IK1OtvRf/versions/YXX7LtHVgCJRgJPNR-USP"],"index":3}}},"window":{"tabs":{"localhost:9867":{"active":"Zf8vsxTZ4mqT7IK1OtvRf","preview":null,"data":[{"editor":{"value":"","pins":[],"pinEditIndex":null,"pinHoverIndex":null,"markers":[]},"id":"KXQPhZmeCuydNXfaHaUT1","lastFocused":"2024-07-22T21:51:41.130Z","layout":{"columnHeadersView":"AUTO","resultsView":"TABLE","currentPaneName":"history","isEditingTitle":false,"titleFormAction":"create","showHistogram":true,"editorHeight":100,"chartHeight":100}},{"editor":{"value":"","pins":[],"pinEditIndex":null,"pinHoverIndex":null,"markers":[]},"id":"aTXdJfbdZv72OllBUSrPH","lastFocused":"2024-07-22T21:51:46.901Z","layout":{"columnHeadersView":"AUTO","resultsView":"TABLE","currentPaneName":"history","isEditingTitle":false,"titleFormAction":"create","showHistogram":true,"editorHeight":100,"chartHeight":100}},{"editor":{"value":"","pins":[{"type":"from","value":"Zeek"}],"pinEditIndex":null,"pinHoverIndex":null,"markers":[]},"id":"sIpManYfhNgo6gWdu10bA","lastFocused":"2024-07-22T21:51:52.288Z","layout":{"columnHeadersView":"AUTO","resultsView":"TABLE","currentPaneName":"history","isEditingTitle":false,"titleFormAction":"create","showHistogram":true,"editorHeight":100,"chartHeight":100}},{"editor":{"value":"count()","pins":[{"type":"from","value":"Zeek"}],"pinEditIndex":null,"pinHoverIndex":null,"markers":[]},"id":"Zf8vsxTZ4mqT7IK1OtvRf","lastFocused":"2024-07-22T21:51:54.637Z","layout":{"columnHeadersView":"AUTO","resultsView":"TABLE","currentPaneName":"history","isEditingTitle":false,"titleFormAction":"create","showHistogram":true,"editorHeight":100,"chartHeight":100}}]}},"lakeId":"localhost:9867"},"query_sessions":{"ids":["sIpManYfhNgo6gWdu10bA","Zf8vsxTZ4mqT7IK1OtvRf"],"entities":{"sIpManYfhNgo6gWdu10bA":{"id":"sIpManYfhNgo6gWdu10bA","createdAt":"2024-07-22T21:51:50.220Z","updatedAt":"2024-07-22T21:51:50.220Z"},"Zf8vsxTZ4mqT7IK1OtvRf":{"id":"Zf8vsxTZ4mqT7IK1OtvRf","createdAt":"2024-07-22T21:51:53.604Z","updatedAt":"2024-07-22T21:51:53.604Z"}}}},"size":[1470,900],"position":[97,59]}},"globalState":{"configPropValues":{"application":{"updateMode":"startup"},"pools":{"nameDelimiter":"/"},"display":{"timeZone":"UTC","timeFormat":"","thousandsSeparator":",","decimal":"."},"editor":{"runQueryOnEnter":"enter"},"defaultLake":{"address":"localhost"},"brimcap":{"yamlConfigPath":"","suricataLocalRulesPath":"","pcapExtractionFolderPath":""}},"lakes":{"localhost:9867":{"host":"http://localhost","port":9867,"id":"localhost:9867","name":"jkerr's Zed Lake","authType":"none","version":"v1.17.0-1-g9766d17d","features":{"describe":true}}},"launches":{"1.17.0":"2024-07-22T21:51:39.019Z"},"queries":{"id":"root","name":"root","isOpen":true,"items":[]},"queryVersions":{"sIpManYfhNgo6gWdu10bA":{"ids":["0","BQS4GdEC5JcgV1fankooP"],"entities":{"0":{"ts":"2024-07-22T21:51:50.221Z","version":"0","value":"","pins":[]},"BQS4GdEC5JcgV1fankooP":{"version":"BQS4GdEC5JcgV1fankooP","ts":"2024-07-22T21:51:52.289Z","value":"","pins":[{"type":"from","value":"Zeek"}]}}},"Zf8vsxTZ4mqT7IK1OtvRf":{"ids":["0","88LzM379511XaOMGPKkHB","F8SPMf-AioseJa3F7P4P4","YXX7LtHVgCJRgJPNR-USP"],"entities":{"0":{"ts":"2024-07-22T21:51:53.605Z","version":"0","value":"","pins":[]},"88LzM379511XaOMGPKkHB":{"version":"88LzM379511XaOMGPKkHB","ts":"2024-07-22T21:51:54.639Z","value":"","pins":[{"type":"from","value":"Zeek"}]},"F8SPMf-AioseJa3F7P4P4":{"version":"F8SPMf-AioseJa3F7P4P4","ts":"2024-07-22T21:51:58.443Z","value":"coun()","pins":[{"type":"from","value":"Zeek"}]},"YXX7LtHVgCJRgJPNR-USP":{"version":"YXX7LtHVgCJRgJPNR-USP","ts":"2024-07-22T21:52:00.870Z","value":"count()","pins":[{"type":"from","value":"Zeek"}]}}},"KbZNe9FuSHnKKfB398B0Z":{"ids":["0","mDbO6knM4vakcKQ0u4CLv"],"entities":{"0":{"ts":"2024-07-22T21:55:18.536Z","version":"0","value":"","pins":[]},"mDbO6knM4vakcKQ0u4CLv":{"version":"mDbO6knM4vakcKQ0u4CLv","ts":"2024-07-22T21:55:20.354Z","value":"","pins":[{"type":"from","value":"Zeek"}]}}}},"sessionQueries":{"KXQPhZmeCuydNXfaHaUT1":{"id":"KXQPhZmeCuydNXfaHaUT1","name":"Query Session"},"kdDEVovYVHS21tjnETGPf":{"id":"kdDEVovYVHS21tjnETGPf","name":"Query Session"},"yGhtm6Nk3bk8gg6_7J4p8":{"id":"yGhtm6Nk3bk8gg6_7J4p8","name":"Query Session"},"aTXdJfbdZv72OllBUSrPH":{"id":"aTXdJfbdZv72OllBUSrPH","name":"Query Session"},"sIpManYfhNgo6gWdu10bA":{"id":"sIpManYfhNgo6gWdu10bA","name":"Query Session"},"Zf8vsxTZ4mqT7IK1OtvRf":{"id":"Zf8vsxTZ4mqT7IK1OtvRf","name":"Query Session"},"4r9pCPQD_yYkS8Is0x5ab":{"id":"4r9pCPQD_yYkS8Is0x5ab","name":"Query Session"},"KbZNe9FuSHnKKfB398B0Z":{"id":"KbZNe9FuSHnKKfB398B0Z","name":"Query Session"}},"poolSettings":{"ids":[],"entities":{}}}}} \ No newline at end of file diff --git a/apps/zui/src/views/sessions-pane/handler.ts b/apps/zui/src/views/sessions-pane/handler.ts new file mode 100644 index 0000000000..f7e2e200b2 --- /dev/null +++ b/apps/zui/src/views/sessions-pane/handler.ts @@ -0,0 +1,18 @@ +import {showMenu} from "src/core/menu" +import {ViewHandler} from "src/core/view-handler" +import {QuerySession} from "src/models/query-session" + +export class SessionsPaneHandler extends ViewHandler { + constructor() { + super() + } + + showMenu(item: QuerySession) { + return showMenu([ + { + label: "Delete", + click: () => item.destroy(), + }, + ]) + } +} diff --git a/apps/zui/src/views/sessions-pane/index.tsx b/apps/zui/src/views/sessions-pane/index.tsx new file mode 100644 index 0000000000..d71f9b55f7 --- /dev/null +++ b/apps/zui/src/views/sessions-pane/index.tsx @@ -0,0 +1,46 @@ +import {useSelector} from "react-redux" +import {Icon} from "src/components/icon" +import {VirtualList} from "src/js/components/virtual-list" +import SessionHistories from "src/js/state/SessionHistories" +import {QuerySession} from "src/models/query-session" +import {SessionsPaneHandler} from "./handler" +import Tabs from "src/js/state/Tabs" + +export function SessionsPane() { + useSelector(SessionHistories.raw) // We need this here to update the display name + useSelector(Tabs.getActive) // We need this to update isActive + const sessions = QuerySession.useAll().sort( + (item, pivot) => pivot.createdAt.getTime() - item.createdAt.getTime() + ) + const handler = new SessionsPaneHandler() + + return ( +
+ + {({item, style}) => ( +
  • item.activate()} + onContextMenu={() => handler.showMenu(item)} + > +
    + + + {item.displayName} + +
    +
  • + )} +
    +
    + ) +} diff --git a/apps/zui/src/views/sidebar/body.tsx b/apps/zui/src/views/sidebar/body.tsx index d875ef866f..8beef4bd61 100644 --- a/apps/zui/src/views/sidebar/body.tsx +++ b/apps/zui/src/views/sidebar/body.tsx @@ -6,4 +6,6 @@ export const Body = styled.section` height: 100%; display: flex; flex-direction: column; + z-index: 199; /* Above the drag anchor */ + margin-inline-end: 1px; ` diff --git a/apps/zui/src/views/sidebar/index.tsx b/apps/zui/src/views/sidebar/index.tsx index 40c7b9c564..0b124e5244 100644 --- a/apps/zui/src/views/sidebar/index.tsx +++ b/apps/zui/src/views/sidebar/index.tsx @@ -11,6 +11,7 @@ import {Menu} from "./menu" import {SidebarToggleButton} from "./sidebar-toggle-button" import AppErrorBoundary from "src/js/components/AppErrorBoundary" import {Body} from "./body" +import {SessionsPane} from "../sessions-pane" const EmptyText = styled.div` ${(p) => p.theme.typography.labelNormal} @@ -26,6 +27,8 @@ const PaneSwitch = ({name}) => { return case "queries": return + case "sessions": + return default: return null } diff --git a/apps/zui/src/views/sidebar/menu.tsx b/apps/zui/src/views/sidebar/menu.tsx index f1cbded6ef..a814c0c3cd 100644 --- a/apps/zui/src/views/sidebar/menu.tsx +++ b/apps/zui/src/views/sidebar/menu.tsx @@ -17,7 +17,7 @@ export function Menu() { }) return (
    diff --git a/yarn.lock b/yarn.lock index ab976ea161..44e366a648 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6814,6 +6814,17 @@ __metadata: languageName: node linkType: hard +"bullet@npm:^0.0.2": + version: 0.0.2 + resolution: "bullet@npm:0.0.2" + dependencies: + "@reduxjs/toolkit": ^2.2.5 + lodash-es: ^4.17.21 + pluralize: ^8.0.0 + checksum: 9e99282d52e346f2f7612a581f65d3693c2aa9ddd9ac5352abf7990d79c34748d1d196d88e9fcc96243d9d286a7e25d876d174e93d99a8519598c36a9be85ae5 + languageName: node + linkType: hard + "bundle-name@npm:^3.0.0": version: 3.0.0 resolution: "bundle-name@npm:3.0.0" @@ -12438,6 +12449,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash-es@npm:4.17.21" + checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -14427,6 +14445,13 @@ __metadata: languageName: node linkType: hard +"pluralize@npm:^8.0.0": + version: 8.0.0 + resolution: "pluralize@npm:8.0.0" + checksum: 08931d4a6a4a5561a7f94f67a31c17e6632cb21e459ab3ff4f6f629d9a822984cf8afef2311d2005fbea5d7ef26016ebb090db008e2d8bce39d0a9a9d218736e + languageName: node + linkType: hard + "polished@npm:^3.6.5": version: 3.6.5 resolution: "polished@npm:3.6.5" @@ -18322,6 +18347,7 @@ __metadata: ajv: ^6.9.1 animejs: ^3.2.0 brimcap: "brimdata/brimcap#0c3a564771dd204e9ce93e4600de2dbcf97bd446" + bullet: ^0.0.2 chalk: ^4.1.0 chevrotain: ^10.5.0 chrono-node: ^2.5.0