chore: sync

This commit is contained in:
lencx
2022-12-23 15:27:05 +08:00
parent 2be560e69a
commit 389e00a5e0
17 changed files with 260 additions and 252 deletions

View File

@@ -2,46 +2,52 @@ import { useState, useEffect } from 'react';
import { clone } from 'lodash';
import { invoke } from '@tauri-apps/api';
import { CHAT_MODEL_JSON, readJSON, writeJSON } from '@/utils';
import { CHAT_MODEL_JSON, CHAT_MODEL_CMD_JSON, readJSON, writeJSON } from '@/utils';
import useInit from '@/hooks/useInit';
export default function useChatModel(key: string, file = CHAT_MODEL_JSON) {
const [modelJson, setModelJson] = useState<Record<string, any>>([]);
const [modelJson, setModelJson] = useState<Record<string, any>>({});
useInit(async () => {
const data = await readJSON(file, {
defaultVal: { name: 'ChatGPT Model', [key]: [] },
defaultVal: { name: 'ChatGPT Model', [key]: null },
});
setModelJson(data);
});
const modelSet = async (data: Record<string, any>[]) => {
const modelSet = async (data: Record<string, any>[]|Record<string, any>) => {
const oData = clone(modelJson);
oData[key] = data;
await writeJSON(file, oData);
await invoke('window_reload', { label: 'core' });
setModelJson(oData);
}
return { modelJson, modelSet, modelData: modelJson?.[key] || [] };
}
export function useCacheModel(file: string) {
const [modelJson, setModelJson] = useState<Record<string, any>[]>([]);
export function useCacheModel(file = '') {
const [modelCacheJson, setModelCacheJson] = useState<Record<string, any>[]>([]);
useEffect(() => {
if (!file) return;
(async () => {
const data = await readJSON(file, { isRoot: true });
setModelJson(data);
const data = await readJSON(file, { isRoot: true, isList: true });
setModelCacheJson(data);
})();
}, [file]);
const modelSet = async (data: Record<string, any>[]) => {
await writeJSON(file, data, { isRoot: true });
await invoke('window_reload', { label: 'core' });
setModelJson(data);
const modelCacheSet = async (data: Record<string, any>[], newFile = '') => {
await writeJSON(newFile ? newFile : file, data, { isRoot: true });
setModelCacheJson(data);
await modelCacheCmd();
}
return { modelJson, modelSet };
const modelCacheCmd = async () => {
// Generate the `chat.model.cmd.json` file and refresh the page for the slash command to take effect.
const list = await invoke('cmd_list');
await writeJSON(CHAT_MODEL_CMD_JSON, { name: 'ChatGPT CMD', last_updated: Date.now(), data: list });
await invoke('window_reload', { label: 'core' });
};
return { modelCacheJson, modelCacheSet, modelCacheCmd };
}

View File

@@ -17,6 +17,9 @@ export default function useData(oData: any[]) {
};
const opInit = (val: any[] = []) => {
if (!val || !Array.isArray(val)) return;
console.log('«20» /src/hooks/useData.ts ~> ', val);
const nData = val.map(i => ({ [safeKey]: v4(), ...i }));
setData(nData);
};

View File

@@ -5,7 +5,7 @@ import useChatModel from '@/hooks/useChatModel';
import { GITHUB_PROMPTS_CSV_URL, chatPromptsPath, genCmd } from '@/utils';
export default function useEvent() {
const { modelSet } = useChatModel('sys_sync_prompts');
const { modelSet } = useChatModel('sync_prompts');
// Using `emit` and `listen` will be triggered multiple times in development mode.
// So here we use `eval` to call `__sync_prompt`
useInit(() => {

41
src/main.scss vendored
View File

@@ -22,4 +22,45 @@ html, body {
.ant-table-selection-column {
width: 50px !important;
min-width: 50px !important;
}
.chat-prompts-val {
display: inline-block;
width: 100%;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.chat-add-btn {
margin-bottom: 5px;
}
.chat-prompts-tags {
.ant-tag {
margin: 2px;
}
}
.chat-sync-path {
font-size: 12px;
font-weight: 500;
color: #888;
margin-bottom: 5px;
line-height: 16px;
span {
display: inline-block;
// background-color: #d8d8d8;
color: #4096ff;
padding: 0 8px;
height: 20px;
line-height: 20px;
border-radius: 4px;
cursor: pointer;
text-decoration: underline;
}
}

15
src/utils.ts vendored
View File

@@ -3,7 +3,7 @@ import { homeDir, join, dirname } from '@tauri-apps/api/path';
import dayjs from 'dayjs';
export const CHAT_MODEL_JSON = 'chat.model.json';
export const CHAT_MODEL_SYNC_JSON = 'chat.model.sync.json';
export const CHAT_MODEL_CMD_JSON = 'chat.model.cmd.json';
export const CHAT_PROMPTS_CSV = 'chat.prompts.csv';
export const GITHUB_PROMPTS_CSV_URL = 'https://raw.githubusercontent.com/f/awesome-chatgpt-prompts/main/prompts.csv';
export const DISABLE_AUTO_COMPLETE = {
@@ -20,22 +20,23 @@ export const chatModelPath = async (): Promise<string> => {
return join(await chatRoot(), CHAT_MODEL_JSON);
}
export const chatModelSyncPath = async (): Promise<string> => {
return join(await chatRoot(), CHAT_MODEL_SYNC_JSON);
}
// export const chatModelSyncPath = async (): Promise<string> => {
// return join(await chatRoot(), CHAT_MODEL_SYNC_JSON);
// }
export const chatPromptsPath = async (): Promise<string> => {
return join(await chatRoot(), CHAT_PROMPTS_CSV);
}
type readJSONOpts = { defaultVal?: Record<string, any>, isRoot?: boolean };
type readJSONOpts = { defaultVal?: Record<string, any>, isRoot?: boolean, isList?: boolean };
export const readJSON = async (path: string, opts: readJSONOpts = {}) => {
const { defaultVal = {}, isRoot = false } = opts;
const { defaultVal = {}, isRoot = false, isList = false } = opts;
const root = await chatRoot();
const file = await join(isRoot ? '' : root, path);
if (!await exists(file)) {
writeTextFile(file, JSON.stringify({
await createDir(await dirname(file), { recursive: true });
await writeTextFile(file, isList ? '[]' : JSON.stringify({
name: 'ChatGPT',
link: 'https://github.com/lencx/ChatGPT',
...defaultVal,

View File

@@ -1,28 +0,0 @@
.chat-prompts-tags {
.ant-tag {
margin: 2px;
}
}
.add-btn {
margin-bottom: 10px;
}
.chat-model-path {
font-size: 12px;
font-weight: bold;
color: #888;
margin-bottom: 5px;
span {
display: inline-block;
// background-color: #d8d8d8;
color: #4096ff;
padding: 0 8px;
height: 20px;
line-height: 20px;
border-radius: 4px;
cursor: pointer;
text-decoration: underline;
}
}

View File

@@ -3,19 +3,19 @@ import { Table, Modal, Button, message } from 'antd';
import { invoke, http, path, fs } from '@tauri-apps/api';
import useData from '@/hooks/useData';
import useChatModel from '@/hooks/useChatModel';
import useChatModel, { useCacheModel } from '@/hooks/useChatModel';
import useColumns from '@/hooks/useColumns';
import { TABLE_PAGINATION } from '@/hooks/useTable';
import { CHAT_MODEL_SYNC_JSON, chatRoot, writeJSON, readJSON, genCmd } from '@/utils';
import { CHAT_MODEL_JSON, chatRoot, readJSON, genCmd } from '@/utils';
import { syncColumns, getPath } from './config';
import SyncForm from './Form';
import './index.scss';
const setTag = (data: Record<string, any>[]) => data.map((i) => ({ ...i, tags: ['user-sync'], enable: true }))
export default function SyncCustom() {
const [isVisible, setVisible] = useState(false);
const { modelData, modelSet } = useChatModel('sync_url', CHAT_MODEL_SYNC_JSON);
const { modelData, modelSet } = useChatModel('sync_custom', CHAT_MODEL_JSON);
const { modelCacheCmd, modelCacheSet } = useCacheModel();
const { opData, opInit, opAdd, opRemove, opReplace, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(syncColumns());
const formRef = useRef<any>(null);
@@ -53,7 +53,7 @@ export default function SyncCustom() {
const handleSync = async (filename: string) => {
const record = opInfo?.opRecord;
const isJson = /json$/.test(record?.ext);
const file = await path.join(await chatRoot(), 'cache_sync', filename);
const file = await path.join(await chatRoot(), 'cache_model', filename);
const filePath = await getPath(record);
// https or http
@@ -65,13 +65,14 @@ export default function SyncCustom() {
if (res.ok) {
if (isJson) {
// parse json
writeJSON(file, setTag(Array.isArray(res?.data) ? res?.data : []), { isRoot: true, dir: 'cache_sync' });
await modelCacheSet(setTag(Array.isArray(res?.data) ? res?.data : []), file);
} else {
// parse csv
const list: Record<string, string>[] = await invoke('parse_prompt', { data: res?.data });
const fmtList = list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['user-sync'] }));
await writeJSON(file, fmtList, { isRoot: true, dir: 'cache_sync' });
await modelCacheSet(fmtList, file);
}
await modelCacheCmd();
message.success('ChatGPT Prompts data has been synchronized!');
} else {
message.error('ChatGPT Prompts data sync failed, please try again!');
@@ -82,14 +83,15 @@ export default function SyncCustom() {
if (isJson) {
// parse json
const data = await readJSON(filePath, { isRoot: true });
await writeJSON(file, setTag(Array.isArray(data) ? data : []), { isRoot: true, dir: 'cache_sync' });
await modelCacheSet(setTag(Array.isArray(data) ? data : []), file);
} else {
// parse csv
const data = await fs.readTextFile(filePath);
const list: Record<string, string>[] = await invoke('parse_prompt', { data });
const fmtList = list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['user-sync'] }));
await writeJSON(file, fmtList, { isRoot: true, dir: 'cache_sync' });
await modelCacheSet(fmtList, file);
}
await modelCacheCmd();
};
const handleOk = () => {
@@ -109,7 +111,7 @@ export default function SyncCustom() {
return (
<div>
<Button
className="add-btn"
className="chat-add-btn"
type="primary"
onClick={opInfo.opNew}
>

View File

@@ -1,13 +1,3 @@
.chat-prompts-tags {
.ant-tag {
margin: 2px;
}
}
.add-btn {
margin-bottom: 5px;
}
.chat-table-tip, .chat-table-btns {
display: flex;
justify-content: space-between;
@@ -20,22 +10,3 @@
margin-left: 10px;
}
}
.chat-model-path {
font-size: 12px;
font-weight: bold;
color: #888;
margin-bottom: 5px;
span {
display: inline-block;
// background-color: #d8d8d8;
color: #4096ff;
padding: 0 8px;
height: 20px;
line-height: 20px;
border-radius: 4px;
cursor: pointer;
text-decoration: underline;
}
}

View File

@@ -1,14 +1,13 @@
import { useEffect, useState } from 'react';
import { Table, Button, message, Popconfirm } from 'antd';
import { invoke } from '@tauri-apps/api';
import { fetch, ResponseType } from '@tauri-apps/api/http';
import { writeTextFile } from '@tauri-apps/api/fs';
import { invoke, http, path, shell } from '@tauri-apps/api';
import useColumns from '@/hooks/useColumns';
import useInit from '@/hooks/useInit';
import useData from '@/hooks/useData';
import useChatModel from '@/hooks/useChatModel';
import useColumns from '@/hooks/useColumns';
import useChatModel, { useCacheModel } from '@/hooks/useChatModel';
import useTable, { TABLE_PAGINATION } from '@/hooks/useTable';
import { fmtDate, chatPromptsPath, GITHUB_PROMPTS_CSV_URL, genCmd } from '@/utils';
import { fmtDate, chatRoot, GITHUB_PROMPTS_CSV_URL, genCmd } from '@/utils';
import { syncColumns } from './config';
import './index.scss';
@@ -16,36 +15,39 @@ const promptsURL = 'https://github.com/f/awesome-chatgpt-prompts/blob/main/promp
export default function SyncPrompts() {
const { rowSelection, selectedRowIDs } = useTable();
const [lastUpdated, setLastUpdated] = useState();
const { modelJson, modelSet } = useChatModel('sys_sync_prompts');
const [jsonPath, setJsonPath] = useState('');
const { modelJson, modelSet } = useChatModel('sync_prompts');
const { modelCacheJson, modelCacheSet } = useCacheModel(jsonPath);
const { opData, opInit, opReplace, opReplaceItems, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(syncColumns());
const lastUpdated = modelJson?.sync_prompts?.last_updated;
const selectedItems = rowSelection.selectedRowKeys || [];
useInit(async () => {
setJsonPath(await path.join(await chatRoot(), 'cache_model', 'chatgpt_prompts.json'));
});
useEffect(() => {
if (!modelJson?.sys_sync_prompts) return;
opInit(modelJson?.sys_sync_prompts);
if (lastUpdated) return;
(async () => {
const fileData: Record<string, any> = await invoke('metadata', { path: await chatPromptsPath() });
setLastUpdated(fileData.accessedAtMs);
})();
}, [modelJson?.sys_sync_prompts])
if (modelCacheJson.length <= 0) return;
opInit(modelCacheJson);
}, [modelCacheJson.length]);
const handleSync = async () => {
const res = await fetch(GITHUB_PROMPTS_CSV_URL, {
const res = await http.fetch(GITHUB_PROMPTS_CSV_URL, {
method: 'GET',
responseType: ResponseType.Text,
responseType: http.ResponseType.Text,
});
const data = (res.data || '') as string;
if (res.ok) {
// const content = data.replace(/"(\s+)?,(\s+)?"/g, '","');
await writeTextFile(await chatPromptsPath(), data);
const list: Record<string, string>[] = await invoke('parse_prompt', { data });
opInit(list);
modelSet(list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['chatgpt-prompts'] })));
setLastUpdated(fmtDate(Date.now()) as any);
const fmtList = list.map(i => ({ ...i, cmd: i.cmd ? i.cmd : genCmd(i.act), enable: true, tags: ['chatgpt-prompts'] }));
await modelCacheSet(fmtList);
opInit(fmtList);
modelSet({
id: 'chatgpt_prompts',
last_updated: Date.now(),
});
message.success('ChatGPT Prompts data has been synchronized!');
} else {
message.error('ChatGPT Prompts data sync failed, please try again!');
@@ -55,13 +57,13 @@ export default function SyncPrompts() {
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelSet(data);
modelCacheSet(data);
}
}, [opInfo.opTime]);
const handleEnable = (isEnable: boolean) => {
const data = opReplaceItems(selectedRowIDs, { enable: isEnable })
modelSet(data);
modelCacheSet(data);
};
return (
@@ -87,7 +89,10 @@ export default function SyncPrompts() {
</Popconfirm>
</div>
<div className="chat-table-tip">
<span className="chat-model-path">URL: <a href={promptsURL} target="_blank" title={promptsURL}>f/awesome-chatgpt-prompts/prompts.csv</a></span>
<div className="chat-sync-path">
<div>PATH: <a onClick={() => shell.open(promptsURL)} target="_blank" title={promptsURL}>f/awesome-chatgpt-prompts/prompts.csv</a></div>
<div>CACHE: <a onClick={() => shell.open(jsonPath)} target="_blank" title={jsonPath}>{jsonPath}</a></div>
</div>
{lastUpdated && <span style={{ marginLeft: 10, color: '#888', fontSize: 12 }}>Last updated on {fmtDate(lastUpdated)}</span>}
</div>
<Table

View File

@@ -1,42 +0,0 @@
// .chat-prompts-tags {
// .ant-tag {
// margin: 2px;
// }
// }
// .add-btn {
// margin-bottom: 5px;
// }
// .chat-table-tip, .chat-table-btns {
// display: flex;
// justify-content: space-between;
// }
// .chat-table-btns {
// margin-bottom: 5px;
// .num {
// margin-left: 10px;
// }
// }
.chat-sync-path {
font-size: 12px;
font-weight: 500;
color: #888;
margin-bottom: 5px;
line-height: 16px;
span {
display: inline-block;
// background-color: #d8d8d8;
color: #4096ff;
padding: 0 8px;
height: 20px;
line-height: 20px;
border-radius: 4px;
cursor: pointer;
text-decoration: underline;
}
}

View File

@@ -12,7 +12,6 @@ import { fmtDate, chatRoot } from '@/utils';
import { getPath } from '@/view/model/SyncCustom/config';
import { syncColumns } from './config';
import useInit from '@/hooks/useInit';
import './index.scss';
export default function SyncRecord() {
const location = useLocation();
@@ -21,7 +20,7 @@ export default function SyncRecord() {
const state = location?.state;
const { rowSelection, selectedRowIDs } = useTable();
const { modelJson, modelSet } = useCacheModel(jsonPath);
const { modelCacheJson, modelCacheSet } = useCacheModel(jsonPath);
const { opData, opInit, opReplace, opReplaceItems, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(syncColumns());
@@ -29,24 +28,24 @@ export default function SyncRecord() {
useInit(async () => {
setFilePath(await getPath(state));
setJsonPath(await path.join(await chatRoot(), 'cache_sync', `${state?.id}.json`));
setJsonPath(await path.join(await chatRoot(), 'cache_model', `${state?.id}.json`));
})
useEffect(() => {
if (modelJson.length <= 0) return;
opInit(modelJson);
}, [modelJson.length]);
if (modelCacheJson.length <= 0) return;
opInit(modelCacheJson);
}, [modelCacheJson.length]);
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelSet(data);
modelCacheSet(data);
}
}, [opInfo.opTime]);
const handleEnable = (isEnable: boolean) => {
const data = opReplaceItems(selectedRowIDs, { enable: isEnable })
modelSet(data);
modelCacheSet(data);
};
return (

View File

@@ -1,39 +0,0 @@
.chat-prompts-val {
display: inline-block;
width: 100%;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.chat-prompts-tags {
.ant-tag {
margin: 2px;
}
}
.add-btn {
margin-bottom: 5px;
}
.chat-model-path {
font-size: 12px;
font-weight: bold;
color: #888;
margin-bottom: 5px;
span {
display: inline-block;
// background-color: #d8d8d8;
color: #4096ff;
padding: 0 8px;
height: 20px;
line-height: 20px;
border-radius: 4px;
cursor: pointer;
text-decoration: underline;
}
}

View File

@@ -1,29 +1,36 @@
import { useState, useRef, useEffect } from 'react';
import { Table, Button, Modal, message } from 'antd';
import { invoke } from '@tauri-apps/api';
import { shell, path } from '@tauri-apps/api';
import useInit from '@/hooks/useInit';
import useData from '@/hooks/useData';
import useChatModel from '@/hooks/useChatModel';
import useChatModel, { useCacheModel } from '@/hooks/useChatModel';
import useColumns from '@/hooks/useColumns';
import { TABLE_PAGINATION } from '@/hooks/useTable';
import { chatModelPath } from '@/utils';
import useTable, { TABLE_PAGINATION } from '@/hooks/useTable';
import { chatRoot, fmtDate } from '@/utils';
import { modelColumns } from './config';
import UserCustomForm from './Form';
import './index.scss';
export default function LanguageModel() {
const { rowSelection, selectedRowIDs } = useTable();
const [isVisible, setVisible] = useState(false);
const [modelPath, setChatModelPath] = useState('');
const { modelData, modelSet } = useChatModel('user_custom');
const { opData, opInit, opAdd, opRemove, opReplace, opSafeKey } = useData([]);
const [jsonPath, setJsonPath] = useState('');
const { modelJson, modelSet } = useChatModel('user_custom');
const { modelCacheJson, modelCacheSet } = useCacheModel(jsonPath);
const { opData, opInit, opReplaceItems, opAdd, opRemove, opReplace, opSafeKey } = useData([]);
const { columns, ...opInfo } = useColumns(modelColumns());
const lastUpdated = modelJson?.user_custom?.last_updated;
const selectedItems = rowSelection.selectedRowKeys || [];
const formRef = useRef<any>(null);
useInit(async () => {
setJsonPath(await path.join(await chatRoot(), 'cache_model', 'user_custom.json'));
});
useEffect(() => {
if (modelData.length <= 0) return;
opInit(modelData);
}, [modelData]);
if (modelCacheJson.length <= 0) return;
opInit(modelCacheJson);
}, [modelCacheJson.length]);
useEffect(() => {
if (!opInfo.opType) return;
@@ -32,7 +39,7 @@ export default function LanguageModel() {
}
if (['delete'].includes(opInfo.opType)) {
const data = opRemove(opInfo?.opRecord?.[opSafeKey]);
modelSet(data);
modelCacheSet(data);
opInfo.resetRecord();
}
}, [opInfo.opType, formRef]);
@@ -40,14 +47,22 @@ export default function LanguageModel() {
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelSet(data);
modelCacheSet(data);
}
}, [opInfo.opTime])
useInit(async () => {
const path = await chatModelPath();
setChatModelPath(path);
})
useEffect(() => {
if (opInfo.opType === 'enable') {
const data = opReplace(opInfo?.opRecord?.[opSafeKey], opInfo?.opRecord);
modelCacheSet(data);
}
}, [opInfo.opTime]);
const handleEnable = (isEnable: boolean) => {
const data = opReplaceItems(selectedRowIDs, { enable: isEnable })
modelCacheSet(data);
};
const hide = () => {
setVisible(false);
@@ -56,8 +71,8 @@ export default function LanguageModel() {
const handleOk = () => {
formRef.current?.form?.validateFields()
.then((vals: Record<string, any>) => {
if (modelData.map((i: any) => i.cmd).includes(vals.cmd) && opInfo?.opRecord?.cmd !== vals.cmd) {
.then(async (vals: Record<string, any>) => {
if (modelCacheJson.map((i: any) => i.cmd).includes(vals.cmd) && opInfo?.opRecord?.cmd !== vals.cmd) {
message.warning(`"cmd: /${vals.cmd}" already exists, please change the "${vals.cmd}" name and resubmit.`);
return;
}
@@ -67,28 +82,46 @@ export default function LanguageModel() {
case 'edit': data = opReplace(opInfo?.opRecord?.[opSafeKey], vals); break;
default: break;
}
modelSet(data);
opInfo.setExtra(Date.now());
await modelCacheSet(data);
opInit(data);
modelSet({
id: 'user_custom',
last_updated: Date.now(),
});
hide();
})
};
const handleOpenFile = () => {
invoke('open_file', { path: modelPath });
};
const modalTitle = `${({ new: 'Create', edit: 'Edit' })[opInfo.opType]} Model`;
return (
<div>
<Button className="add-btn" type="primary" onClick={opInfo.opNew}>Add Model</Button>
<div className="chat-model-path">PATH: <span onClick={handleOpenFile}>{modelPath}</span></div>
<div className="chat-table-btns">
<Button className="chat-add-btn" type="primary" onClick={opInfo.opNew}>Add Model</Button>
<div>
{selectedItems.length > 0 && (
<>
<Button type="primary" onClick={() => handleEnable(true)}>Enable</Button>
<Button onClick={() => handleEnable(false)}>Disable</Button>
<span className="num">Selected {selectedItems.length} items</span>
</>
)}
</div>
</div>
{/* <div className="chat-model-path">PATH: <span onClick={handleOpenFile}>{modelPath}</span></div> */}
<div className="chat-table-tip">
<div className="chat-sync-path">
<div>CACHE: <a onClick={() => shell.open(jsonPath)} title={jsonPath}>{jsonPath}</a></div>
</div>
{lastUpdated && <span style={{ marginLeft: 10, color: '#888', fontSize: 12 }}>Last updated on {fmtDate(lastUpdated)}</span>}
</div>
<Table
key={opInfo.opExtra}
key={lastUpdated}
rowKey="cmd"
columns={columns}
scroll={{ x: 'auto' }}
dataSource={opData}
rowSelection={rowSelection}
pagination={TABLE_PAGINATION}
/>
<Modal