Skip to content

Commit 114a78b

Browse files
authored
Merge pull request #4120 from RedisInsight/fe/feature/CR-215-workbench-local-storage-history
CR-215 workbench local storage history
2 parents fe1d1c7 + 171f6ae commit 114a78b

File tree

6 files changed

+387
-17
lines changed

6 files changed

+387
-17
lines changed

redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -276,21 +276,21 @@ const QueryCardCliPlugin = (props: Props) => {
276276
data-testid="pluginIframe"
277277
/>
278278
{!!error && (
279-
<div className={styles.container}>
280-
<EuiFlexItem className="query-card-output-response-fail">
281-
<span data-testid="query-card-no-module-output">
282-
<span className={styles.alertIconWrapper}>
279+
<div className={styles.container}>
280+
<EuiFlexItem className="query-card-output-response-fail">
281+
<span data-testid="query-card-no-module-output">
282+
<span className={styles.alertIconWrapper}>
283283
<EuiIcon type="alert" color="danger" style={{ display: 'inline', marginRight: 10 }} />
284-
</span>
285-
<EuiTextColor color="danger">{error}</EuiTextColor>
286284
</span>
287-
</EuiFlexItem>
288-
</div>
285+
<EuiTextColor color="danger">{error}</EuiTextColor>
286+
</span>
287+
</EuiFlexItem>
288+
</div>
289289
)}
290290
{!isPluginLoaded && (
291-
<div>
292-
<EuiLoadingContent lines={5} />
293-
</div>
291+
<div>
292+
<EuiLoadingContent lines={5} />
293+
</div>
294294
)}
295295
</div>
296296
</div>

redisinsight/ui/src/config/default.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@ export const defaultConfig = {
5151
activityMonitorThrottleTimeout: intEnv('RI_ACTIVITY_MONITOR_THROTTLE_TIMEOUT', 30_000),
5252
sessionTtlSeconds: intEnv('RI_SESSION_TTL_SECONDS', 30 * 60),
5353
localResourcesBaseUrl: process.env.RI_LOCAL_RESOURCES_BASE_URL,
54-
useLocalResources: booleanEnv('RI_USE_LOCAL_RESOURCES', false)
54+
useLocalResources: booleanEnv('RI_USE_LOCAL_RESOURCES', false),
55+
indexedDbName: process.env.RI_INDEXED_DB_NAME || 'RI_LOCAL_STORAGE',
5556
},
5657
workbench: {
5758
pipelineCountDefault: intEnv('PIPELINE_COUNT_DEFAULT', 5),
59+
maxResultSize: intEnv('RI_COMMAND_EXECUTION_MAX_RESULT_SIZE', 1024 * 1024),
5860
},
5961
browser: {
6062
scanCountDefault: intEnv('RI_SCAN_COUNT_DEFAULT', 500),

redisinsight/ui/src/constants/storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ enum BrowserStorageItem {
1313
segmentAnonymousId = 'ajs_anonymous_id',
1414
wbClientUuid = 'wbClientUuid',
1515
wbInputHistory = 'wbInputHistory',
16+
wbCommandsHistory = 'command_execution',
1617
treeViewDelimiter = 'treeViewDelimiter',
1718
treeViewSort = 'treeViewSort',
1819
autoRefreshRate = 'autoRefreshRate',

redisinsight/ui/src/services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './theme'
1010
export * from './hooks'
1111
export * from './capability'
1212
export { apiService, resourcesService }
13+
export { WorkbenchStorage } from 'uiSrc/services/workbenchStorage';
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import { flatten } from 'lodash'
2+
import { CommandExecution } from 'uiSrc/slices/interfaces'
3+
import { BrowserStorageItem } from 'uiSrc/constants'
4+
import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli'
5+
import { getConfig } from 'uiSrc/config'
6+
import { formatBytes } from 'uiSrc/utils'
7+
import { WORKBENCH_HISTORY_MAX_LENGTH } from 'uiSrc/pages/workbench/constants'
8+
9+
const riConfig = getConfig()
10+
11+
export class WorkbenchStorage {
12+
private db?: IDBDatabase
13+
14+
constructor(private readonly dbName: string, private readonly version = 1) {
15+
}
16+
17+
private initDb(storeName: string) {
18+
return new Promise((resolve, reject) => {
19+
if (!window.indexedDB) {
20+
reject(new Error('indexedDB is not supported'))
21+
return
22+
}
23+
// Let us open our database
24+
const DBOpenRequest = window.indexedDB.open(this.dbName, this.version)
25+
26+
DBOpenRequest.onerror = (event) => {
27+
event.preventDefault()
28+
reject(DBOpenRequest.error)
29+
console.error('indexedDB open error')
30+
}
31+
32+
DBOpenRequest.onsuccess = () => {
33+
this.db = DBOpenRequest.result
34+
this.db.onversionchange = (e) => {
35+
// Triggered when the database is modified (e.g. adding an objectStore) or
36+
// deleted (even when initiated by other sessions in different tabs).
37+
// Closing the connection here prevents those operations from being blocked.
38+
// If the database is accessed again later by this instance, the connection
39+
// will be reopened or the database recreated as needed.
40+
(e.target as IDBDatabase)?.close()
41+
}
42+
resolve(this.db)
43+
}
44+
45+
// This event handles the event whereby a new version of the database needs to be created
46+
// Either one has not been created before, or a new version number has been submitted via the
47+
// window.indexedDB.open line above
48+
// it is only implemented in recent browsers
49+
DBOpenRequest.onupgradeneeded = (event) => {
50+
this.db = DBOpenRequest.result
51+
52+
this.db.onerror = (event) => {
53+
event.preventDefault()
54+
reject(DBOpenRequest.error)
55+
}
56+
57+
try {
58+
if (event.newVersion && event.newVersion > event.oldVersion
59+
&& event.oldVersion > 0
60+
&& this.db.objectStoreNames.contains(storeName)) {
61+
// if there is need to update
62+
this.db.deleteObjectStore(storeName)
63+
}
64+
// Create an objectStore for this database
65+
const objectStore = this.db.createObjectStore(storeName, { keyPath: ['id', 'databaseId'] })
66+
objectStore.createIndex('dbId', 'databaseId', { unique: false })
67+
objectStore.createIndex('commandId', 'id', { unique: true })
68+
} catch (ex) {
69+
if (ex instanceof DOMException && ex?.name === 'ConstraintError') {
70+
console.warn(
71+
`The database "${this.dbName}" has been upgraded from version ${event.oldVersion} to version
72+
${event.newVersion}, but the storage "${storeName}" already exists.`,
73+
)
74+
} else {
75+
throw ex
76+
}
77+
}
78+
}
79+
})
80+
}
81+
82+
async getDb(storeName: string) {
83+
if (!this.db) {
84+
await this.initDb(storeName)
85+
}
86+
return this.db
87+
}
88+
89+
getItem(storeName: string, commandId: string, onSuccess?: () => void, onError?: () => void) {
90+
return new Promise((resolve, reject) => {
91+
try {
92+
this.getDb(storeName).then((db) => {
93+
if (db === undefined) {
94+
reject(new Error('Failed to retrieve item from IndexedDB'))
95+
return
96+
}
97+
const objectStore = db.transaction(storeName, 'readonly')?.objectStore(storeName)
98+
const idbIndex = objectStore?.index('commandId')
99+
const indexReq = idbIndex?.get(commandId)
100+
indexReq.onsuccess = () => {
101+
const value = indexReq.result
102+
onSuccess?.()
103+
resolve(value)
104+
}
105+
indexReq.onerror = () => {
106+
onError?.()
107+
reject(indexReq.error)
108+
}
109+
})
110+
} catch (e) {
111+
onError?.()
112+
reject(e)
113+
}
114+
})
115+
}
116+
117+
getItems(storeName: string, dbId: string, onSuccess?: () => void, onError?: () => void) {
118+
return new Promise((resolve, reject) => {
119+
try {
120+
this.getDb(storeName).then((db) => {
121+
if (db === undefined) {
122+
reject(new Error('Failed to retrieve item from IndexedDB'))
123+
return
124+
}
125+
const objectStore = db.transaction(storeName, 'readonly')?.objectStore(storeName)
126+
const idbIndex = objectStore?.index('dbId')
127+
const indexReq = idbIndex?.getAll(dbId)
128+
indexReq.onsuccess = () => {
129+
const values = indexReq.result
130+
onSuccess?.()
131+
if (values && values.length > 0) {
132+
resolve(values)
133+
} else {
134+
resolve([])
135+
}
136+
}
137+
indexReq.onerror = () => {
138+
onError?.()
139+
reject(indexReq.error)
140+
}
141+
})
142+
} catch (e) {
143+
onError?.()
144+
reject(e)
145+
}
146+
})
147+
}
148+
149+
setItem(storeName: string, value: any, onSuccess?: () => void, onError?: () => void): Promise<void> {
150+
return new Promise((resolve, reject) => {
151+
try {
152+
this.getDb(storeName).then((db) => {
153+
if (db === undefined) {
154+
reject(new Error('Failed to set item in IndexedDB'))
155+
return
156+
}
157+
const transaction = db.transaction(storeName, 'readwrite')
158+
const req = transaction?.objectStore(storeName)?.put(value)
159+
transaction.oncomplete = () => {
160+
onSuccess?.()
161+
resolve()
162+
}
163+
transaction.onerror = () => {
164+
onError?.()
165+
reject(req?.error)
166+
}
167+
})
168+
} catch (e) {
169+
reject(e)
170+
}
171+
})
172+
}
173+
174+
removeItem(
175+
storeName: string,
176+
dbId: string,
177+
commandId: string,
178+
onSuccess?: () => void,
179+
onError?: () => void
180+
): Promise<string | void> {
181+
return new Promise((resolve, reject) => {
182+
try {
183+
this.getDb(storeName).then((db) => {
184+
if (db === undefined) {
185+
reject(new Error('Failed to remove item from IndexedDB'))
186+
return
187+
}
188+
const transaction = db.transaction(storeName, 'readwrite')
189+
const req = transaction.objectStore(storeName)?.delete([commandId, dbId])
190+
191+
transaction.oncomplete = () => {
192+
onSuccess?.()
193+
resolve(commandId)
194+
}
195+
196+
transaction.onerror = () => {
197+
onError?.()
198+
reject(req?.error)
199+
}
200+
})
201+
} catch (e) {
202+
onError?.()
203+
reject(e)
204+
}
205+
})
206+
}
207+
208+
clear(storeName: string, dbId: string, onSuccess?: () => void, onError?: () => void): Promise<void> {
209+
return new Promise((resolve, reject) => {
210+
try {
211+
this.getDb(storeName).then((db) => {
212+
if (db === undefined) {
213+
reject(new Error('Failed to clear items in IndexedDB'))
214+
return
215+
}
216+
217+
const objectStore = db.transaction(storeName, 'readwrite')?.objectStore(storeName)
218+
const idbIndex = objectStore?.index('dbId')
219+
const indexReq = idbIndex?.openCursor(dbId)
220+
indexReq.onsuccess = () => {
221+
const cursor = indexReq.result
222+
onSuccess?.()
223+
if (cursor) {
224+
cursor.delete()
225+
cursor.continue()
226+
} else {
227+
// either deleted all items or there were none
228+
resolve()
229+
}
230+
}
231+
indexReq.onerror = () => {
232+
onError?.()
233+
reject(indexReq.error)
234+
}
235+
})
236+
} catch (e) {
237+
onError?.()
238+
reject(e)
239+
}
240+
})
241+
}
242+
}
243+
244+
export const wbHistoryStorage = new WorkbenchStorage(riConfig.app.indexedDbName, 2)
245+
246+
type CommandHistoryType = CommandExecution[]
247+
248+
export async function getLocalWbHistory(dbId: string) {
249+
try {
250+
const history = await wbHistoryStorage.getItems(BrowserStorageItem.wbCommandsHistory, dbId) as CommandHistoryType
251+
252+
return history || []
253+
} catch (e) {
254+
console.error(e)
255+
return []
256+
}
257+
}
258+
259+
export function saveLocalWbHistory(commandsHistory: CommandHistoryType) {
260+
try {
261+
const key = BrowserStorageItem.wbCommandsHistory
262+
return Promise.all(flatten(commandsHistory.map((chItem) => wbHistoryStorage.setItem(key, chItem))))
263+
} catch (e) {
264+
console.error(e)
265+
return null
266+
}
267+
}
268+
269+
async function cleanupDatabaseHistory(dbId: string) {
270+
const commandsHistory: CommandHistoryType = await getLocalWbHistory(dbId)
271+
let size = 0
272+
// collect items up to maxItemsPerDb
273+
const update = commandsHistory.reduce((acc, commandsHistoryElement) => {
274+
if (size >= WORKBENCH_HISTORY_MAX_LENGTH) {
275+
return acc
276+
}
277+
size++
278+
acc.push(commandsHistoryElement)
279+
return acc
280+
}, [] as CommandHistoryType)
281+
// clear old items
282+
await clearCommands(dbId)
283+
// save
284+
await saveLocalWbHistory(update)
285+
}
286+
287+
export async function addCommands(data: CommandExecution[]) {
288+
// Store command results in local storage!
289+
const storedData = data.map((item) => {
290+
// Do not store command execution result that exceeded limitation
291+
if (JSON.stringify(item.result).length > riConfig.workbench.maxResultSize) {
292+
item.result = [
293+
{
294+
status: CommandExecutionStatus.Success,
295+
response: `Results have been deleted since they exceed ${formatBytes(riConfig.workbench.maxResultSize)}.
296+
Re-run the command to see new results.`,
297+
},
298+
]
299+
}
300+
return item
301+
})
302+
await saveLocalWbHistory(storedData)
303+
const [{ databaseId }] = storedData
304+
return cleanupDatabaseHistory(databaseId)
305+
}
306+
307+
export async function removeCommand(dbId: string, commandId: string) {
308+
// Delete command from local storage?!
309+
await wbHistoryStorage.removeItem(BrowserStorageItem.wbCommandsHistory, dbId, commandId)
310+
}
311+
312+
export async function clearCommands(dbId: string) {
313+
await wbHistoryStorage.clear(BrowserStorageItem.wbCommandsHistory, dbId)
314+
}
315+
316+
export async function findCommand(commandId: string) {
317+
// Fetch command from local storage
318+
return wbHistoryStorage.getItem(BrowserStorageItem.wbCommandsHistory, commandId)
319+
}

0 commit comments

Comments
 (0)