支持实例从csv&json导入

This commit is contained in:
chaos-zhu 2024-07-29 11:48:00 +08:00
parent a95caf6d2f
commit ac7f4eb509
9 changed files with 440 additions and 74 deletions

View File

@ -86,38 +86,38 @@ async function removeHost({
if (hostIdx === -1) return res.fail({ msg: `${ host }不存在` }) if (hostIdx === -1) return res.fail({ msg: `${ host }不存在` })
hostList.splice(hostIdx, 1) hostList.splice(hostIdx, 1)
writeHostList(hostList) writeHostList(hostList)
// 查询是否存在ssh记录
// let sshRecord = await readSSHRecord()
// let sshIdx = sshRecord.findIndex(item => item.host === host)
// let flag = sshIdx !== -1
// if (flag) sshRecord.splice(sshIdx, 1)
// writeSSHRecord(sshRecord)
// res.success({ data: `${ host }已移除, ${ flag ? '并移除ssh记录' : '' }` })
res.success({ data: `${ host }已移除` }) res.success({ data: `${ host }已移除` })
} }
// 原手动排序接口-废弃 async function importHost({
// async function updateHostSort({ res, request }) { res, request
// let { body: { list } } = request }) {
// if (!list) return res.fail({ msg: '参数错误' }) let { body: { importHost } } = request
// let hostList = await readHostList() if (!Array.isArray(importHost)) return res.fail({ msg: '参数错误' })
// if (hostList.length !== list.length) return res.fail({ msg: '失败: host数量不匹配' }) let hostList = await readHostList()
// let sortResult = [] // 过滤已存在的host
// for (let i = 0; i < list.length; i++) { let hostListSet = new Set(hostList.map(item => item.host))
// const curHost = list[i] let newHostList = importHost.filter(item => !hostListSet.has(item.host))
// let temp = hostList.find(({ host }) => curHost.host === host) if (newHostList.length === 0) return res.fail({ msg: '导入的实例已存在' })
// if (!temp) return res.fail({ msg: `查找失败: ${ curHost.name }` })
// sortResult.push(temp) let extraFiels = {
// } expired: null, expiredNotify: false, group: 'default', consoleUrl: '', remark: '',
// writeHostList(sortResult) authType: 'privateKey', password: '', privateKey: '', credential: '', command: ''
// res.success({ msg: 'success' }) }
// } newHostList = newHostList.map((item, index) => {
item.port = Number(item.port) || 0
item.index = hostList.length + index + 1
return Object.assign(item, { ...extraFiels })
})
hostList.push(...newHostList)
writeHostList(hostList)
res.success({ data: { len: newHostList.length } })
}
module.exports = { module.exports = {
getHostList, getHostList,
addHost, addHost,
updateHost, updateHost,
removeHost removeHost,
// updateHostSort importHost
} }

View File

@ -1,5 +1,5 @@
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh') const { getSSHList, addSSH, updateSSH, removeSSH, getCommand } = require('../controller/ssh')
const { getHostList, addHost, updateHost, removeHost } = require('../controller/host') const { getHostList, addHost, updateHost, removeHost, importHost } = require('../controller/host')
const { login, getpublicKey, updatePwd, getLoginRecord } = require('../controller/user') const { login, getpublicKey, updatePwd, getLoginRecord } = require('../controller/user')
const { getSupportEmailList, getUserEmailList, updateUserEmailList, removeUserEmail, pushEmail, getNotifyList, updateNotifyList } = require('../controller/notify') const { getSupportEmailList, getUserEmailList, updateUserEmailList, removeUserEmail, pushEmail, getNotifyList, updateNotifyList } = require('../controller/notify')
const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group') const { getGroupList, addGroupList, updateGroupList, removeGroup } = require('../controller/group')
@ -51,12 +51,12 @@ const host = [
method: 'post', method: 'post',
path: '/host-remove', path: '/host-remove',
controller: removeHost controller: removeHost
},
{
method: 'post',
path: '/import-host',
controller: importHost
} }
// {
// method: 'put',
// path: '/host-sort',
// controller: updateHostSort
// }
] ]
const user = [ const user = [
{ {

View File

@ -4,7 +4,7 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes"> <meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes">
<title>EasyNode</title> <title>EasyNode</title>
<script src="//at.alicdn.com/t/c/font_3309550_ezojxo1wj3.js"></script> <script src="//at.alicdn.com/t/c/font_3309550_x7zmcgwaxf.js"></script>
</head> </head>
<body> <body>

View File

@ -32,6 +32,7 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"csv-parse": "^5.5.6",
"element-plus": "^2.7.6", "element-plus": "^2.7.6",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",

View File

@ -37,6 +37,9 @@ export default {
removeHost(data) { removeHost(data) {
return axios({ url: '/host-remove', method: 'post', data }) return axios({ url: '/host-remove', method: 'post', data })
}, },
importHost(data) {
return axios({ url: '/import-host', method: 'post', data })
},
getPubPem() { getPubPem() {
return axios({ url: '/get-pub-pem', method: 'get' }) return axios({ url: '/get-pub-pem', method: 'get' })
}, },

View File

@ -1,28 +1,78 @@
<template> <template>
<el-dialog v-model="visible" width="600px" top="45px" modal-class="host_form_dialog" append-to-body :title="title" <el-dialog
:close-on-click-modal="false" @open="setDefaultData" @closed="handleClosed"> v-model="visible"
<el-form ref="formRef" :model="hostForm" :rules="rules" :hide-required-asterisk="true" label-suffix="" width="600px"
label-width="100px" :show-message="false"> top="45px"
modal-class="host_form_dialog"
append-to-body
:title="title"
:close-on-click-modal="false"
@open="setDefaultData"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="hostForm"
:rules="rules"
:hide-required-asterisk="true"
label-suffix=""
label-width="100px"
:show-message="false"
>
<transition-group name="list" mode="out-in" tag="div"> <transition-group name="list" mode="out-in" tag="div">
<el-form-item key="group" label="分组" prop="group"> <el-form-item key="group" label="分组" prop="group">
<el-select v-model="hostForm.group" placeholder="实例分组" style="width: 100%;"> <el-select v-model="hostForm.group" placeholder="实例分组" style="width: 100%;">
<el-option v-for="item in groupList" :key="item.id" :label="item.name" :value="item.id" /> <el-option
v-for="item in groupList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item key="name" label="名称" prop="name"> <el-form-item key="name" label="名称" prop="name">
<el-input v-model.trim="hostForm.name" clearable placeholder="" autocomplete="off" /> <el-input
v-model.trim="hostForm.name"
clearable
placeholder=""
autocomplete="off"
/>
</el-form-item> </el-form-item>
<div key="instance_info" class="instance_info"> <div key="instance_info" class="instance_info">
<el-form-item key="host" class="form_item_host" label="主机" prop="host"> <el-form-item
<el-input v-model.trim="hostForm.host" clearable placeholder="IP" autocomplete="off" /> key="host"
class="form_item_host"
label="主机"
prop="host"
>
<el-input
v-model.trim="hostForm.host"
clearable
placeholder="IP"
autocomplete="off"
/>
</el-form-item> </el-form-item>
<el-form-item key="port" class="form_item_port" label="端口" prop="port"> <el-form-item
<el-input v-model.trim.number="hostForm.port" clearable placeholder="port" autocomplete="off" /> key="port"
class="form_item_port"
label="端口"
prop="port"
>
<el-input
v-model.trim.number="hostForm.port"
clearable
placeholder="port"
autocomplete="off"
/>
</el-form-item> </el-form-item>
</div> </div>
<el-form-item key="username" label="用户名" prop="username"> <el-form-item key="username" label="用户名" prop="username">
<el-autocomplete v-model.trim="hostForm.username" :fetch-suggestions="userSearch" style="width: 100%;" <el-autocomplete
clearable> v-model.trim="hostForm.username"
:fetch-suggestions="userSearch"
style="width: 100%;"
clearable
>
<template #default="{ item }"> <template #default="{ item }">
<div class="value">{{ item.value }}</div> <div class="value">{{ item.value }}</div>
</template> </template>
@ -33,23 +83,56 @@
<el-radio v-model.trim="hostForm.authType" value="password">密码</el-radio> <el-radio v-model.trim="hostForm.authType" value="password">密码</el-radio>
<el-radio v-model.trim="hostForm.authType" value="credential">凭据</el-radio> <el-radio v-model.trim="hostForm.authType" value="credential">凭据</el-radio>
</el-form-item> </el-form-item>
<el-form-item v-if="hostForm.authType === 'privateKey'" key="privateKey" prop="privateKey" label="密钥"> <el-form-item
v-if="hostForm.authType === 'privateKey'"
key="privateKey"
prop="privateKey"
label="密钥"
>
<el-button type="primary" size="small" @click="handleClickUploadBtn"> <el-button type="primary" size="small" @click="handleClickUploadBtn">
本地私钥... 本地私钥...
</el-button> </el-button>
<!-- <el-button type="primary" size="small" @click="handleClickUploadBtn"> <!-- <el-button type="primary" size="small" @click="handleClickUploadBtn">
从凭据导入... 从凭据导入...
</el-button> --> </el-button> -->
<input ref="privateKeyRef" type="file" name="privateKey" style="display: none;" <input
@change="handleSelectPrivateKeyFile"> ref="privateKeyRef"
<el-input v-model.trim="hostForm.privateKey" type="textarea" :rows="5" clearable autocomplete="off" type="file"
style="margin-top: 5px;" placeholder="-----BEGIN RSA PRIVATE KEY-----" /> name="privateKey"
style="display: none;"
@change="handleSelectPrivateKeyFile"
>
<el-input
v-model.trim="hostForm.privateKey"
type="textarea"
:rows="5"
clearable
autocomplete="off"
style="margin-top: 5px;"
placeholder="-----BEGIN RSA PRIVATE KEY-----"
/>
</el-form-item> </el-form-item>
<el-form-item v-if="hostForm.authType === 'password'" key="password" prop="password" label="密码"> <el-form-item
<el-input v-model.trim="hostForm.password" type="password" placeholder="" autocomplete="off" clearable v-if="hostForm.authType === 'password'"
show-password /> key="password"
prop="password"
label="密码"
>
<el-input
v-model.trim="hostForm.password"
type="password"
placeholder=""
autocomplete="off"
clearable
show-password
/>
</el-form-item> </el-form-item>
<el-form-item v-if="hostForm.authType === 'credential'" key="credential" prop="credential" label="凭据"> <el-form-item
v-if="hostForm.authType === 'credential'"
key="credential"
prop="credential"
label="凭据"
>
<el-select v-model="hostForm.credential" class="credential_select" placeholder=""> <el-select v-model="hostForm.credential" class="credential_select" placeholder="">
<template #empty> <template #empty>
<div class="empty_credential"> <div class="empty_credential">
@ -59,7 +142,12 @@
</el-button> </el-button>
</div> </div>
</template> </template>
<el-option v-for="item in sshList" :key="item.id" :label="item.name" :value="item.id"> <el-option
v-for="item in sshList"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="auth_type_wrap"> <div class="auth_type_wrap">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
<span class="auth_type_text"> <span class="auth_type_text">
@ -70,28 +158,61 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item key="command" prop="command" label="执行指令"> <el-form-item key="command" prop="command" label="执行指令">
<el-input v-model="hostForm.command" type="textarea" :rows="5" clearable autocomplete="off" <el-input
placeholder="连接服务器后自动执行的指令(例如: sudo -i)" /> v-model="hostForm.command"
type="textarea"
:rows="5"
clearable
autocomplete="off"
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
/>
</el-form-item> </el-form-item>
<el-form-item key="expired" label="到期时间" prop="expired"> <el-form-item key="expired" label="到期时间" prop="expired">
<el-date-picker v-model="hostForm.expired" type="date" style="width: 100%;" value-format="x" <el-date-picker
placeholder="实例到期时间" /> v-model="hostForm.expired"
type="date"
style="width: 100%;"
value-format="x"
placeholder="实例到期时间"
/>
</el-form-item> </el-form-item>
<el-form-item v-if="hostForm.expired" key="expiredNotify" label="到期提醒" prop="expiredNotify"> <el-form-item
v-if="hostForm.expired"
key="expiredNotify"
label="到期提醒"
prop="expiredNotify"
>
<el-tooltip content="将在实例到期前7、3、1天发送提醒(需在设置中绑定有效邮箱)" placement="right"> <el-tooltip content="将在实例到期前7、3、1天发送提醒(需在设置中绑定有效邮箱)" placement="right">
<el-switch v-model="hostForm.expiredNotify" :active-value="true" :inactive-value="false" /> <el-switch v-model="hostForm.expiredNotify" :active-value="true" :inactive-value="false" />
</el-tooltip> </el-tooltip>
</el-form-item> </el-form-item>
<el-form-item key="consoleUrl" label="控制台URL" prop="consoleUrl"> <el-form-item key="consoleUrl" label="控制台URL" prop="consoleUrl">
<el-input v-model.trim="hostForm.consoleUrl" clearable placeholder="用于直达云服务商控制台" autocomplete="off" <el-input
@keyup.enter="handleSave" /> v-model.trim="hostForm.consoleUrl"
clearable
placeholder="用于直达云服务商控制台"
autocomplete="off"
@keyup.enter="handleSave"
/>
</el-form-item> </el-form-item>
<el-form-item key="index" label="序号" prop="index"> <el-form-item key="index" label="序号" prop="index">
<el-input v-model.trim.number="hostForm.index" clearable placeholder="用于实例列表中排序(填写数字)" autocomplete="off" /> <el-input
v-model.trim.number="hostForm.index"
clearable
placeholder="用于实例列表中排序(填写数字)"
autocomplete="off"
/>
</el-form-item> </el-form-item>
<el-form-item key="remark" label="备注" prop="remark"> <el-form-item key="remark" label="备注" prop="remark">
<el-input v-model.trim="hostForm.remark" type="textarea" :rows="3" clearable autocomplete="off" <el-input
placeholder="简单记录实例用途" /> v-model.trim="hostForm.remark"
type="textarea"
:rows="3"
clearable
autocomplete="off"
placeholder="简单记录实例用途"
/>
</el-form-item> </el-form-item>
</transition-group> </transition-group>
</el-form> </el-form>
@ -195,7 +316,7 @@ const handleSelectPrivateKeyFile = (event) => {
let reader = new FileReader() let reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
hostForm.privateKey = e.target.result hostForm.privateKey = e.target.result
privateKeyRef.value.value = '' privateKeyRef.value = ''
} }
reader.readAsText(file) reader.readAsText(file)
} }

View File

@ -0,0 +1,221 @@
<template>
<el-dialog
v-model="visible"
width="600px"
top="225px"
modal-class="import_form_dialog"
append-to-body
title="导入实例配置"
:close-on-click-modal="false"
>
<h2>选择要导入的文件类型</h2>
<ul class="type_list">
<li @click="handleFromCsv">
<svg-icon name="icon-csv" class="icon" />
<span class="from">Xshell</span>
<span class="type">(csv)</span>
<input
ref="csvInputRef"
type="file"
accept=".csv"
multiple
name="csvInput"
style="display: none;"
@change="handleCsvFile"
>
</li>
<li @click="handleFromJson">
<svg-icon name="icon-json" class="icon" />
<span class="from">FinalShell</span>
<span class="type">(json)</span>
<input
ref="jsonInputRef"
type="file"
accept=".json"
multiple
name="jsonInput"
style="display: none;"
@change="handleJsonFile"
>
</li>
</ul>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, getCurrentInstance, nextTick } from 'vue'
import { RSAEncrypt, AESEncrypt, randomStr } from '@utils/index.js'
import { parse } from 'csv-parse/browser/esm/sync'
const { proxy: { $api, $router, $message, $store } } = getCurrentInstance()
const props = defineProps({
show: {
required: true,
type: Boolean
}
})
const emit = defineEmits(['update:show', 'update-list',])
const jsonInputRef = ref(null)
const csvInputRef = ref(null)
let visible = computed({
get: () => props.show,
set: (newVal) => emit('update:show', newVal)
})
function handleFromCsv() {
csvInputRef.value.click()
}
function handleFromJson() {
jsonInputRef.value.click()
}
const handleCsvFile = (event) => {
const files = event.target.files
if (!files.length) {
console.warn('No files selected')
return
}
const csvFiles = [...files,].filter(file => file.type === 'text/csv')
if (csvFiles.length === 0) return $message.warning('未选择有效的CSV文件')
let readerPromises = csvFiles.map(file => {
return new Promise((resolve, reject) => {
let reader = new FileReader()
reader.onload = (e) => {
const csvText = e.target.result
try {
const jsonContents = parse(csvText, {
columns: ['name', 'protocol', 'host', 'port', 'username', 'placeholder',],
from_line: 1 // xshell
})
handleImportHost(jsonContents)
} catch (error) {
console.error(`Error parsing CSV file ${ file.name }:`, error)
}
}
reader.onerror = () => {
reject(new Error(`Failed to read file: ${ file.name }`))
}
reader.readAsText(file)
})
})
Promise.all(readerPromises)
.then(jsonContents => {
let formatJson = jsonContents.map(item => {
const { name, host, port, user_name: username } = item
return { name, host, port, username }
})
handleImportHost(formatJson)
})
.catch(error => {
$message.error('导入失败: ', error.message)
console.error('导入失败: ', error)
})
}
const handleJsonFile = (event) => {
let files = event.target.files
let jsonFiles = Array.from(files).filter(file => file.type === 'application/json')
if (jsonFiles.length === 0) return $message.warning('未选择有效的JSON文件')
let readerPromises = jsonFiles.map(file => {
return new Promise((resolve, reject) => {
let reader = new FileReader()
reader.onload = (e) => {
try {
let jsonContent = JSON.parse(e.target.result)
resolve(jsonContent)
} catch (error) {
reject(new Error(`Failed to parse JSON file: ${ file.name }`))
}
}
reader.onerror = () => {
reject(new Error(`Failed to read file: ${ file.name }`))
}
reader.readAsText(file)
})
})
Promise.all(readerPromises)
.then(jsonContents => {
let formatJson = jsonContents.map(item => {
const { name, host, port, user_name: username } = item
return { name, host, port, username }
})
handleImportHost(formatJson)
})
.catch(error => {
$message.error('导入失败: ', error.message)
console.error('导入失败: ', error)
})
}
async function handleImportHost(importHost) {
// console.log(': ', importHost)
try {
let { data: { len } } = await $api.importHost({ importHost })
$message({ type: 'success', center: true, message: `成功导入实例: ${ len }` })
emit('update-list')
visible.value = false
} catch (error) {
$message.error('导入失败:', error.message)
}
}
</script>
<style lang="scss">
.import_form_dialog {
h2 {
font-size: 14px;
font-weight: 600;
text-align: center;
margin: 15px 0 25px 0;
}
.type_list {
display: flex;
align-items: center;
justify-content: center;
user-select: none;
li {
margin: 0 25px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 150px;
height: 150px;
cursor: pointer;
border-radius: 3px;
&:hover {
background-color: #f1f2f5;
color: var(--el-menu-active-color);
}
.icon {
width: 35px;
height: 35px;
}
span {
display: inline-block;
}
.from {
font-size: 14px;
margin: 15px 0;
}
.type {
font-size: 12px;
}
}
}
}
.dialog_footer {
display: flex;
justify-content: center;
}
</style>

View File

@ -5,11 +5,14 @@
<el-button type="primary" @click="handleHiddenIP"> <el-button type="primary" @click="handleHiddenIP">
{{ hiddenIp ? '显示IP' : '隐藏IP' }} {{ hiddenIp ? '显示IP' : '隐藏IP' }}
</el-button> </el-button>
<el-button type="primary" @click="importVisible = true">导入实例</el-button>
</div> </div>
<div class="server_group_collapse"> <div class="server_group_collapse">
<div v-if="isNoHost"> <div v-if="isNoHost">
<el-empty description="暂无实例"> <el-empty description="暂无实例">
<el-button type="primary" @click="hostFormVisible = true">添加第一台实例配置</el-button> <el-button type="primary" @click="hostFormVisible = true">添加实例配置</el-button>
<span class="or"></span>
<el-button type="primary" @click="importVisible = true">批量导入实例</el-button>
</el-empty> </el-empty>
</div> </div>
<el-collapse v-else v-model="activeGroup"> <el-collapse v-else v-model="activeGroup">
@ -38,6 +41,10 @@
@update-list="handleUpdateList" @update-list="handleUpdateList"
@closed="updateHostData = null" @closed="updateHostData = null"
/> />
<ImportHost
v-model:show="importVisible"
@update-list="handleUpdateList"
/>
</div> </div>
</template> </template>
@ -45,15 +52,18 @@
import { ref, getCurrentInstance, computed, watch } from 'vue' import { ref, getCurrentInstance, computed, watch } from 'vue'
import HostCard from './components/host-card.vue' import HostCard from './components/host-card.vue'
import HostForm from './components/host-form.vue' import HostForm from './components/host-form.vue'
import ImportHost from './components/import-host.vue'
const { proxy: { $store, $message } } = getCurrentInstance() const { proxy: { $store, $message } } = getCurrentInstance()
const updateHostData = ref(null) let updateHostData = ref(null)
const hostFormVisible = ref(false) let hostFormVisible = ref(false)
const hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0)) let importVisible = ref(false)
const activeGroup = ref([])
const handleUpdateList = async () => { let hiddenIp = ref(Number(localStorage.getItem('hiddenIp') || 0))
let activeGroup = ref([])
let handleUpdateList = async () => {
try { try {
await $store.getHostList() await $store.getHostList()
} catch (err) { } catch (err) {
@ -62,12 +72,12 @@ const handleUpdateList = async () => {
} }
} }
const handleUpdateHost = (defaultData) => { let handleUpdateHost = (defaultData) => {
hostFormVisible.value = true hostFormVisible.value = true
updateHostData.value = defaultData updateHostData.value = defaultData
} }
const handleHiddenIP = () => { let handleHiddenIP = () => {
hiddenIp.value = hiddenIp.value ? 0 : 1 hiddenIp.value = hiddenIp.value ? 0 : 1
localStorage.setItem('hiddenIp', String(hiddenIp.value)) localStorage.setItem('hiddenIp', String(hiddenIp.value))
} }
@ -124,6 +134,11 @@ let isNoHost = computed(() => Object.keys(groupHostList.value).length === 0)
.host_card_container { .host_card_container {
padding-top: 25px; padding-top: 25px;
} }
.or {
color: var(--el-text-color-secondary);
font-size: var(--el-font-size-base);
margin: 0 25px;
}
} }
} }
</style> </style>

View File

@ -1909,6 +1909,11 @@ csstype@^3.1.3:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
csv-parse@^5.5.6:
version "5.5.6"
resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.6.tgz#0d726d58a60416361358eec291a9f93abe0b6b1a"
integrity sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==
date-fns@^2.30.0: date-fns@^2.30.0:
version "2.30.0" version "2.30.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"