fix(ui): refactor list types for player, add docs to legacy msg manager methods; refactor unpack methods

This commit is contained in:
nick-delirium 2024-01-15 16:28:08 +01:00
parent 531cf7499e
commit 9e3d9ea437
12 changed files with 306 additions and 181 deletions

View file

@ -76,7 +76,7 @@ function MobileOverviewPanelCont({ issuesList }: { issuesList: Record<string, a
)
}
function WebOverviewPanelCont({ issuesList }: { issuesList: Record<string, any>[] }) {
function WebOverviewPanelCont() {
const { store } = React.useContext(PlayerContext);
const [selectedFeatures, setSelectedFeatures] = React.useState([
'PERFORMANCE',
@ -92,7 +92,6 @@ function WebOverviewPanelCont({ issuesList }: { issuesList: Record<string, any>[
} = store.get();
const stackEventList = tabStates[currentTab]?.stackList || []
// const eventsList = tabStates[currentTab]?.eventList || []
const frustrationsList = tabStates[currentTab]?.frustrationsList || []
const exceptionsList = tabStates[currentTab]?.exceptionsList || []
const resourceListUnmap = tabStates[currentTab]?.resourceList || []

View file

@ -234,8 +234,8 @@ const reducer = (state = initialState, action: IAction) => {
errors,
issues,
resources,
stackEvents,
userEvents,
stackEvents,
userTesting
);

View file

@ -0,0 +1,39 @@
import * as fzstd from 'fzstd';
import { gunzipSync } from 'fflate'
const unpack = (b: Uint8Array): Uint8Array => {
// zstd magical numbers 40 181 47 253
const isZstd = b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd
const isGzip = b[0] === 0x1F && b[1] === 0x8B && b[2] === 0x08;
if (isGzip) {
const now = performance.now()
const data = gunzipSync(b)
console.debug(
"Gunzip time",
Math.floor(performance.now() - now) + 'ms',
'size',
Math.floor(b.byteLength / 1024),
'->',
Math.floor(data.byteLength / 1024),
'kb'
)
return data
}
if (isZstd) {
const now = performance.now()
const data = fzstd.decompress(b)
console.debug(
"Zstd unpack time",
Math.floor(performance.now() - now) + 'ms',
'size',
Math.floor(b.byteLength / 1024),
'->',
Math.floor(data.byteLength / 1024),
'kb'
)
return data
}
return b
}
export default unpack

View file

@ -128,10 +128,15 @@ export default class IOSMessageManager implements IMessageManager {
});
}
_sortMessagesHack() {
/** empty here. Kept for consistency with normal manager */
sortDomRemoveMessages() {
return;
}
public getListsFullState = () => {
return this.lists.getFullListsState();
}
private waitingForFiles: boolean = false;
public onFileReadSuccess = () => {
let newState: Partial<State> = {
@ -148,7 +153,7 @@ export default class IOSMessageManager implements IMessageManager {
this.state.update(newState);
};
public onFileReadFailed = (e: any) => {
public onFileReadFailed = (...e: any[]) => {
logger.error(e);
this.state.update({error: true});
this.uiErrorHandler?.error('Error requesting a session file');

View file

@ -27,11 +27,12 @@ export interface IMessageManager {
onFileReadFinally(): void;
startLoading(): void;
resetMessageManagers(): void;
getListsFullState(): Record<string, unknown>
move(t: number): any;
distributeMessage(msg: Message): void;
setMessagesLoading(messagesLoading: boolean): void;
clean(): void;
_sortMessagesHack: (msgs: Message[]) => void;
sortDomRemoveMessages: (msgs: Message[]) => void;
}
export interface SetState {

View file

@ -1,87 +1,142 @@
import { InjectedEvent } from 'Types/session/event';
import Issue from 'Types/session/issue';
import ListWalker from '../common/ListWalker';
import ListWalkerWithMarks from '../common/ListWalkerWithMarks';
import type { Timed } from '../common/types';
import type { IResourceRequest, IResourceTiming, Timed } from 'Player';
import {
Redux as reduxMsg,
Vuex as vuexMsg,
MobX as mobxMsg,
Zustand as zustandMsg,
NgRx as ngrxMsg,
GraphQl as graphqlMsg,
ConsoleLog as logMsg,
WsChannel as websocketMsg,
Profiler as profilerMsg,
} from 'Player/web/messages';
type stackMsg = {
name: string;
Payload: string;
tp: number;
} & Timed;
type exceptionsMsg = {
tp: number;
name: string;
message: string;
payload: string;
metadata: string;
} & Timed;
const SIMPLE_LIST_NAMES = [ "event", "redux", "mobx", "vuex", "zustand", "ngrx", "graphql", "exceptions", "profiles", "frustrations"] as const
const MARKED_LIST_NAMES = [ "log", "resource", "fetch", "stack", "websocket" ] as const
//const entityNamesSimple = [ "event", "profile" ];
type MsgTypeMap = {
reduxList: reduxMsg;
mobxList: mobxMsg;
vuexList: vuexMsg;
zustandList: zustandMsg;
ngrxList: ngrxMsg;
graphqlList: graphqlMsg;
logList: logMsg;
fetchList: IResourceRequest;
resourceList: IResourceTiming;
stackList: stackMsg;
websocketList: websocketMsg;
profilerList: profilerMsg;
exceptionsList: exceptionsMsg;
frustrationsList: Issue | InjectedEvent;
};
type ListMessageType<K> = K extends keyof MsgTypeMap ? Array<MsgTypeMap[K]> : Array<Timed>;
const LIST_NAMES = [...SIMPLE_LIST_NAMES, ...MARKED_LIST_NAMES ] as const
const SIMPLE_LIST_NAMES = [
'event',
'redux',
'mobx',
'vuex',
'zustand',
'ngrx',
'graphql',
'exceptions',
'profiles',
'frustrations',
] as const;
const MARKED_LIST_NAMES = ['log', 'resource', 'fetch', 'stack', 'websocket'] as const;
type KeysList = `${typeof LIST_NAMES[number]}List`
type KeysListNow = `${typeof LIST_NAMES[number]}ListNow`
type KeysMarkedCountNow = `${typeof MARKED_LIST_NAMES[number]}MarkedCountNow`
const LIST_NAMES = [...SIMPLE_LIST_NAMES, ...MARKED_LIST_NAMES] as const;
type KeysList = `${(typeof LIST_NAMES)[number]}List`;
type KeysMarkedCountNow = `${(typeof MARKED_LIST_NAMES)[number]}MarkedCountNow`;
type StateList = {
[key in KeysList]: Timed[]
}
[K in KeysList]: ListMessageType<K>;
};
type StateListNow = {
[key in KeysListNow]: Timed[]
}
[K in KeysList as `${K}Now`]: ListMessageType<K>;
};
type StateMarkedCountNow = {
[key in KeysMarkedCountNow]: number
}
type StateNow = StateListNow & StateMarkedCountNow
export type State = StateList & StateNow
[key in KeysMarkedCountNow]: number;
};
type StateNow = StateListNow & StateMarkedCountNow;
export type State = StateList & StateNow;
// maybe use list object itself inside the store
export const INITIAL_STATE = LIST_NAMES.reduce((state, name) => {
state[`${name}List`] = []
state[`${name}ListNow`] = []
return state
}, MARKED_LIST_NAMES.reduce((state, name) => {
state[`${name}MarkedCountNow`] = 0
return state
export const INITIAL_STATE = LIST_NAMES.reduce(
(state, name) => {
state[`${name}List`] = [];
state[`${name}ListNow`] = [];
return state;
},
MARKED_LIST_NAMES.reduce((state, name) => {
state[`${name}MarkedCountNow`] = 0;
return state;
}, {} as Partial<StateMarkedCountNow>) as Partial<State>
) as State
) as State;
type SimpleListsObject = {
[key in typeof SIMPLE_LIST_NAMES[number]]: ListWalker<Timed>
}
[key in (typeof SIMPLE_LIST_NAMES)[number]]: ListWalker<Timed>;
};
type MarkedListsObject = {
[key in typeof MARKED_LIST_NAMES[number]]: ListWalkerWithMarks<Timed>
}
type ListsObject = SimpleListsObject & MarkedListsObject
[key in (typeof MARKED_LIST_NAMES)[number]]: ListWalkerWithMarks<Timed>;
};
type ListsObject = SimpleListsObject & MarkedListsObject;
export type InitialLists = {
[key in typeof LIST_NAMES[number]]: any[] // .isRed()?
}
export type InitialLists = {
[key in (typeof LIST_NAMES)[number]]: any[]; // .isRed()?
};
export default class Lists {
lists: ListsObject
lists: ListsObject;
constructor(initialLists: Partial<InitialLists> = {}) {
const lists: Partial<ListsObject> = {}
const lists: Partial<ListsObject> = {};
for (const name of SIMPLE_LIST_NAMES) {
lists[name] = new ListWalker(initialLists[name])
lists[name] = new ListWalker(initialLists[name]);
}
for (const name of MARKED_LIST_NAMES) {
// TODO: provide types
lists[name] = new ListWalkerWithMarks((el) => el.isRed, initialLists[name])
lists[name] = new ListWalkerWithMarks((el) => el.isRed, initialLists[name]);
}
this.lists = lists as ListsObject
this.lists = lists as ListsObject;
}
getFullListsState(): StateList {
return LIST_NAMES.reduce((state, name) => {
state[`${name}List`] = this.lists[name].list
return state
}, {} as Partial<StateList>) as StateList
state[`${name}List`] = this.lists[name].list;
return state;
}, {} as Partial<StateList>) as StateList;
}
moveGetState(t: number): StateNow {
return LIST_NAMES.reduce((state, name) => {
const lastMsg = this.lists[name].moveGetLast(t) // index: name === 'exceptions' ? undefined : index);
if (lastMsg != null) {
state[`${name}ListNow`] = this.lists[name].listNow
}
return state
}, MARKED_LIST_NAMES.reduce((state, name) => {
state[`${name}MarkedCountNow`] = this.lists[name].markedCountNow // Red --> Marked
return state
return LIST_NAMES.reduce(
(state, name) => {
const lastMsg = this.lists[name].moveGetLast(t); // index: name === 'exceptions' ? undefined : index);
if (lastMsg != null) {
state[`${name}ListNow`] = this.lists[name].listNow;
}
return state;
},
MARKED_LIST_NAMES.reduce((state, name) => {
state[`${name}MarkedCountNow`] = this.lists[name].markedCountNow; // Red --> Marked
return state;
}, {} as Partial<StateMarkedCountNow>) as Partial<State>
) as State
) as State;
}
}
}

View file

@ -1,20 +1,18 @@
import type { Store, SessionFilesInfo } from 'Player';
import {IMessageManager} from "Player/player/Animator";
import { decryptSessionBytes } from './network/crypto';
import MFileReader from './messages/MFileReader';
import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles';
import type {
Message,
} from './messages';
import type { Message } from './messages';
import logger from 'App/logger';
import * as fzstd from 'fzstd';
import unpack from 'Player/common/unpack';
import MessageManager from 'Player/web/MessageManager';
import IOSMessageManager from 'Player/mobile/IOSMessageManager';
interface State {
firstFileLoading: boolean,
domLoading: boolean,
devtoolsLoading: boolean,
error: boolean,
firstFileLoading: boolean;
domLoading: boolean;
devtoolsLoading: boolean;
error: boolean;
}
export default class MessageLoader {
@ -23,131 +21,148 @@ export default class MessageLoader {
domLoading: false,
devtoolsLoading: false,
error: false,
}
};
constructor(
private readonly session: SessionFilesInfo,
private store: Store<State>,
private messageManager: IMessageManager,
private messageManager: MessageManager | IOSMessageManager,
private isClickmap: boolean,
private uiErrorHandler?: { error: (msg: string) => void }
) {}
createNewParser(shouldDecrypt = true, file?: string, toggleStatus?: (isLoading: boolean) => void) {
const decrypt = shouldDecrypt && this.session.fileKey
? (b: Uint8Array) => decryptSessionBytes(b, this.session.fileKey!)
: (b: Uint8Array) => Promise.resolve(b)
createNewParser(
shouldDecrypt = true,
file?: string
) {
const decrypt =
shouldDecrypt && this.session.fileKey
? (b: Uint8Array) => decryptSessionBytes(b, this.session.fileKey!)
: (b: Uint8Array) => Promise.resolve(b);
// Each time called - new fileReader created
const unarchived = (b: Uint8Array) => {
// zstd magical numbers 40 181 47 253
const isZstd = b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd
if (isZstd) {
return fzstd.decompress(b)
} else {
return b
}
}
const fileReader = new MFileReader(new Uint8Array(), this.session.startedAt)
const fileReader = new MFileReader(new Uint8Array(), this.session.startedAt);
return (b: Uint8Array) => {
decrypt(b).then(b => {
const data = unarchived(b)
toggleStatus?.(true);
fileReader.append(data)
fileReader.checkForIndexes()
const msgs: Array<Message & { _index?: number }> = []
for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) {
msgs.push(msg)
}
const sorted = msgs.sort((m1, m2) => {
return m1.time - m2.time
})
return decrypt(b)
.then((b) => {
const data = unpack(b);
fileReader.append(data);
fileReader.checkForIndexes();
const msgs: Array<Message & { tabId: string }> = [];
let finished = false;
while (!finished) {
const msg = fileReader.readNext();
if (msg) {
msgs.push(msg);
} else {
finished = true;
break;
}
}
sorted.forEach(msg => {
this.messageManager.distributeMessage(msg)
})
logger.info("Messages count: ", msgs.length, sorted, file)
const sortedMessages = msgs.sort((m1, m2) => {
return m1.time - m2.time;
});
this.messageManager._sortMessagesHack(sorted)
toggleStatus?.(false);
this.messageManager.setMessagesLoading(false)
}).catch(e => {
console.error(e)
this.uiErrorHandler?.error('Error parsing file: ' + e.message)
})
}
sortedMessages.forEach((msg) => {
this.messageManager.distributeMessage(msg);
});
logger.info('Messages count: ', msgs.length, sortedMessages, file);
this.messageManager.sortDomRemoveMessages(sortedMessages);
this.messageManager.setMessagesLoading(false);
})
.catch((e) => {
console.error(e);
this.uiErrorHandler?.error('Error parsing file: ' + e.message);
});
};
}
loadDomFiles(urls: string[], parser: (b: Uint8Array) => Promise<void>) {
if (urls.length > 0) {
this.store.update({ domLoading: true })
return loadFiles(urls, parser, true).then(() => this.store.update({ domLoading: false }))
this.store.update({ domLoading: true });
return loadFiles(urls, parser, true).then(() => this.store.update({ domLoading: false }));
} else {
return Promise.resolve()
return Promise.resolve();
}
}
loadDevtools(parser: (b: Uint8Array) => Promise<void>) {
if (!this.isClickmap) {
this.store.update({ devtoolsLoading: true })
return loadFiles(this.session.devtoolsURL, parser)
// TODO: also in case of dynamic update through assist
.then(() => {
// @ts-ignore ?
this.store.update({ ...this.messageManager.getListsFullState(), devtoolsLoading: false });
})
this.store.update({ devtoolsLoading: true });
return (
loadFiles(this.session.devtoolsURL, parser)
// TODO: also in case of dynamic update through assist
.then(() => {
// @ts-ignore ?
this.store.update({
...this.messageManager.getListsFullState(),
devtoolsLoading: false,
});
})
);
} else {
return Promise.resolve()
return Promise.resolve();
}
}
async loadFiles() {
this.messageManager.startLoading()
this.messageManager.startLoading();
const loadMethod = this.session.domURL && this.session.domURL.length > 0
? { url: this.session.domURL, parser: () => this.createNewParser(true, 'dom') }
: { url: this.session.mobsUrl, parser: () => this.createNewParser(false, 'dom') }
const loadMethod =
this.session.domURL && this.session.domURL.length > 0
? { mobUrls: this.session.domURL, parser: () => this.createNewParser(true, 'dom') }
: { mobUrls: this.session.mobsUrl, parser: () => this.createNewParser(false, 'dom') };
const parser = loadMethod.parser()
const devtoolsParser = this.createNewParser(true, 'devtools')
const parser = loadMethod.parser();
const devtoolsParser = this.createNewParser(true, 'devtools');
/**
* We load first dom mob file before the rest
* to speed up time to replay
* but as a tradeoff we have to have some copy-paste
* we load first dom mob file before the rest
* (because parser can read them in parallel)
* as a tradeoff we have some copy-paste code
* for the devtools file
* */
try {
await loadFiles([loadMethod.url[0]], parser)
const restDomFilesPromise = this.loadDomFiles([...loadMethod.url.slice(1)], parser)
const restDevtoolsFilesPromise = this.loadDevtools(devtoolsParser)
await loadFiles([loadMethod.mobUrls[0]], parser);
const restDomFilesPromise = this.loadDomFiles([...loadMethod.mobUrls.slice(1)], parser);
const restDevtoolsFilesPromise = this.loadDevtools(devtoolsParser);
await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise])
this.messageManager.onFileReadSuccess()
} catch (e) {
await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise]);
this.messageManager.onFileReadSuccess();
} catch (sessionLoadError) {
try {
this.store.update({ domLoading: true, devtoolsLoading: true })
const efsDomFilePromise = requestEFSDom(this.session.sessionId)
const efsDevtoolsFilePromise = requestEFSDevtools(this.session.sessionId)
this.store.update({ domLoading: true, devtoolsLoading: true });
const efsDomFilePromise = requestEFSDom(this.session.sessionId);
const efsDevtoolsFilePromise = requestEFSDevtools(this.session.sessionId);
const [domData, devtoolsData] = await Promise.allSettled([efsDomFilePromise, efsDevtoolsFilePromise])
const domParser = this.createNewParser(false, 'domEFS')
const devtoolsParser = this.createNewParser(false, 'devtoolsEFS')
const parseDomPromise: Promise<any> = domData.status === 'fulfilled'
? domParser(domData.value) : Promise.reject('No dom file in EFS')
const parseDevtoolsPromise: Promise<any> = devtoolsData.status === 'fulfilled'
? devtoolsParser(devtoolsData.value) : Promise.reject('No devtools file in EFS')
const [domData, devtoolsData] = await Promise.allSettled([
efsDomFilePromise,
efsDevtoolsFilePromise,
]);
const domParser = this.createNewParser(false, 'domEFS');
const devtoolsParser = this.createNewParser(false, 'devtoolsEFS');
const parseDomPromise: Promise<any> =
domData.status === 'fulfilled'
? domParser(domData.value)
: Promise.reject('No dom file in EFS');
const parseDevtoolsPromise: Promise<any> =
devtoolsData.status === 'fulfilled'
? devtoolsParser(devtoolsData.value)
: Promise.reject('No devtools file in EFS');
await Promise.all([parseDomPromise, parseDevtoolsPromise])
this.messageManager.onFileReadSuccess()
} catch (e2) {
this.messageManager.onFileReadFailed(e)
await Promise.all([parseDomPromise, parseDevtoolsPromise]);
this.messageManager.onFileReadSuccess();
} catch (unprocessedLoadError) {
this.messageManager.onFileReadFailed(sessionLoadError, unprocessedLoadError);
}
} finally {
this.messageManager.onFileReadFinally()
this.store.update({ domLoading: false, devtoolsLoading: false })
this.messageManager.onFileReadFinally();
this.store.update({ domLoading: false, devtoolsLoading: false });
}
}
clean() {
this.store.update(MessageLoader.INITIAL_STATE);
}
}
}

View file

@ -146,8 +146,12 @@ export default class MessageManager {
});
}
public _sortMessagesHack = (msgs: Message[]) => {
Object.values(this.tabs).forEach((tab) => tab._sortMessagesHack(msgs));
/**
* Legacy code. Iterates over all tab managers and sorts messages for their pagesManager.
* Ensures that RemoveNode messages with parent being <HEAD> are sorted before other RemoveNode messages.
* */
public sortDomRemoveMessages = (msgs: Message[]) => {
Object.values(this.tabs).forEach((tab) => tab.sortDomRemoveMessages(msgs));
};
private waitingForFiles: boolean = false;
@ -159,7 +163,7 @@ export default class MessageManager {
Object.values(this.tabs).forEach((tab) => tab.onFileReadSuccess?.());
};
public onFileReadFailed = (e: any) => {
public onFileReadFailed = (...e: any[]) => {
logger.error(e);
this.state.update({ error: true });
this.uiErrorHandler?.error('Error requesting a session file');

View file

@ -351,22 +351,25 @@ export default class TabSessionManager {
return this.decoder.decode(msg);
}
public _sortMessagesHack = (msgs: Message[]) => {
/**
* Legacy code. Ensures that RemoveNode messages with parent being <HEAD> are sorted before other RemoveNode messages.
* */
public sortDomRemoveMessages = (msgs: Message[]) => {
// @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first))
const headChildrenIds = msgs.filter((m) => m.parentID === 1).map((m) => m.id);
const headChildrenMsgIds = msgs.filter((m) => m.parentID === 1).map((m) => m.id);
this.pagesManager.sortPages((m1, m2) => {
if (m1.time === m2.time) {
if (m1.tp === MType.RemoveNode && m2.tp !== MType.RemoveNode) {
if (headChildrenIds.includes(m1.id)) {
if (headChildrenMsgIds.includes(m1.id)) {
return -1;
}
} else if (m2.tp === MType.RemoveNode && m1.tp !== MType.RemoveNode) {
if (headChildrenIds.includes(m2.id)) {
if (headChildrenMsgIds.includes(m2.id)) {
return 1;
}
} else if (m2.tp === MType.RemoveNode && m1.tp === MType.RemoveNode) {
const m1FromHead = headChildrenIds.includes(m1.id);
const m2FromHead = headChildrenIds.includes(m2.id);
const m1FromHead = headChildrenMsgIds.includes(m1.id);
const m2FromHead = headChildrenMsgIds.includes(m2.id);
if (m1FromHead && !m2FromHead) {
return -1;
} else if (m2FromHead && !m1FromHead) {

View file

@ -65,7 +65,7 @@ export default class MFileReader extends RawMessageReader {
}
currentTab = 'back-compatability'
readNext(): Message & { _index?: number } | null {
readNext(): Message & { tabId: string; _index?: number } | null {
if (this.error || !this.hasNextByte()) {
return null
}
@ -95,6 +95,7 @@ export default class MFileReader extends RawMessageReader {
this.currentTime = rMsg.timestamp - this.startTime
return {
tp: 9999,
tabId: '',
time: this.currentTime,
}
}

View file

@ -1,5 +1,3 @@
import { gunzipSync } from 'fflate'
const u8aFromHex = (hexString:string) =>
Uint8Array.from(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)))
@ -20,24 +18,6 @@ export function decryptSessionBytes(cypher: Uint8Array, keyString: string): Prom
return crypto.subtle.importKey("raw", byteKey, { name: "AES-CBC" }, false, ["decrypt"])
.then(key => crypto.subtle.decrypt({ name: "AES-CBC", iv: iv}, key, cypher))
.then((bArray: ArrayBuffer) => new Uint8Array(bArray))
.then(async (u8Array: Uint8Array) => {
const isGzip = u8Array[0] === 0x1F && u8Array[1] === 0x8B && u8Array[2] === 0x08;
if (isGzip) {
const now = performance.now()
const data = gunzipSync(u8Array)
console.debug(
"Decompression time",
Math.floor(performance.now() - now) + 'ms',
'size',
Math.floor(u8Array.byteLength/1024),
'->',
Math.floor(data.byteLength/1024),
'kb'
)
return data
} else return u8Array
})
//?? TS doesn not catch the `decrypt`` returning type
}

View file

@ -83,6 +83,29 @@ interface IResource {
responseBodySize?: number,
}
export interface IResourceTiming extends IResource {
name: string,
isRed: boolean,
isYellow: boolean,
type: ResourceType,
method: "GET" | "POST" | "PUT" | "DELETE" | "..",
success: boolean,
status: "2xx-3xx" | "4xx-5xx",
time: number,
}
export interface IResourceRequest extends IResource {
name: string,
isRed: boolean,
isYellow: boolean,
type: ResourceType.XHR | ResourceType.FETCH | ResourceType.IOS,
method: "GET" | "POST" | "PUT" | "DELETE" | "..",
success: boolean,
status: number,
time: number,
decodedBodySize?: number,
}
export const Resource = (resource: IResource) => ({
...resource,