perf: update report

This commit is contained in:
ibuler
2026-03-31 21:20:49 +08:00
60 changed files with 1339 additions and 1152 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,7 @@
node_modules/
dist/
lina/
ui/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
@@ -18,4 +19,4 @@ tests/**/coverage/
.env.development
.python-version
helper.json
helper.json

View File

@@ -1,4 +1,4 @@
FROM jumpserver/lina-base:20260114_045651 AS stage-build
FROM jumpserver/lina-base:20260331_081153 AS stage-build
ARG VERSION
ENV VERSION=$VERSION

View File

@@ -59,6 +59,7 @@
"npm": "^7.8.0",
"nprogress": "0.2.0",
"path-to-regexp": "3.3.0",
"sm-crypto": "^0.4.0",
"sortablejs": "^1.15.6",
"v-sanitize": "^0.0.13",
"vue": "2.7.16",

View File

@@ -2,7 +2,7 @@
<AutoDataForm
v-if="!loading"
ref="AutoDataForm"
:class="addTemplate? '': 'account-add'"
:class="addTemplate ? '' : 'account-add'"
:submit-btn-text="submitBtnText"
v-bind="$data"
@submit="confirm"
@@ -11,7 +11,7 @@
<script>
import AutoDataForm from '@/components/Form/AutoDataForm/index.vue'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import { accountFieldsMeta } from '@/components/Apps/AccountCreateUpdateForm/const'
export default {
@@ -59,16 +59,27 @@ export default {
]
},
url: '/api/v1/accounts/accounts/',
form: Object.assign({ 'on_invalid': 'error' }, this.account || {}),
form: Object.assign({ on_invalid: 'error' }, this.account || {}),
encryptedFields: ['secret'],
fields: [
[this.$t('Basic'), ['name', 'username', 'privileged', 'su_from', 'su_from_username', 'template']],
[
this.$t('Basic'),
['name', 'username', 'privileged', 'su_from', 'su_from_username', 'template']
],
[this.$t('Asset'), ['nodes', 'assets']],
[this.$t('Secret'), [
'secret_type', 'password', 'ssh_key', 'token',
'access_key', 'passphrase', 'api_key',
'secret_reset'
]],
[
this.$t('Secret'),
[
'secret_type',
'password',
'ssh_key',
'token',
'access_key',
'passphrase',
'api_key',
'secret_reset'
]
],
[this.$t('Other'), ['push_now', 'params', 'on_invalid', 'is_active', 'comment']]
],
fieldsMeta: accountFieldsMeta(this),
@@ -170,7 +181,7 @@ export default {
}
</script>
<style lang='scss' scoped>
<style lang="scss" scoped>
.account-add {
::v-deep .el-form-item {
//margin-bottom: 5px;

View File

@@ -11,7 +11,7 @@
<script>
import { GenericUpdateFormDialog } from '@/layout/components'
import { accountFieldsMeta } from '@/components/Apps/AccountCreateUpdateForm/const'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
export default {
name: 'AccountBulkUpdateDialog',
@@ -25,7 +25,7 @@ export default {
},
selectedRows: {
type: Array,
default: () => ([])
default: () => []
}
},
data() {
@@ -35,7 +35,7 @@ export default {
hasSaveContinue: false,
fields: [],
fieldsMeta: accountFieldsMeta(this),
cleanOtherFormValue: (formValue) => {
cleanOtherFormValue: formValue => {
for (const value of formValue) {
Object.keys(value).forEach((item, index, arr) => {
if (['ssh_key', 'token', 'access_key', 'api_key', 'password'].includes(item)) {
@@ -82,6 +82,4 @@ export default {
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@@ -23,7 +23,7 @@
<script>
import Dialog from '@/components/Dialog/index.vue'
import { accountFieldsMeta } from '@/components/Apps/AccountCreateUpdateForm/const'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import AutoDataForm from '@/components/Form/AutoDataForm/index.vue'
export default {
@@ -46,8 +46,14 @@ export default {
const accountMeta = accountFieldsMeta(this)
return {
fields: [
'name', 'secret_type', 'password', 'ssh_key', 'token',
'access_key', 'passphrase', 'api_key'
'name',
'secret_type',
'password',
'ssh_key',
'token',
'access_key',
'passphrase',
'api_key'
],
fieldsMeta: {
...accountMeta,
@@ -80,18 +86,19 @@ export default {
const data = {
secret: encryptPassword(form[secretType])
}
this.$axios.patch(
`/api/v1/accounts/accounts/${this.account.id}/`,
data,
{ disableFlashErrorMsg: true }
).then(res => {
this.$message.success(this.$tc('UpdateSuccessMsg'))
this.iVisible = false
}).catch(err => {
const errMsg = Object.values(err.response.data).join(', ')
this.$message.error(this.$tc('UpdateErrorMsg') + ' ' + errMsg)
this.iVisible = false
})
this.$axios
.patch(`/api/v1/accounts/accounts/${this.account.id}/`, data, {
disableFlashErrorMsg: true
})
.then(res => {
this.$message.success(this.$tc('UpdateSuccessMsg'))
this.iVisible = false
})
.catch(err => {
const errMsg = Object.values(err.response.data).join(', ')
this.$message.error(this.$tc('UpdateErrorMsg') + ' ' + errMsg)
this.iVisible = false
})
},
handleCancel() {
this.$emit('update:visible', false)

View File

@@ -20,10 +20,12 @@
<el-form-item :label="secretTypeLabel">
<SecretViewerFormatter
:cell-value="secretInfo.secret"
:col="{ formatterArgs: {
name: account['name'],
secretType: secretType || ''
}}"
:col="{
formatterArgs: {
name: account['name'],
secretType: secretType || ''
}
}"
@input="onShowKeyCopyFormatterChange"
/>
</el-form-item>
@@ -36,12 +38,12 @@
<el-form-item :label="$tc('DateUpdated')">
<span>{{ account['date_updated'] | date }}</span>
</el-form-item>
<el-form-item v-if="showPasswordRecord" v-perms="'accounts.view_accountsecret'" :label="$tc('PasswordRecord')">
<el-link
:underline="false"
type="success"
@click="showHistoryDialog"
>
<el-form-item
v-if="showPasswordRecord"
v-perms="'accounts.view_accountsecret'"
:label="$tc('PasswordRecord')"
>
<el-link :underline="false" type="success" @click="showHistoryDialog">
<span style="padding-right: 30px">
{{ versions }}
</span>
@@ -61,7 +63,7 @@
import Dialog from '@/components/Dialog/index.vue'
import PasswordHistoryDialog from './PasswordHistoryDialog.vue'
import { SecretViewerFormatter } from '@/components/Table/TableFormatters'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import { mapGetters } from 'vuex'
export default {
@@ -90,7 +92,7 @@ export default {
},
title: {
type: String,
default: function() {
default: function () {
return this.$tc('Detail')
}
},
@@ -144,7 +146,8 @@ export default {
name: this.secretInfo.name,
secret: encryptPassword(this.modifiedSecret)
}
const url = this.type === 'account' ? `/api/v1/accounts/accounts` : `/api/v1/accounts/account-templates`
const url =
this.type === 'account' ? `/api/v1/accounts/accounts` : `/api/v1/accounts/account-templates`
this.$axios.patch(`${url}/${this.account.id}/`, params).then(() => {
this.$message.success(this.$tc('UpdateSuccessMsg'))
})
@@ -154,7 +157,7 @@ export default {
this.$message.warning(this.$tc('AccountSecretReadDisabled'))
return
}
return this.$axios.get(this.url).then((res) => {
return this.$axios.get(this.url).then(res => {
this.secretInfo = res
this.sshKeyFingerprint = res?.spec_info?.ssh_key_fingerprint || '-'
this.showSecret = true
@@ -180,7 +183,7 @@ export default {
}
.el-form-item {
border-bottom: 1px solid #EBEEF5;
border-bottom: 1px solid #ebeef5;
padding: 5px 0;
margin-bottom: 0;

View File

@@ -118,7 +118,8 @@
</template>
<script>
import Dialog from '@/components/Dialog/index.vue'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import { logout as redirectToLogout } from '@/utils/request'
export default {
name: 'UserConfirmDialog',
@@ -179,7 +180,7 @@ export default {
this.inputPlaceholder = this.subTypeChoices.filter(item => item.name === val)[0]?.placeholder
this.smsWidth = val === 'sms' ? 6 : 0
},
performConfirm: _.debounce(function({ response, callback, cancel }) {
performConfirm: _.debounce(function ({ response, callback, cancel }) {
if (this.processing || this.visible) {
return
}
@@ -224,7 +225,7 @@ export default {
})
}, 500),
logout() {
window.location.href = `${process.env.VUE_APP_LOGOUT_PATH}?next=${this.$route.fullPath}`
redirectToLogout()
},
sendCode() {
this.$axios

View File

@@ -3,7 +3,7 @@
<Dialog
v-if="exportDialogShow"
:destroy-on-close="true"
:title="$tc('Export')"
:title="iDialogTitle"
:visible.sync="exportDialogShow"
width="700px"
@close="handleExportCancel"
@@ -14,10 +14,10 @@
{{ tips }}
</el-alert>
<el-form label-position="left" style="padding-left: 20px">
<el-form-item :label="$tc('FileType' )" :label-width="'100px'">
<el-form-item v-if="showFileType" :label="$tc('FileType' )" :label-width="'100px'">
<el-radio-group v-model="exportTypeOption">
<el-radio
v-for="option of exportTypeOptions"
v-for="option of iExportTypeOptions"
:key="option.value"
:disabled="!option.can"
:label="option.value"
@@ -99,6 +99,34 @@ export default {
tipsType: {
type: String,
default: 'success'
},
triggerEvent: {
type: String,
default: 'showExportDialog'
},
dialogTitle: {
type: String,
default: ''
},
showFileType: {
type: Boolean,
default: true
},
defaultExportType: {
type: String,
default: 'csv'
},
fixedExportType: {
type: String,
default: ''
},
fileTypeOptions: {
type: Array,
default: () => []
},
extraQuery: {
type: Object,
default: () => ({})
}
},
data() {
@@ -137,6 +165,9 @@ export default {
tableHasQuery() {
return Object.keys(this.tableQuery).length > 0
},
iDialogTitle() {
return this.dialogTitle || this.$tc('Export')
},
exportOptions() {
return [
{
@@ -156,7 +187,10 @@ export default {
}
]
},
exportTypeOptions() {
iExportTypeOptions() {
if (this.fileTypeOptions.length > 0) {
return this.fileTypeOptions
}
return [
{
label: 'CSV',
@@ -172,10 +206,10 @@ export default {
}
},
beforeDestroy() {
this.$eventBus.$off('showExportDialog', this.showExportDialogHandler)
this.$eventBus.$off(this.triggerEvent, this.showExportDialogHandler)
},
mounted() {
this.$eventBus.$on('showExportDialog', this.showExportDialogHandler)
this.$eventBus.$on(this.triggerEvent, this.showExportDialogHandler)
},
methods: {
showExportDialogHandler({ selectedRows, url, name }) {
@@ -184,6 +218,9 @@ export default {
}
},
showExportDialog() {
this.exportTypeOption = this.fixedExportType || this.defaultExportType
this.exportOption = 'all'
if (!this.mfaVerifyRequired) {
this.exportDialogShow = true
@@ -199,6 +236,13 @@ export default {
}
this.$axios.get('/api/v1/authentication/confirm/check/?confirm_type=mfa').then(() => {
this.exportDialogShow = true
if (this.hasSelected) {
this.exportOption = 'selected'
}
if (this.tableHasQuery) {
this.exportOption = 'filtered'
}
})
},
downloadCsv(url) {
@@ -216,7 +260,8 @@ export default {
const spm = await createSourceIdCache(resources)
query['spm'] = spm.spm
}
query['format'] = exportTypeOption
Object.assign(query, this.extraQuery)
query['format'] = exportTypeOption || this.fixedExportType || this.defaultExportType
const queryStr =
(url.indexOf('?') > -1 ? '&' : '?') +
queryUtil.stringify(query, '=', '&')

View File

@@ -1,6 +1,7 @@
<template>
<div>
<ExportDialog :selected-rows="selectedRows" v-bind="exportOptions" v-on="$listeners" />
<ExportDialog :selected-rows="selectedRows" v-bind="reportExportOptions" v-on="$listeners" />
<ImportDialog :selected-rows="selectedRows" v-bind="importOptions" v-on="$listeners" />
</div>
</template>
@@ -24,6 +25,10 @@ export default {
type: Object,
default: () => ({})
},
reportExportOptions: {
type: Object,
default: () => ({})
},
importOptions: {
type: Object,
default: () => ({})

View File

@@ -11,10 +11,18 @@
<div class="row">
<el-progress :percentage="processedPercent" />
</div>
<DataTable v-if="tableGenDone" id="importTable" ref="dataTable" :config="tableConfig" class="importTable" />
<DataTable
v-if="tableGenDone"
id="importTable"
ref="dataTable"
:config="tableConfig"
class="importTable"
/>
<div class="row" style="padding-top: 20px">
<div class="btn-groups">
<el-button v-if="showCancel" size="small" @click="performCancel">{{ $t('Cancel') }}</el-button>
<el-button v-if="showCancel" size="small" @click="performCancel">{{
$t('Cancel')
}}</el-button>
<el-button
v-show="!disableImportBtn"
size="small"
@@ -45,7 +53,7 @@ import DataTable from '@/components/Table/DataTable/index.vue'
import { getUpdateObjURL } from '@/utils/common/index'
import { sleep } from '@/utils/common/time'
import { EditableInputFormatter } from '@/components/Table/TableFormatters'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import getStatusColumnMeta from '@/components/Table/ListTable/TableAction/const'
export default {
@@ -162,17 +170,17 @@ export default {
return this.importActions[this.importAction]
},
successData() {
return this.iTotalData.filter((item) => {
return this.iTotalData.filter(item => {
return item['@status'] === 'ok'
})
},
failedData() {
return this.iTotalData.filter((item) => {
return this.iTotalData.filter(item => {
return typeof item['@status'] === 'object' && item['@status'].name === 'error'
})
},
pendingData() {
return this.iTotalData.filter((item) => {
return this.iTotalData.filter(item => {
return item['@status'] === 'pending'
})
},
@@ -195,7 +203,7 @@ export default {
if (this.totalCount === 0) {
return 0
}
return Math.round(this.processedCount / this.totalCount * 100)
return Math.round((this.processedCount / this.totalCount) * 100)
},
elDataTable() {
return this.$refs['dataTable'].dataTable
@@ -218,7 +226,7 @@ export default {
} else if (val === 'error') {
this.tableConfig.totalData = this.failedData
} else {
this.tableConfig.totalData = this.iTotalData.filter((item) => {
this.tableConfig.totalData = this.iTotalData.filter(item => {
return item['@status'] === val
})
}
@@ -278,7 +286,8 @@ export default {
return columns
},
getEncryptFields() {
const fromProp = Array.isArray(this.encryptFields) && this.encryptFields.length ? this.encryptFields : null
const fromProp =
Array.isArray(this.encryptFields) && this.encryptFields.length ? this.encryptFields : null
return fromProp || ['password', 'secret', 'private_key']
},
generateTableData(tableTitles, tableData) {
@@ -415,11 +424,7 @@ export default {
},
async performUpdateObject(item) {
const updateUrl = getUpdateObjURL(this.url, item.id)
return this.$axios.patch(
updateUrl,
item,
{ disableFlashErrorMsg: true }
)
return this.$axios.patch(updateUrl, item, { disableFlashErrorMsg: true })
},
async defaultPerformUploadObject(item) {
let handler = this.performCreateObject
@@ -439,11 +444,7 @@ export default {
}
},
async performCreateObject(item) {
return this.$axios.post(
this.url,
item,
{ disableFlashErrorMsg: true }
)
return this.$axios.post(this.url, item, { disableFlashErrorMsg: true })
},
keepElementInViewport() {
const tableRef = document.getElementById('importTable')
@@ -455,11 +456,7 @@ export default {
const rect = parentTdRef.getBoundingClientRect()
let windowInnerHeight = window.innerHeight || document.documentElement.clientHeight
windowInnerHeight = windowInnerHeight * 0.97 - 150
const inViewport = (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= windowInnerHeight
)
const inViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= windowInnerHeight
if (!inViewport) {
parentTdRef.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'start' })
}
@@ -468,8 +465,7 @@ export default {
this.tableConfig.totalData.push(item)
},
handleClick(btn) {
const callback = btn.callback || function() {
}
const callback = btn.callback || function () {}
callback(btn)
}
}
@@ -477,10 +473,10 @@ export default {
</script>
<style lang="scss" scoped>
@import "~@/styles/variables";
@import '~@/styles/variables';
.summary-item {
padding: 0 10px
padding: 0 10px;
}
.summary-success {

View File

@@ -4,6 +4,7 @@
<ImExportDialog
v-if="dialogExportVisible"
:export-options="iExportOptions"
:report-export-options="iReportExportOptions"
:import-options="iImportOptions"
:selected-rows="selectedRows"
v-bind="$attrs"
@@ -20,6 +21,7 @@ import { cleanActions } from './utils'
import { assignIfNot } from '@/utils/common/index'
const defaultTrue = { type: [Boolean, Function, String], default: true }
const defaultFalse = { type: [Boolean, Function, String], default: false }
export default {
name: 'RightSide',
@@ -48,6 +50,22 @@ export default {
})
}
},
hasReportExport: defaultFalse,
reportExportOptions: {
type: Object,
default: () => ({})
},
handleReportExportClick: {
type: Function,
default: function({ selectedRows }) {
const url = this.iReportExportOptions.url
const triggerEvent = this.iReportExportOptions.triggerEvent || 'showReportExportDialog'
this.dialogExportVisible = true
this.$nextTick(() => {
this.$eventBus.$emit(triggerEvent, { selectedRows, url, name: this.name })
})
}
},
hasImport: defaultTrue,
importOptions: {
type: Object,
@@ -133,9 +151,16 @@ export default {
name: 'actionExport',
icon: 'download',
tip: this.$t('Export'),
has: this.hasExport,
has: this.showLegacyExport.bind(this),
callback: this.handleExportClick.bind(this)
},
{
name: 'actionReportExport',
icon: 'fa-file-excel-o',
tip: this.$t('ReportExport'),
has: this.showReportExport.bind(this),
callback: this.handleReportExportClick.bind(this)
},
{
name: 'actionRefresh',
icon: 'refresh',
@@ -179,9 +204,53 @@ export default {
url: this.tableUrl,
...this.exportOptions
}
},
iReportExportOptions() {
const reportExportOptions = this.reportExportOptions || {}
const {
extraQuery = {},
fileTypeOptions,
...restReportExportOptions
} = reportExportOptions
return {
url: this.tableUrl,
triggerEvent: 'showReportExportDialog',
dialogTitle: this.$t('ReportExport'),
fixedExportType: 'xlsx',
showFileType: false,
...restReportExportOptions,
extraQuery: {
export_mode: 'report',
...extraQuery
},
fileTypeOptions: fileTypeOptions || [
{
label: 'Excel',
value: 'xlsx',
can: true
}
]
}
}
},
methods: {
getActionVisibility(value, args) {
if (typeof value === 'function') {
return value(args)
}
return value
},
showLegacyExport(args) {
const reportExportVisibility = this.getActionVisibility(this.hasReportExport, args)
if (reportExportVisibility) {
return false
}
return this.getActionVisibility(this.hasExport, args)
},
showReportExport(args) {
return this.getActionVisibility(this.hasReportExport, args)
},
handleFilterClick() {
this.$emit('update:quick-filter-expand', !this.quickFilterExpand)
},

View File

@@ -44,6 +44,7 @@
<script>
import BaseFormatter from './base.vue'
import { addBasePath } from '@/utils/common/index'
export default {
name: 'AccountConnectFormatter',
@@ -56,12 +57,12 @@ export default {
can: () => true,
getConnectUrl: (row, protocol, asset) => {
const assetId = asset ? asset.id : row.asset.id
return `/luna/admin-connect/?
return addBasePath(`/luna/admin-connect/?
asset=${assetId}
&account=${row.id}
&protocol=${protocol}
&org_id=${this.$store.getters.currentOrg.id}
`.replace(/\s+/g, '')
`.replace(/\s+/g, ''))
},
asset: null,
assetUrl: '/api/v1/assets/assets/{id}/',

View File

@@ -46,7 +46,7 @@ Vue.prototype.$tr = key => {
export async function fetchTranslationsFromAPI() {
try {
const res = await axios.get(`${window.__BASE_PATH__}/api/v1/settings/i18n/lina/?lang=${lang}&flat=0`)
const res = await axios.get(`/api/v1/settings/i18n/lina/?lang=${lang}&flat=0`)
const data = res.data
for (const key in data) {
if (data.hasOwnProperty(key)) {

View File

@@ -34,6 +34,10 @@ export default {
let key
if (this.$route.query['_']) {
key = this.$route.query['_']
} else if (this.$route.path.startsWith('/audit/reports/')) {
// 报表页面:只用路径作为 key让同一路径的组件实例被复用
// 包含 query 会导致每次 query 变化都创建新的缓存实例,积累的 deactivated 实例会同时响应路由变化形成循环
key = _.trimEnd(this.$route.path, '/')
} else if (this.$route.name.toLowerCase().includes('list')) {
key = _.trimEnd(this.$route.path, '/') + '?' + new URLSearchParams(query).toString()
} else {

View File

@@ -19,7 +19,7 @@
<script>
import AutoDataForm from '@/components/Form/AutoDataForm'
import { getUpdateObjURL } from '@/utils/common/index'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import deepmerge from 'deepmerge'
export default {
@@ -45,12 +45,12 @@ export default {
},
afterGetFormValue: {
type: Function,
default: (value) => value
default: value => value
},
// 提交前清理form的值
cleanFormValue: {
type: Function,
default: (value) => value
default: value => value
},
// 获取 meta
afterGetRemoteMeta: {
@@ -76,28 +76,28 @@ export default {
// 创建成功的msg
createSuccessMsg: {
type: String,
default: function() {
default: function () {
return this.$t('CreateSuccessMsg')
}
},
// 保存成功继续添加的msg
saveSuccessContinueMsg: {
type: String,
default: function() {
default: function () {
return this.$t('SaveSuccessContinueMsg')
}
},
// 更新成功的msg
updateSuccessMsg: {
type: String,
default: function() {
default: function () {
return this.$t('UpdateSuccessMsg')
}
},
// 创建成功的跳转路由
createSuccessNextRoute: {
type: Object,
default: function() {
default: function () {
const routeName = this.$route.name?.replace('Create', 'List')
return { name: routeName }
}
@@ -105,16 +105,15 @@ export default {
// 更新成功的跳转路由
updateSuccessNextRoute: {
type: Object,
default: function() {
default: function () {
const routeName = this.$route.name?.replace('Update', 'List')
return { name: routeName }
}
},
objectDetailRoute: {
type: Object,
default: function() {
const routeName = this.$route.name?.replace('Update', 'Detail')
.replace('Create', 'Detail')
default: function () {
const routeName = this.$route.name?.replace('Update', 'Detail').replace('Create', 'Detail')
return { name: routeName }
}
},
@@ -127,7 +126,7 @@ export default {
},
cloneNameSuffix: {
type: [String, Number],
default: function() {
default: function () {
return this.$t('Duplicate').toLowerCase()
}
},
@@ -139,7 +138,7 @@ export default {
// 获取创建和更新的url function
getUrl: {
type: Function,
default: function() {
default: function () {
const objectId = this.getUpdateId()
let url = this.url
if (objectId) {
@@ -180,12 +179,16 @@ export default {
msg = msg[0].toLowerCase() + msg.slice(1)
this.$message({
message: h('p', null, [
h('el-link', {
on: {
click: () => this.$router.push(detailRoute)
h(
'el-link',
{
on: {
click: () => this.$router.push(detailRoute)
},
style: { 'vertical-align': 'top', 'margin-right': '5px' }
},
style: { 'vertical-align': 'top', 'margin-right': '5px' }
}, msgLinkName),
msgLinkName
),
h('span', {}, msg)
]),
type: 'success'
@@ -200,9 +203,12 @@ export default {
default(res, method, vm, addContinue) {
const route = this.getNextRoute(res, method)
if (!(route.params && route.params.id)) {
route['params'] = deepmerge(route['params'] || {}, { 'id': res.id })
route['params'] = deepmerge(route['params'] || {}, { id: res.id })
}
route['query'] = deepmerge(route['query'], { 'order': this.extraQueryOrder, 'updated': new Date().getTime() })
route['query'] = deepmerge(route['query'], {
order: this.extraQueryOrder,
updated: new Date().getTime()
})
this.$emit('submitSuccess', res)
@@ -344,7 +350,7 @@ export default {
encryptFields(values) {
// 批量提交clean 后可能是个数组
if (values instanceof Array) {
return values.map((item) => this.encryptFields(item))
return values.map(item => this.encryptFields(item))
}
values = { ...values }
for (const field of this.encryptedFields) {
@@ -372,8 +378,8 @@ export default {
defaultOnSubmit(validValues, formName, addContinue) {
this.isSubmitting = true
this.performSubmit(validValues)
.then((res) => this.onPerformSuccess.bind(this)(res, this.method, this, addContinue))
.catch((error) => this.onPerformError(error, this.method, this))
.then(res => this.onPerformSuccess.bind(this)(res, this.method, this, addContinue))
.catch(error => this.onPerformError(error, this.method, this))
.finally(() => {
setTimeout(() => {
this.isSubmitting = false
@@ -383,7 +389,7 @@ export default {
},
async getCloneForm(cloneFrom) {
const [curUrl, query] = this.url.split('?')
const url = `${curUrl}${cloneFrom}/${query ? ('?' + query) : ''}`
const url = `${curUrl}${cloneFrom}/${query ? '?' + query : ''}`
try {
const object = await this.getObjectDetail(url)
let name = ''
@@ -438,5 +444,4 @@ export default {
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@@ -36,6 +36,7 @@
<script>
import { mapGetters } from 'vuex'
import { logout as redirectToLogout } from '@/utils/request'
export default {
name: 'AccountDropdown',
@@ -74,7 +75,7 @@ export default {
if (currentOrg && (currentOrg.autoEnter || currentOrg.is_system)) {
await this.$store.dispatch('users/setCurrentOrg', this.$store.getters.preOrg)
}
window.location.href = `${process.env.VUE_APP_LOGOUT_PATH}?next=${this.$route.fullPath}`
redirectToLogout()
}
}
}

View File

@@ -1,6 +1,6 @@
import i18n from '@/i18n/i18n'
import { message } from '@/utils/vue/message'
import { scopedLocalStorage as localStorage } from '@/utils/storage'
import { getBasePath, scopedLocalStorage as localStorage } from '@/utils/storage'
const _ = require('lodash')
@@ -194,6 +194,38 @@ export function newURL(url) {
return obj
}
export function addBasePath(path = '') {
if (!path || /^https?:\/\//i.test(path)) {
return path
}
const basePath = getBasePath()
const normalizedPath = path.startsWith('/') ? path : `/${path}`
if (!basePath) {
return normalizedPath
}
if (
normalizedPath === basePath ||
normalizedPath.startsWith(basePath + '/') ||
normalizedPath.startsWith(basePath + '?') ||
normalizedPath.startsWith(basePath + '#')
) {
return normalizedPath
}
return `${basePath}${normalizedPath}`
}
export function getCurrentPageUrl() {
if (typeof window === 'undefined') {
return '/'
}
const { pathname, search, hash } = window.location
return `${pathname}${search}${hash}`
}
export function getUpdateObjURL(url, objId) {
const urlObj = new URL(url, location.origin)
let pathname = urlObj.pathname
@@ -231,7 +263,7 @@ export const assignIfNot = _.partialRight(_.assignInWith, customizer)
const scheme = document.location.protocol
const port = document.location.port ? ':' + document.location.port : ''
const BASE_URL = scheme + '//' + document.location.hostname + port
const BASE_URL = scheme + '//' + document.location.hostname + port + getBasePath()
export function groupedDropdownToCascader(group) {
const firstType = group[0]
@@ -427,7 +459,7 @@ export function openNewWindow(url) {
let params = 'toolbar=yes,scrollbars=yes,resizable=yes'
params = params + `,top=${top},left=${left},width=${screen.width / 3},height=${screen.height / 3}`
window.sessionStorage.setItem('newWindowCount', `${count + 1}`)
window.open(url, '_blank', params)
window.open(addBasePath(url), '_blank', params)
}
export function getDrawerWidth() {
@@ -548,8 +580,10 @@ export function randomString(length, includeSymbols = false) {
}
export function createWsUrl(path) {
if (/^wss?:\/\//i.test(path)) {
return path
}
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
const port = location.port ? ':' + location.port : ''
const base = window.__BASE_PATH__ || ''
return scheme + '://' + location.hostname + port + base + path
return scheme + '://' + location.hostname + port + addBasePath(path)
}

View File

@@ -31,7 +31,7 @@ function openOrReuseWindow(
export function openTaskPage(taskId, taskType, taskUrl) {
taskType = taskType || 'celery'
if (!taskUrl) {
taskUrl = `${window.__BASE_PATH__}/core/ops/${taskType}/task/${taskId}/log/?type=${taskType}`
taskUrl = `/core/ops/${taskType}/task/${taskId}/log/?type=${taskType}`
}
openOrReuseWindow(taskUrl)
}

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import i18n from '@/i18n/i18n'
import { eventBus } from '@/utils/vue/eventbus'
import { getTokenFromCookie } from '@/utils/jms/auth'
import { getErrorResponseMsg } from '@/utils/common'
import { addBasePath, getCurrentPageUrl, getErrorResponseMsg } from '@/utils/common'
import { MessageBox } from 'element-ui'
import { message } from '@/utils/vue/message'
import store from '@/store'
@@ -70,9 +70,8 @@ service.interceptors.request.use(
function goToLogin() {
setTimeout(() => {
const base = window.__BASE_PATH__ || ''
const next = base ? window.location.pathname.replace(base, '') : window.location.pathname
window.location = `${base}${process.env.VUE_APP_LOGIN_PATH}?next=${next}`
const next = encodeURIComponent(getCurrentPageUrl())
window.location = `${addBasePath(process.env.VUE_APP_LOGIN_PATH)}?next=${next}`
}, 200)
localStorage.setItem('next', window.location.hash.replace('#', ''))
}
@@ -114,9 +113,8 @@ function ifBadRequest({ response, error }) {
}
export function logout() {
const base = window.__BASE_PATH__ || ''
const next = base ? location.pathname.replace(base, '') : location.pathname
window.location.href = `${base}${process.env.VUE_APP_LOGOUT_PATH}?next=${next}`
const next = encodeURIComponent(getCurrentPageUrl())
window.location.href = `${addBasePath(process.env.VUE_APP_LOGOUT_PATH)}?next=${next}`
}
export function flashErrorMsg({ response, error }) {

View File

@@ -1,6 +1,4 @@
/**
* Created by PanJiaChen on 16/11/18.
*/
import { encryptPassword } from './session-encrypt'
/**
* @param {string} path
@@ -43,60 +41,6 @@ const options = {
}
}
const filter = new xss.FilterXSS(options)
import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
import CryptoJS from 'crypto-js'
import { vueCookie as VueCookie } from '@/utils/storage'
export function fillKey(key) {
const KeyLength = 16
if (key.length > KeyLength) {
key = key.slice(0, KeyLength)
}
const filledKey = Buffer.alloc(KeyLength)
const keys = Buffer.from(key)
for (let i = 0; i < keys.length; i++) {
filledKey[i] = keys[i]
}
return filledKey
}
export function aesEncrypt(text, originKey) {
const key = CryptoJS.enc.Utf8.parse(fillKey(originKey))
return CryptoJS.AES.encrypt(text, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.ZeroPadding
}).toString()
}
export function rsaEncrypt(text, pubKey) {
const jsEncrypt = new JSEncrypt()
jsEncrypt.setPublicKey(pubKey)
return jsEncrypt.encrypt(text)
}
export function getCookie(name) {
return VueCookie.get(name)
}
export function encryptPassword(password) {
if (!password) {
return ''
}
let rsaPublicKeyText = getCookie('jms_public_key')
if (!rsaPublicKeyText) {
return password
}
const aesKey = (Math.random() + 1).toString(36).substring(2)
// public key 是 base64 存储的
rsaPublicKeyText = rsaPublicKeyText.replaceAll('"', '')
const rsaPublicKey = atob(rsaPublicKeyText)
const keyCipher = rsaEncrypt(aesKey, rsaPublicKey)
const passwordCipher = aesEncrypt(String(password), aesKey)
return `${keyCipher}:${passwordCipher}`
}
window.aesEncrypt = aesEncrypt
window.fillKey = fillKey
export default filter
window.encryptPassword = encryptPassword

View File

@@ -0,0 +1,160 @@
import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
import CryptoJS from 'crypto-js'
import { vueCookie as VueCookie } from '@/utils/storage'
import { sm2, sm4 } from 'sm-crypto'
export function getCookie(name) {
return VueCookie.get(name)
}
export function fillKey(key) {
const KeyLength = 16
if (key.length > KeyLength) {
key = key.slice(0, KeyLength)
}
const filledKey = Buffer.alloc(KeyLength)
const keys = Buffer.from(key)
for (let i = 0; i < keys.length; i++) {
filledKey[i] = keys[i]
}
return filledKey
}
function aesEncrypt(text, originKey) {
const key = CryptoJS.enc.Utf8.parse(fillKey(originKey))
return CryptoJS.AES.encrypt(text, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.ZeroPadding
}).toString()
}
function rsaEncrypt(text, pubKey) {
if (!text) {
return text
}
const jsEncrypt = new JSEncrypt()
jsEncrypt.setPublicKey(pubKey)
return jsEncrypt.encrypt(text)
}
function rsaDecrypt(cipher, pkey) {
const jsEncrypt = new JSEncrypt()
jsEncrypt.setPrivateKey(pkey)
return jsEncrypt.decrypt(cipher)
}
window.rsaEncrypt = rsaEncrypt
window.rsaDecrypt = rsaDecrypt
function hexToBytes(hex) {
if (!hex) return new Uint8Array([])
hex = hex.toString().trim().toLowerCase()
if (hex.startsWith('0x')) {
hex = hex.slice(2)
}
// 确保是偶数长度
const len = Math.floor(hex.length / 2)
const bytes = new Uint8Array(len)
for (let i = 0; i < len; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16)
}
return bytes
}
function bytesToBase64(bytes) {
// Uint8Array -> base64标准 base64
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
function rsaEncryptPassword(password, rsaPublicKey) {
const aesKey = (Math.random() + 1).toString(36).substring(2)
// public key 是 base64 存储的
const keyCipher = rsaEncrypt(aesKey, rsaPublicKey)
const passwordCipher = aesEncrypt(password, aesKey)
return `${keyCipher}:${passwordCipher}`
}
function ensureSm2PublicKey(sm2PublicKey) {
// sm2.min.js 的 doEncrypt 需要能被 decodePointHex 解析的公钥:
// 通常为非压缩点 hex格式 `04||x||y`(总长度 130
// 但后端生成/下发的公钥有时是 `x||y`(长度 128这里做归一化补齐 `04` 前缀。
if (typeof sm2PublicKey === 'string') {
sm2PublicKey = sm2PublicKey.replaceAll('"', '').trim()
if (sm2PublicKey.startsWith('0x')) {
sm2PublicKey = sm2PublicKey.slice(2)
}
// 后端下发的 SM2 公钥常见是 x||y128 hexsm-crypto 需要 04||x||y130 hex
if (sm2PublicKey.length === 128 && !sm2PublicKey.startsWith('04')) {
sm2PublicKey = '04' + sm2PublicKey
}
}
return sm2PublicKey
}
function gmEncryptPassword(password, sm2PublicKey) {
sm2PublicKey = ensureSm2PublicKey(sm2PublicKey)
// 只适配前端,不改后端:
// 直接生成 16 字符 key后端 padding_key 会保持原样,不再补齐)
const sm4KeyRaw = randomString(16)
const sm4KeyHex = Buffer.from(sm4KeyRaw).toString('hex')
let keyCipher = ''
try {
// 与后端 gmssl.sm2.CryptSM2 默认 decrypt 的 mode 对齐:
// gmssl 解析的格式是 C1C2C3mode=0前端这里输出也用 mode=0。
keyCipher = sm2.doEncrypt(sm4KeyRaw, sm2PublicKey, 0)
} catch (e) {
console.error('gmEncryptPassword sm2.doEncrypt failed:', e)
// 避免前端崩溃:失败时返回明文,由后端按原值流程处理(至少可继续登录/看报错)
return password
}
const passwordCipher = sm4.encrypt(password, sm4KeyHex)
// sm2/sm4 默认输出是 hex但后端 gm.py/session.py 需要 base64
// - sm2_decrypt: base64.b64decode
// - sm4 decrypt: base64.urlsafe_b64decode
const keyCipherB64 = bytesToBase64(hexToBytes(keyCipher))
const passwordCipherB64 = bytesToBase64(hexToBytes(passwordCipher))
return `${keyCipherB64}:${passwordCipherB64}`
}
export function encryptPassword(password) {
if (!password) {
console.log('password is empty')
return ''
}
let publicKeyText = getCookie('jms_public_key')
if (!publicKeyText) {
console.log('publicKeyText is empty')
return password
}
publicKeyText = publicKeyText.replaceAll('"', '')
publicKeyText = atob(publicKeyText)
let cipher = ''
let jmsGMSSL = getCookie('jms_gm_ssl')
if (publicKeyText.includes('PUBLIC KEY')) {
jmsGMSSL = '0'
}
if (jmsGMSSL === '1') {
cipher = gmEncryptPassword(password, publicKeyText)
} else {
cipher = rsaEncryptPassword(password, publicKeyText)
}
return cipher
}
export function randomString(length) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
const charactersLength = characters.length
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}

View File

@@ -5,7 +5,7 @@
<script>
import { GenericCreateUpdatePage } from '@/layout/components'
import getChangeSecretFields from '@/views/accounts/AccountBackup/fields'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import { periodicMeta } from '@/components/const'
export default {
@@ -20,7 +20,8 @@ export default {
url: '/api/v1/accounts/account-backup-plans/',
fields: [
[this.$t('Basic'), ['name', 'types']],
[this.$t('Backup'),
[
this.$t('Backup'),
[
'backup_type',
'is_password_divided_by_email',
@@ -91,6 +92,4 @@ export default {
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@@ -1,14 +1,11 @@
<template>
<GenericCreateUpdatePage
v-bind="$data"
@getObjectDone="getObjectDone"
/>
<GenericCreateUpdatePage v-bind="$data" @getObjectDone="getObjectDone" />
</template>
<script>
import GenericCreateUpdatePage from '@/layout/components/GenericCreateUpdatePage'
import { templateFields, templateFieldsMeta } from './const.js'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
export default {
name: 'GatewayCreateUpdate',
@@ -27,7 +24,7 @@ export default {
return {
initial: {
secret_type: 'password',
push_params: { }
push_params: {}
},
url: '/api/v1/accounts/account-templates/',
hasDetailInMsg: false,

View File

@@ -4,7 +4,7 @@
<script>
import GenericCreateUpdatePage from '@/layout/components/GenericCreateUpdatePage'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
import { getUpdateObjURL, setUrlParam } from '@/utils/common/index'
import { assetFieldsMeta } from '@/views/assets/const'
@@ -38,7 +38,7 @@ export default {
},
updateInitial: {
type: Function,
default: (initial) => {
default: initial => {
return initial
}
}
@@ -82,7 +82,7 @@ export default {
delete values['accounts']
} else {
const accounts = values?.accounts || []
values.accounts = accounts.map((item) => {
values.accounts = accounts.map(item => {
item['secret'] = encryptPassword(item['secret'])
return item
})
@@ -179,5 +179,4 @@ export default {
}
</script>
<style>
</style>
<style></style>

View File

@@ -1,5 +1,5 @@
import i18n from '@/i18n/i18n'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
export const gcp = 'gcp'
export const aliyun = 'aliyun'
@@ -55,8 +55,18 @@ export const publicHostProviders = [
export const publicDBProviders = [aliyun]
export const privateCloudProviders = [
vmware, qingcloud_private, huaweicloud_private, ctyun_private,
openstack, zstack, nutanix, fc, scp, apsara_stack, smartx, proxmox
vmware,
qingcloud_private,
huaweicloud_private,
ctyun_private,
openstack,
zstack,
nutanix,
fc,
scp,
apsara_stack,
smartx,
proxmox
]
export const ACCOUNT_PROVIDER_ATTRS_MAP = {

View File

@@ -95,6 +95,7 @@ export default {
},
headerActions: {
hasLeftActions: false,
hasReportExport: true,
hasDatePicker: true,
hasImport: false,
searchConfig: {

View File

@@ -73,6 +73,7 @@ export default {
},
headerActions: {
hasLeftActions: false,
hasReportExport: true,
hasImport: false
}
}

View File

@@ -36,6 +36,7 @@ export default {
headerActions: {
hasLeftActions: false,
hasImport: false,
hasReportExport: true,
hasDatePicker: true,
searchConfig: {
getUrlQuery: true

View File

@@ -72,7 +72,11 @@ export default {
headerActions: {
hasLeftActions: false,
hasImport: false,
hasDatePicker: true
hasReportExport: true,
hasDatePicker: true,
searchConfig: {
getUrlQuery: true
}
}
}
}

View File

@@ -26,7 +26,11 @@ export default {
headerActions: {
hasLeftActions: false,
hasImport: false,
hasDatePicker: true
hasReportExport: true,
hasDatePicker: true,
searchConfig: {
getUrlQuery: true
}
}
}
}

View File

@@ -4,11 +4,18 @@
:title="title"
:nav="nav"
:name="name"
:charts="charts"
:tables="tables"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<ReportToolbar
:filters="currentFilters"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<template v-if="showChart">
<div class="chart-container full-width">
<div class="chart-container-title">
@@ -19,17 +26,6 @@
</div>
</div>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div v-if="!isCustomReport" class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('RiskyAccount') }}</div>
@@ -76,17 +72,6 @@
</div>
</el-card>
</div>
<ReportToolbar
v-if="tableData.length"
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap full-width">
<el-card class="report-card" shadow="hover">
<div v-if="t.name" class="chart-container-title">
@@ -101,16 +86,6 @@
</div>
</div>
<div v-else>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<el-table :data="tableData.rows" border>
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
@@ -151,6 +126,16 @@ export default {
return {
title: this.$t('AccountAutomationReport'),
name: 'AccountAutomationReport',
charts: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'TaskExecutionTrends', title: this.$t('TaskExecutionTrends') },
{ name: 'AccountResult', title: this.$t('AccountResult') }
],
tables: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'TaskExecutionTrends', title: this.$t('TaskExecutionTrends') },
{ name: 'AccountResult', title: this.$t('AccountResult') }
],
description: '-',
days: localStorage.getItem(this.name) || '7',
automation_stats: {
@@ -283,18 +268,10 @@ export default {
}
}
},
watch: {
days() {
this.getData()
}
},
async mounted() {
await this.getData()
},
methods: {
onChange(val) {
this.days = val
},
async getData() {
const data = await this.fetchReportData('/api/v1/reports/reports/account-automation/')
await this.loadTableData('/api/v1/reports/reports/account-automation/')

View File

@@ -4,11 +4,18 @@
:title="title"
:nav="nav"
:name="name"
:charts="charts"
:tables="tables"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<ReportToolbar
:filters="currentFilters"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<template v-if="showChart">
<div class="chart-container full-width">
<div class="chart-container-title">
@@ -19,17 +26,6 @@
</div>
</div>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div class="chart-container">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AccountCreationSourceDistribution') }}</div>
@@ -97,17 +93,6 @@
</div>
</el-card>
</div>
<ReportToolbar
v-if="tableData.length"
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap full-width">
<el-card class="report-card" shadow="hover">
<div v-if="t.name" class="chart-container-title">
@@ -128,16 +113,6 @@
</div>
</div>
<div v-else>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<el-table :data="tableData.rows" border>
<el-table-column
v-for="column in tableData.columns"
@@ -183,6 +158,22 @@ export default {
return {
title: this.$t('AccountStatisticsReport'),
name: 'AccountStatistics',
charts: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'AccountCreationSourceDistribution', title: this.$t('AccountCreationSourceDistribution') },
{ name: 'AccountConnectivityStatusDistribution', title: this.$t('AccountConnectivityStatusDistribution') },
{ name: 'AccountPasswordChangeTrends', title: this.$t('AccountPasswordChangeTrends') },
{ name: 'RankByNumberOfAssetAccounts', title: this.$t('RankByNumberOfAssetAccounts') },
{ name: 'AccountAndPasswordChangeRank', title: this.$t('AccountAndPasswordChangeRank') }
],
tables: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'AccountCreationSourceDistribution', title: this.$t('AccountCreationSourceDistribution') },
{ name: 'AccountConnectivityStatusDistribution', title: this.$t('AccountConnectivityStatusDistribution') },
{ name: 'AccountPasswordChangeTrends', title: this.$t('AccountPasswordChangeTrends') },
{ name: 'RankByNumberOfAssetAccounts', title: this.$t('RankByNumberOfAssetAccounts') },
{ name: 'AccountAndPasswordChangeRank', title: this.$t('AccountAndPasswordChangeRank') }
],
days: '30',
account_stats: {
'total': 0,

View File

@@ -38,14 +38,16 @@
<script>
import Page from '@/layout/components/Page'
import { resolveRoute } from '@/utils/vue/index'
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
import AccountStatistics from '@/views/reports/accounts/AccountStatistics.vue'
import AccountAutomation from '@/views/reports/accounts/AccountAutomation.vue'
import { appendQuery, buildCustomReportRouteQuery, reportDebugLog } from '@/views/reports/base/reportUtils'
const MENU_ITEMS = [
{
key: 'AccountStatistics',
titleKey: 'AccountStatisticsReport',
routeName: 'AccountStatistics',
component: AccountStatistics,
path: '/reports/accounts/account-statistics',
icon: 'fa fa-users',
perm: 'rbac.view_accountstatisticsreport',
reportType: 'AccountStatistics'
@@ -53,7 +55,8 @@ const MENU_ITEMS = [
{
key: 'AccountAutomationReport',
titleKey: 'AccountAutomationReport',
routeName: 'AccountAutomationReport',
component: AccountAutomation,
path: '/reports/accounts/account-automation',
icon: 'fa fa-cogs',
perm: 'rbac.view_accountautomationreport',
reportType: 'AccountAutomationReport'
@@ -72,17 +75,36 @@ export default {
component: '',
componentKey: '',
selectedChartKey: '',
chartItems: []
chartItems: [],
catalogLoaded: false,
lastSyncQueryKey: '',
isPageActive: true
}
},
watch: {
'$route.fullPath'() {
if (!this.isPageActive) return
const routeKey = this.buildRouteSyncKey(this.$route.query)
if (routeKey === this.lastSyncQueryKey) return
this.lastSyncQueryKey = routeKey
reportDebugLog('accounts.index.route.fullPath', {
routePath: this.$route.path,
query: this.$route.query,
selectedChartKey: this.selectedChartKey
})
this.syncSelectedFromRoute()
}
},
async created() {
await this.loadCatalog()
},
activated() {
this.isPageActive = true
this.syncSelectedFromRoute()
},
deactivated() {
this.isPageActive = false
},
methods: {
getBaseItems() {
return MENU_ITEMS
@@ -90,15 +112,20 @@ export default {
.map(item => ({
key: item.key,
title: this.$t(item.titleKey),
routeName: item.routeName,
component: item.component,
path: item.path,
icon: item.icon,
isCustom: false,
reportType: item.reportType,
query: {},
query: {
chart_key: item.key
},
children: []
}))
},
async loadCatalog() {
reportDebugLog('accounts.index.loadCatalog.start', { routePath: this.$route.path, query: this.$route.query })
this.catalogLoaded = false
const items = this.getBaseItems()
const itemMap = items.reduce((acc, item) => {
if (item.reportType) {
@@ -116,16 +143,24 @@ export default {
target.children = (group.children || []).map(child => ({
key: `report-${child.id}`,
title: child.name,
routeName: target.routeName,
reportId: child.id,
component: target.component,
path: target.path,
reportId: String(child.id),
isCustom: true,
query: { report_id: child.id }
query: {
chart_key: target.key,
...buildCustomReportRouteQuery(child)
}
}))
})
} catch (error) {
console.error('load report catalog failed', error)
}
this.chartItems = items
this.catalogLoaded = true
reportDebugLog('accounts.index.loadCatalog.done', {
items: items.map(item => ({ key: item.key, childCount: (item.children || []).length }))
})
this.syncSelectedFromRoute()
},
syncSelectedFromRoute() {
@@ -135,32 +170,75 @@ export default {
if (reportId) {
target = this.chartItems
.flatMap(item => item.children || [])
.find(item => item.reportId === reportId)
.find(item => String(item.reportId) === String(reportId))
if (!target) {
this.loadCatalog()
if (!this.catalogLoaded) {
return
}
const nextQuery = { ...(this.$route.query || {}) }
delete nextQuery.report_id
this.$router.replace({ path: this.$route.path, query: nextQuery })
return
}
}
if (!target) {
target = this.chartItems[0]
const chartKey = this.$route.query.chart_key
target = this.chartItems.find(item => item.key === chartKey)
|| this.chartItems.find(item => item.key === this.selectedChartKey)
|| this.chartItems[0]
}
if (target) {
if (target && (this.selectedChartKey !== target.key || !this.component)) {
this.applyChart(target)
}
reportDebugLog('accounts.index.syncSelectedFromRoute', {
reportId,
selectedChartKey: this.selectedChartKey,
targetKey: target?.key || ''
})
},
buildRouteSyncKey(query = {}) {
return JSON.stringify({
report_id: query.report_id || '',
chart_key: query.chart_key || '',
days: query.days || ''
})
},
isActive(item) {
return this.selectedChartKey === item.key
},
applyChart(chart) {
this.selectedChartKey = chart.key
const route = resolveRoute({ name: chart.routeName }, this.$router)
this.component = route.components.default
this.componentKey = `${chart.key}-${this.$route.fullPath}`
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
if (!chart.component || !chart.path) {
return
}
this.component = chart.component
this.componentKey = chart.key
this.url = appendQuery('/ui/#' + chart.path, chart.query || {})
reportDebugLog('accounts.index.applyChart', {
chartKey: chart.key,
reportId: chart.reportId || '',
url: this.url,
routePath: this.$route.path,
query: this.$route.query
})
},
handleChangeChart(chart) {
const nextQuery = chart.query || {}
if (isSameReportQuery(this.$route.query, nextQuery)) {
const nextQuery = {
...(this.$route.query.days ? { days: this.$route.query.days } : {}),
...(chart.query || {})
}
reportDebugLog('accounts.index.handleChangeChart', {
chartKey: chart.key,
reportId: chart.reportId || '',
nextQuery,
currentQuery: this.$route.query
})
const normalize = (query = {}) => ({
days: query.days || '',
report_id: query.report_id || '',
chart_key: query.chart_key || ''
})
if (JSON.stringify(normalize(this.$route.query)) === JSON.stringify(normalize(nextQuery))) {
this.applyChart(chart)
return
}
@@ -231,7 +309,7 @@ h5 {
}
}
.folder-list li.active menu-link {
.folder-list li.active > .menu-link {
color: var(--color-primary);
border-radius: 4px;
}

View File

@@ -4,11 +4,18 @@
:title="title"
:nav="nav"
:name="name"
:charts="charts"
:tables="tables"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<ReportToolbar
:filters="currentFilters"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<template v-if="showChart">
<div class="chart-container full-width">
<div class="chart-container-title">
@@ -19,16 +26,6 @@
</div>
</div>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div class="chart-container">
<div class="chart-container-title">
<UserAssetActivity :days="days" :metrics="user_asset_activity_metrics" />
@@ -98,16 +95,6 @@
</div>
</el-card>
</div>
<ReportToolbar
v-if="tableData.length"
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap">
<el-card class="report-card" shadow="hover">
<div v-if="t.name" class="chart-container-title">
@@ -122,15 +109,6 @@
</div>
</div>
<div v-else>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<el-table :data="tableData.rows" border>
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
@@ -170,6 +148,22 @@ export default {
return {
title: this.$t('AssetActivityReport'),
name: 'AssetReport',
charts: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'UserAssetActivity', title: this.$t('UserAssetActivity') },
{ name: 'DistributionOfAssetLoginMethods', title: this.$t('DistributionOfAssetLoginMethods') },
{ name: 'RemoteLoginProtocolUsageDistribution', title: this.$t('RemoteLoginProtocolUsageDistribution') },
{ name: 'OperatingSystemDistributionOfLoginAssets', title: this.$t('OperatingSystemDistributionOfLoginAssets') },
{ name: 'AssetLoginTrends', title: this.$t('AssetLoginTrends') }
],
tables: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'UserAssetActivity', title: this.$t('UserAssetActivity') },
{ name: 'DistributionOfAssetLoginMethods', title: this.$t('DistributionOfAssetLoginMethods') },
{ name: 'RemoteLoginProtocolUsageDistribution', title: this.$t('RemoteLoginProtocolUsageDistribution') },
{ name: 'OperatingSystemDistributionOfLoginAssets', title: this.$t('OperatingSystemDistributionOfLoginAssets') },
{ name: 'AssetLoginTrends', title: this.$t('AssetLoginTrends') }
],
days: localStorage.getItem(this.name) || '7',
session_stats: {
'total': 0,
@@ -431,18 +425,10 @@ export default {
}
}
},
watch: {
days() {
this.getData()
}
},
async mounted() {
await this.getData()
},
methods: {
onChange(val) {
this.days = val
},
conversionData(data) {
return data.map(item => {
return {

View File

@@ -4,11 +4,18 @@
:title="title"
:nav="nav"
:name="name"
:charts="charts"
:tables="tables"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
>
<div class="charts-grid">
<ReportToolbar
:filters="currentFilters"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<template v-if="showChart">
<div class="chart-container full-width">
<div class="chart-container-title">
@@ -19,17 +26,6 @@
</div>
</div>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div class="chart-container full-width">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('AssetTypeDistribution') }}</div>
@@ -74,17 +70,6 @@
</div>
</el-card>
</div>
<ReportToolbar
v-if="tableData.length"
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap full-width">
<el-card class="report-card" shadow="hover">
<div v-if="t.name" class="chart-container-title">
@@ -105,16 +90,6 @@
</div>
</div>
<div v-else>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
:show-date-controls="false"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<el-table :data="tableData.rows" border>
<el-table-column
v-for="column in tableData.columns"
@@ -158,6 +133,16 @@ export default {
return {
title: this.$t('AssetStatisticsReport'),
name: 'AssetStatistics',
charts: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'AssetTypeDistribution', title: this.$t('AssetTypeDistribution') },
{ name: 'WeeklyGrowthTrend', title: this.$t('WeeklyGrowthTrend') }
],
tables: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'AssetTypeDistribution', title: this.$t('AssetTypeDistribution') },
{ name: 'WeeklyGrowthTrend', title: this.$t('WeeklyGrowthTrend') }
],
days: '7',
asset_stats: {
'total': 0,

View File

@@ -38,14 +38,16 @@
<script>
import Page from '@/layout/components/Page'
import { resolveRoute } from '@/utils/vue/index'
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
import AssetActivity from '@/views/reports/assets/AssetActivity.vue'
import AssetStatistics from '@/views/reports/assets/AssetStatistics.vue'
import { appendQuery, buildCustomReportRouteQuery, reportDebugLog } from '@/views/reports/base/reportUtils'
const MENU_ITEMS = [
{
key: 'AssetStatistics',
titleKey: 'AssetStatisticsReport',
routeName: 'AssetStatistics',
component: AssetStatistics,
path: '/reports/assets/asset-statistics',
icon: 'fa fa-database',
perm: 'rbac.view_assetstatisticsreport',
reportType: 'AssetStatistics'
@@ -53,7 +55,8 @@ const MENU_ITEMS = [
{
key: 'AssetReport',
titleKey: 'AssetActivityReport',
routeName: 'AssetReport',
component: AssetActivity,
path: '/reports/assets/asset-activity',
icon: 'fa fa-exchange',
perm: 'rbac.view_assetactivityreport',
reportType: 'AssetReport'
@@ -72,17 +75,36 @@ export default {
component: '',
componentKey: '',
selectedChartKey: '',
chartItems: []
chartItems: [],
catalogLoaded: false,
lastSyncQueryKey: '',
isPageActive: true
}
},
watch: {
'$route.fullPath'() {
if (!this.isPageActive) return
const routeKey = this.buildRouteSyncKey(this.$route.query)
if (routeKey === this.lastSyncQueryKey) return
this.lastSyncQueryKey = routeKey
reportDebugLog('assets.index.route.fullPath', {
routePath: this.$route.path,
query: this.$route.query,
selectedChartKey: this.selectedChartKey
})
this.syncSelectedFromRoute()
}
},
async created() {
await this.loadCatalog()
},
activated() {
this.isPageActive = true
this.syncSelectedFromRoute()
},
deactivated() {
this.isPageActive = false
},
methods: {
getBaseItems() {
return MENU_ITEMS
@@ -90,15 +112,20 @@ export default {
.map(item => ({
key: item.key,
title: this.$t(item.titleKey),
routeName: item.routeName,
component: item.component,
path: item.path,
icon: item.icon,
isCustom: false,
reportType: item.reportType,
query: {},
query: {
chart_key: item.key
},
children: []
}))
},
async loadCatalog() {
reportDebugLog('assets.index.loadCatalog.start', { routePath: this.$route.path, query: this.$route.query })
this.catalogLoaded = false
const items = this.getBaseItems()
const itemMap = items.reduce((acc, item) => {
if (item.reportType) {
@@ -116,16 +143,24 @@ export default {
target.children = (group.children || []).map(child => ({
key: `report-${child.id}`,
title: child.name,
routeName: target.routeName,
reportId: child.id,
component: target.component,
path: target.path,
reportId: String(child.id),
isCustom: true,
query: { report_id: child.id }
query: {
chart_key: target.key,
...buildCustomReportRouteQuery(child)
}
}))
})
} catch (error) {
console.error('load report catalog failed', error)
}
this.chartItems = items
this.catalogLoaded = true
reportDebugLog('assets.index.loadCatalog.done', {
items: items.map(item => ({ key: item.key, childCount: (item.children || []).length }))
})
this.syncSelectedFromRoute()
},
syncSelectedFromRoute() {
@@ -135,32 +170,75 @@ export default {
if (reportId) {
target = this.chartItems
.flatMap(item => item.children || [])
.find(item => item.reportId === reportId)
.find(item => String(item.reportId) === String(reportId))
if (!target) {
this.loadCatalog()
if (!this.catalogLoaded) {
return
}
const nextQuery = { ...(this.$route.query || {}) }
delete nextQuery.report_id
this.$router.replace({ path: this.$route.path, query: nextQuery })
return
}
}
if (!target) {
target = this.chartItems[0]
const chartKey = this.$route.query.chart_key
target = this.chartItems.find(item => item.key === chartKey)
|| this.chartItems.find(item => item.key === this.selectedChartKey)
|| this.chartItems[0]
}
if (target) {
if (target && (this.selectedChartKey !== target.key || !this.component)) {
this.applyChart(target)
}
reportDebugLog('assets.index.syncSelectedFromRoute', {
reportId,
selectedChartKey: this.selectedChartKey,
targetKey: target?.key || ''
})
},
isActive(item) {
return this.selectedChartKey === item.key
},
applyChart(chart) {
this.selectedChartKey = chart.key
const route = resolveRoute({ name: chart.routeName }, this.$router)
this.component = route.components.default
this.componentKey = `${chart.key}-${this.$route.fullPath}`
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
if (!chart.component || !chart.path) {
return
}
this.component = chart.component
this.componentKey = chart.key
this.url = appendQuery('/ui/#' + chart.path, chart.query || {})
reportDebugLog('assets.index.applyChart', {
chartKey: chart.key,
reportId: chart.reportId || '',
url: this.url,
routePath: this.$route.path,
query: this.$route.query
})
},
buildRouteSyncKey(query = {}) {
return JSON.stringify({
report_id: query.report_id || '',
chart_key: query.chart_key || '',
days: query.days || ''
})
},
handleChangeChart(chart) {
const nextQuery = chart.query || {}
if (isSameReportQuery(this.$route.query, nextQuery)) {
const nextQuery = {
...(this.$route.query.days ? { days: this.$route.query.days } : {}),
...(chart.query || {})
}
reportDebugLog('assets.index.handleChangeChart', {
chartKey: chart.key,
reportId: chart.reportId || '',
nextQuery,
currentQuery: this.$route.query
})
const normalize = (query = {}) => ({
days: query.days || '',
report_id: query.report_id || '',
chart_key: query.chart_key || ''
})
if (JSON.stringify(normalize(this.$route.query)) === JSON.stringify(normalize(nextQuery))) {
this.applyChart(chart)
return
}
@@ -231,7 +309,7 @@ h5 {
}
}
.folder-list li.active .menu-link {
.folder-list li.active > .menu-link {
color: var(--color-primary);
border-radius: 4px;
}

View File

@@ -5,7 +5,11 @@
<Logo />
</div>
<RightAction
:chart-options="chartOptions"
:name="name"
:selected-chart-names="selectedChartNames"
:selected-table-names="selectedTableNames"
:table-options="tableOptions"
:title="title"
:show-operation-dropdown="!isCustomReportPage"
:force-default-actions="nav && isCustomReportPage"
@@ -21,7 +25,7 @@
[{{ new Date().toLocaleString() }}]
</span>
</div>
<div v-if="customizeMode" class="report-visibility-panel">
<div v-if="customizeMode || isCustomReportPage" class="report-visibility-panel">
<div class="report-visibility-row">
<el-checkbox :value="isDisplayModeEnabled('chart')" @change="handleModeToggle('chart', $event)">
{{ $t('ChartReport') }}
@@ -61,12 +65,16 @@
</div>
<div v-if="!nav" class="title-right">
<RightAction
:chart-options="chartOptions"
:name="name"
:title="title"
:editor-only="true"
:selected-chart-names="selectedChartNames"
:selected-table-names="selectedTableNames"
:show-editor-button="false"
:show-custom-actions-in-editor="isCustomReportPage"
:show-operation-only-in-editor="true"
:table-options="tableOptions"
/>
<span v-if="url && showReportExportBtn" class="export-btn inline-export-btn">
<el-button type="text" @click="openNewWindow">

View File

@@ -16,43 +16,47 @@
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="$t('ReportPeriodicExecution')">
<el-switch v-model="form.is_periodic" />
<el-form-item :label="$t('TimeRange')" prop="days">
<el-select v-model="form.days" style="width: 100%">
<el-option
v-for="option in presetOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<template v-if="form.is_periodic">
<el-form-item :label="$t('Interval')" prop="interval">
<el-input-number v-model="form.interval" :min="1" :precision="0" style="width: 100%" />
</el-form-item>
<el-form-item :label="$t('ChartReport')" prop="visibleCharts">
<el-checkbox-group v-model="form.visibleCharts">
<el-checkbox
v-for="item in normalizedChartOptions"
:key="item.name"
:label="item.name"
>
{{ item.title }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item :label="$t('Crontab')">
<CronTab v-model="form.crontab" />
<div class="form-help-text">{{ $t('ReportSchedulePriorityTip') }}</div>
</el-form-item>
<el-form-item :label="$t('ReportRecipientsLabel')" prop="recipients">
<Select2 v-model="form.recipients" v-bind="recipientSelect" />
<div class="form-help-text">{{ $t('ReportRecipientsTip') }}</div>
</el-form-item>
</template>
<el-form-item :label="$t('TableDetails')" prop="visibleTables">
<el-checkbox-group v-model="form.visibleTables">
<el-checkbox
v-for="item in normalizedTableOptions"
:key="item.name"
:label="item.name"
>
{{ item.title }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
</Dialog>
</template>
<script>
import Dialog from '@/components/Dialog'
import Select2 from '@/components/Form/FormFields/Select2.vue'
import CronTab from '@/components/Form/FormFields/CronTab/index.vue'
import { REPORT_PRESET_DAYS_MAP, REPORT_RANGE_PRESET_OPTIONS } from './reportUtils'
const FILTER_FIELD_MAP = {
UserLoginReport: 'user_id',
UserChangePasswordReport: 'user_id',
AssetStatistics: 'asset_id',
AssetReport: 'asset_id',
AccountStatistics: 'account',
AccountAutomationReport: 'account'
}
import { REPORT_RANGE_PRESET_OPTIONS, normalizeReportDays } from './reportUtils'
function getDefaultName(title) {
const now = new Date()
@@ -60,22 +64,10 @@ function getDefaultName(title) {
return `${title}-${date}`
}
function normalizeRecipients(value) {
if (Array.isArray(value)) {
return value
}
if (value && Array.isArray(value.ids)) {
return value.ids
}
return []
}
export default {
name: 'CreateReportDialog',
components: {
CronTab,
Dialog,
Select2
Dialog
},
props: {
visible: {
@@ -97,52 +89,33 @@ export default {
defaultDays: {
type: [String, Number],
default: '7'
},
chartOptions: {
type: Array,
default: () => []
},
tableOptions: {
type: Array,
default: () => []
},
defaultVisibleCharts: {
type: Array,
default: () => []
},
defaultVisibleTables: {
type: Array,
default: () => []
}
},
data() {
const validateCustomRange = (rule, value, callback) => {
if (this.form.range_preset !== 'custom') {
callback()
return
}
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) {
callback(new Error(this.$t('SelectStartAndEndDate')))
return
}
callback()
}
const validateRecipients = (rule, value, callback) => {
if (!this.form.is_periodic) {
callback()
return
}
if (!Array.isArray(value) || value.length === 0) {
callback(new Error(this.$t('PleaseSelectRecipients')))
return
}
callback()
}
const validateSchedule = (rule, value, callback) => {
if (!this.form.is_periodic) {
callback()
return
}
if (!this.form.interval && !this.form.crontab) {
callback(new Error(this.$t('RequireIntervalOrCrontabSetting')))
return
}
callback()
}
return {
submitting: false,
form: this.getInitialForm(),
presetOptions: REPORT_RANGE_PRESET_OPTIONS,
rules: {
name: [{ required: true, message: this.$t('ThisFieldIsRequired'), trigger: 'blur' }],
range_preset: [{ required: true, message: this.$t('PleaseSelectTimeRange'), trigger: 'change' }],
date_range: [{ validator: validateCustomRange, trigger: 'change' }],
interval: [{ validator: validateSchedule, trigger: 'change' }],
recipients: [{ validator: validateRecipients, trigger: 'change' }]
visibleCharts: [{ validator: this.validateVisibleReports, trigger: 'change' }],
visibleTables: [{ validator: this.validateVisibleReports, trigger: 'change' }]
}
}
},
@@ -155,62 +128,14 @@ export default {
this.$emit('update:visible', val)
}
},
filterField() {
return FILTER_FIELD_MAP[this.reportType] || ''
},
isEdit() {
return !!this.report?.id
},
filterLabel() {
return {
user_id: this.$t('UserFilterLabel'),
asset_id: this.$t('AssetFilterLabel'),
account: this.$t('AccountFilterLabel')
}[this.filterField] || ''
normalizedChartOptions() {
return this.normalizeOptions(this.chartOptions)
},
recipientSelect() {
return {
ajax: {
url: '/api/v1/users/users/?fields_size=mini',
transformOption: (item) => ({ label: `${item.name}(${item.username})`, value: item.id })
}
}
},
filterSelect() {
if (this.filterField === 'user_id') {
return {
multiple: false,
ajax: {
url: '/api/v1/users/users/suggestions/',
transformOption: (item) => ({ label: `${item.name}(${item.username})`, value: item.id })
}
}
}
if (this.filterField === 'asset_id') {
return {
multiple: false,
ajax: {
url: '/api/v1/assets/assets/?fields_size=mini',
transformOption: (item) => ({
label: item.name || item.address || item.hostname || item.id,
value: item.id
})
}
}
}
if (this.filterField === 'account') {
return {
multiple: false,
ajax: {
url: '/api/v1/accounts/accounts/?fields_size=mini',
transformOption: (item) => ({
label: item.asset ? `${item.username} @ ${item.asset.name}` : item.username,
value: item.username
})
}
}
}
return {}
normalizedTableOptions() {
return this.normalizeOptions(this.tableOptions)
}
},
watch: {
@@ -221,58 +146,61 @@ export default {
}
},
methods: {
normalizeDays(days) {
return normalizeReportDays(days, '7')
},
normalizeOptions(items = []) {
if (!Array.isArray(items)) {
return []
}
return items
.filter(item => item && item.name)
.map(item => ({
name: String(item.name),
title: String(item.title || item.name)
}))
},
normalizeSelection(raw, options = []) {
const safeOptions = Array.isArray(options) ? options : []
const optionNames = safeOptions.map(item => item.name)
const selected = Array.isArray(raw)
? raw.map(item => String(item).trim()).filter(Boolean)
: []
const filtered = selected.filter(name => optionNames.includes(name))
return filtered.length ? filtered : optionNames
},
validateVisibleReports(rule, value, callback) {
const total = (this.form.visibleCharts || []).length + (this.form.visibleTables || []).length
if (total <= 0) {
callback(new Error(this.$t('PleaseSelectAtLeastOneReportSection')))
return
}
callback()
},
getInitialForm() {
const report = this.report || {}
const reportDays = this.normalizeDays(report.days || this.defaultDays || '7')
const filters = report.filters || {}
const preset = Object.entries(REPORT_PRESET_DAYS_MAP).find(([, days]) => String(days) === String(this.defaultDays))
let filterValue = ''
if (this.filterField) {
// Only accept backend-provided user id options; ignore legacy username strings
if (report._filter_user_options && report._filter_user_options.user_id && report._filter_user_options.user_id.length) {
filterValue = report._filter_user_options.user_id[0].id
} else if (filters[this.filterField]) {
filterValue = filters[this.filterField]
} else {
filterValue = ''
}
}
const chartOptions = this.normalizedChartOptions
const tableOptions = this.normalizedTableOptions
return {
name: report.name || getDefaultName(this.reportTitle || this.reportType || 'report'),
range_preset: filters.range_preset || (filters.start && filters.end ? 'custom' : (preset ? preset[0] : 'last_week')),
date_range: filters.start && filters.end ? [filters.start, filters.end] : [],
filter_value: filterValue,
is_periodic: !!report.is_periodic,
interval: report.interval || 24,
crontab: report.crontab || '',
recipients: normalizeRecipients(report.recipients)
days: reportDays,
visibleCharts: this.normalizeSelection(filters.visible_charts || this.defaultVisibleCharts, chartOptions),
visibleTables: this.normalizeSelection(filters.visible_tables || this.defaultVisibleTables, tableOptions)
}
},
getPayload() {
const filters = {}
let rangeDays = REPORT_PRESET_DAYS_MAP[this.form.range_preset] || parseInt(this.defaultDays) || 7
if (this.form.range_preset === 'custom') {
filters.start = this.form.date_range[0]
filters.end = this.form.date_range[1]
filters.range_preset = ''
const start = new Date(`${filters.start}T00:00:00`)
const end = new Date(`${filters.end}T00:00:00`)
rangeDays = Math.max(1, Math.round((end - start) / 86400000) + 1)
} else {
filters.range_preset = this.form.range_preset
}
if (this.filterField && this.form.filter_value) {
filters[this.filterField] = this.form.filter_value
}
const rangeDays = parseInt(this.normalizeDays(this.form.days), 10)
return {
name: this.form.name,
tp: this.reportType,
is_active: true,
range_days: rangeDays,
filters,
is_periodic: this.form.is_periodic,
interval: this.form.is_periodic ? this.form.interval : null,
crontab: this.form.is_periodic ? (this.form.crontab || '') : '',
recipients: this.form.recipients.length > 0 ? { type: 'ids', ids: this.form.recipients } : {}
days: rangeDays,
filters: {
visible_charts: this.form.visibleCharts,
visible_tables: this.form.visibleTables
}
}
},
handleClose() {

View File

@@ -1,220 +0,0 @@
<template>
<div>
<Drawer
:title="`${reportName || $t('Report')} - ${$t('ExecutionRecords')}`"
:visible.sync="iVisible"
size="980px"
@close-drawer="handleClose"
>
<div v-loading="loading" class="report-execution-drawer">
<el-table :data="executions" border height="calc(100vh - 160px)">
<el-table-column :label="$t('ID')" min-width="180" prop="id" />
<el-table-column :label="$t('Status')" min-width="120" prop="status" />
<el-table-column :label="$t('Trigger')" min-width="120" prop="trigger" />
<el-table-column :label="$t('DateStart')" min-width="180" prop="date_start" />
<el-table-column :label="$t('DateFinished')" min-width="180" prop="date_finished" />
<el-table-column :label="$t('DurationSeconds')" min-width="100" prop="duration" />
<el-table-column :label="$t('SendRecords')" min-width="100">
<template slot-scope="{ row }">
{{ row.send_record_count || 0 }}
</template>
</el-table-column>
<el-table-column :label="$t('Actions')" min-width="120" fixed="right">
<template slot-scope="{ row }">
<el-button size="mini" type="text" @click="openDetail(row)">
{{ $t('Detail') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</Drawer>
<Dialog
v-if="detailVisible"
:destroy-on-close="true"
:show-confirm="false"
:title="detailTitle"
:visible.sync="detailVisible"
top="8vh"
width="900px"
@cancel="detailVisible = false"
>
<div v-loading="detailLoading">
<el-descriptions v-if="selectedExecution" :column="2" border class="detail-summary">
<el-descriptions-item :label="$t('ID')">{{ selectedExecution.id }}</el-descriptions-item>
<el-descriptions-item :label="$t('Status')">{{ selectedExecution.status }}</el-descriptions-item>
<el-descriptions-item :label="$t('Trigger')">{{ selectedExecution.trigger }}</el-descriptions-item>
<el-descriptions-item :label="$t('DurationSeconds')">{{ selectedExecution.duration || 0 }}</el-descriptions-item>
<el-descriptions-item :label="$t('DateStart')">{{ selectedExecution.date_start || '-' }}</el-descriptions-item>
<el-descriptions-item :label="$t('DateFinished')">{{ selectedExecution.date_finished || '-' }}</el-descriptions-item>
</el-descriptions>
<el-table :data="sendRecords" border max-height="360">
<el-table-column :label="$t('Receiver')" min-width="120" prop="receiver" />
<el-table-column :label="$t('Backend')" min-width="120" prop="backend" />
<el-table-column :label="$t('Result')" min-width="100">
<template slot-scope="{ row }">
<span :class="row.is_success ? 'text-primary' : 'text-danger'">
{{ row.is_success ? $t('Success') : $t('Failed') }}
</span>
</template>
</el-table-column>
<el-table-column :label="$t('CreatedTime')" min-width="180" prop="date_created" />
<el-table-column :label="$t('Actions')" min-width="100" fixed="right">
<template slot-scope="{ row }">
<el-button size="mini" type="text" @click="showRecordLog(row)">
{{ $t('Log') }}
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="selectedExecution && selectedExecution.summary" class="execution-summary">
<h4>{{ $t('ExecutionSummary') }}</h4>
<pre>{{ formatJson(selectedExecution.summary) }}</pre>
</div>
</div>
</Dialog>
<Dialog
v-if="logVisible"
:destroy-on-close="true"
:show-confirm="false"
:title="$t('ExecutionLog')"
:visible.sync="logVisible"
top="15vh"
width="760px"
@cancel="logVisible = false"
>
<pre class="log-detail">{{ selectedLog }}</pre>
</Dialog>
</div>
</template>
<script>
import Dialog from '@/components/Dialog'
import Drawer from '@/components/Drawer'
export default {
name: 'ReportExecutionDrawer',
components: {
Dialog,
Drawer
},
props: {
visible: {
type: Boolean,
default: false
},
reportId: {
type: String,
default: ''
},
reportName: {
type: String,
default: ''
}
},
data() {
return {
loading: false,
detailLoading: false,
detailVisible: false,
logVisible: false,
executions: [],
sendRecords: [],
selectedExecution: null,
selectedLog: ''
}
},
computed: {
iVisible: {
get() {
return this.visible
},
set(val) {
this.$emit('update:visible', val)
}
},
detailTitle() {
return this.selectedExecution ? `${this.$t('ExecutionDetail')} - ${this.selectedExecution.id}` : this.$t('ExecutionDetail')
}
},
watch: {
visible(val) {
if (val) {
this.loadExecutions()
}
}
},
methods: {
async loadExecutions() {
if (!this.reportId) {
return
}
this.loading = true
try {
const data = await this.$axios.get('/api/v1/reports/report-executions/', {
params: {
report: this.reportId
}
})
this.executions = data.results || data
} finally {
this.loading = false
}
},
async openDetail(row) {
this.detailVisible = true
this.detailLoading = true
this.selectedExecution = row
try {
const data = await this.$axios.get(`/api/v1/reports/report-executions/${row.id}/`)
this.selectedExecution = data
this.sendRecords = data.send_records || []
} finally {
this.detailLoading = false
}
},
showRecordLog(row) {
this.selectedLog = row.detail || row.error || '-'
this.logVisible = true
},
formatJson(value) {
return JSON.stringify(value || {}, null, 2)
},
handleClose() {
this.iVisible = false
}
}
}
</script>
<style lang="scss" scoped>
.report-execution-drawer {
padding: 16px;
}
.detail-summary {
margin-bottom: 16px;
}
.execution-summary {
margin-top: 16px;
pre {
background: #f5f7fa;
padding: 12px;
overflow: auto;
}
}
.log-detail {
background: #111827;
color: #f9fafb;
min-height: 220px;
overflow: auto;
padding: 16px;
}
</style>

View File

@@ -1,78 +1,35 @@
<template>
<div class="report-toolbar">
<div class="toolbar-left">
<template v-if="showDateControls">
<div class="toolbar-item">
<span class="toolbar-label">{{ $t('TimeRange') }}</span>
<el-select v-model="localRangePreset" size="mini" style="width: 130px" @change="emitChange">
<el-option v-for="option in presetOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<DatetimeRangePicker
v-if="localRangePreset === 'custom'"
:date-start="localDateRange?.[0]"
:date-end="localDateRange?.[1]"
@dateChange="onDateChange"
/>
</div>
</template>
<div v-if="filterField" class="toolbar-item">
<span class="toolbar-label">{{ filterLabel }}</span>
<Select2
v-model="localFilterValue"
v-bind="filterSelect"
style="width: 220px"
@change="emitChange"
/>
</div>
</div>
<SwitchDate :name="storageKey" :days="localDays" @change="emitChange" />
</div>
</template>
<script>
import Select2 from '@/components/Form/FormFields/Select2.vue'
import DatetimeRangePicker from '@/components/Form/FormFields/DatetimeRangePicker.vue'
import { REPORT_RANGE_PRESET_OPTIONS } from './reportUtils'
import SwitchDate from '@/components/Dashboard/SwitchDate.vue'
export default {
name: 'ReportToolbar',
components: {
Select2,
DatetimeRangePicker
SwitchDate
},
props: {
showDateControls: {
type: Boolean,
default: true
},
isCustomReport: {
type: Boolean,
default: false
},
filterField: {
type: String,
default: ''
},
filterLabel: {
type: String,
default: ''
},
filterSelect: {
type: Object,
default: () => ({})
},
filters: {
type: Object,
default: () => ({})
},
reportName: {
type: String,
default: 'reportDays'
}
},
data() {
return {
presetOptions: REPORT_RANGE_PRESET_OPTIONS,
localRangePreset: 'last_week',
localDateRange: [],
localFilterValue: ''
localDays: '7'
}
},
computed: {
storageKey() {
return this.reportName || this.$route?.name || 'reportDays'
}
},
watch: {
@@ -80,29 +37,14 @@ export default {
immediate: true,
deep: true,
handler(val) {
this.localRangePreset = val.range_preset || (val.start && val.end ? 'custom' : 'last_week')
this.localDateRange = val.start && val.end ? [new Date(val.start), new Date(val.end)] : []
this.localFilterValue = val.filter_value || ''
this.localDays = String(val.days || '7')
}
}
},
methods: {
onDateChange(val) {
this.localDateRange = val
this.emitChange()
},
emitChange() {
const payload = {
filter_value: this.localFilterValue
}
if (this.showDateControls) {
payload.range_preset = this.localRangePreset
if (this.localRangePreset === 'custom') {
payload.start = this.localDateRange?.[0] ? this.localDateRange[0].toISOString() : ''
payload.end = this.localDateRange?.[1] ? this.localDateRange[1].toISOString() : ''
}
}
this.$emit('filter-change', payload)
emitChange(days) {
this.localDays = String(days || '7')
this.$emit('filter-change', { days: this.localDays })
}
}
}
@@ -111,50 +53,8 @@ export default {
<style lang="scss" scoped>
.report-toolbar {
display: flex;
justify-content: flex-start;
gap: 10px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
width: 100%;
&.chart-container {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
max-width: none;
min-width: 0;
}
}
.toolbar-left {
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-start;
flex-wrap: wrap;
.toolbar-item {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
}
.toolbar-label {
color: #606266;
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
::v-deep {
.el-select,
.select2,
.el-date-editor {
min-height: 30px;
}
}
}
</style>
</style>

View File

@@ -8,8 +8,8 @@
<i class="el-icon-arrow-down el-icon--right" />
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="history">{{ $t('ExecutionHistory') }}</el-dropdown-item>
<el-dropdown-item v-if="canDeleteReport" command="delete" divided>{{ $t('Delete') }}</el-dropdown-item>
<el-dropdown-item v-if="canSaveReport" command="edit">{{ $t('Edit') }}</el-dropdown-item>
<el-dropdown-item v-if="canDeleteReport" :divided="canSaveReport" command="delete">{{ $t('Delete') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<template v-if="!showOperationOnlyInEditor || !editorOnly">
@@ -45,10 +45,14 @@
</el-button-group>
<CreateReportDialog
:chart-options="chartOptions"
:default-days="getDaysParam()"
:default-visible-charts="selectedChartNames"
:default-visible-tables="selectedTableNames"
:report="editingReport"
:report-title="title"
:report-type="name"
:table-options="tableOptions"
:visible.sync="showCreateDialog"
@created="handleCreated"
/>
@@ -58,20 +62,14 @@
:report-query="$route.query"
:visible.sync="showExportDialog"
/>
<ReportExecutionDrawer
:report-id="reportId"
:report-name="title"
:visible.sync="showExecutionDrawer"
/>
</div>
</template>
<script>
import { download } from '@/utils/common'
import CreateReportDialog from './CreateReportDialog.vue'
import ReportExecutionDrawer from './ReportExecutionDrawer.vue'
import ReportExportDialog from './ReportExportDialog.vue'
import { appendQuery, pickReportQuery } from './reportUtils'
import { appendQuery, pickReportQuery, buildCustomReportRouteQuery, normalizeReportDays, fetchReportDetailShared } from './reportUtils'
const REPORT_ACTION_PERM_MAP = {
UserLoginReport: {
@@ -104,7 +102,6 @@ export default {
name: 'RightAction',
components: {
CreateReportDialog,
ReportExecutionDrawer,
ReportExportDialog
},
props: {
@@ -139,6 +136,22 @@ export default {
forceDefaultActions: {
type: Boolean,
default: false
},
chartOptions: {
type: Array,
default: () => []
},
tableOptions: {
type: Array,
default: () => []
},
selectedChartNames: {
type: Array,
default: () => []
},
selectedTableNames: {
type: Array,
default: () => []
}
},
data() {
@@ -146,7 +159,6 @@ export default {
exportLoading: false,
reportData: null,
showCreateDialog: false,
showExecutionDrawer: false,
showExportDialog: false
}
},
@@ -187,43 +199,26 @@ export default {
},
editingReport() {
const query = this.$route.query || {}
const filters = {}
if (query.range_preset) {
filters.range_preset = query.range_preset
}
if (query.start) {
filters.start = query.start
}
if (query.end) {
filters.end = query.end
}
const key = this.filterField
const filterValue = key ? query[key] : ''
if (key && filterValue) {
filters[key] = filterValue
}
const reportDays = parseInt(normalizeReportDays(query.days || this.reportData?.days || this.getDaysParam(), '7'), 10)
if (this.isCustomReport) {
return {
...(this.reportData || {}),
days: reportDays,
filters: {
...(this.reportData?.filters || {}),
...filters
// 使用当前页面的勾选状态,而不是已保存的状态
visible_charts: this.selectedChartNames,
visible_tables: this.selectedTableNames
}
}
}
return {
filters
days: reportDays,
filters: {
visible_charts: this.selectedChartNames,
visible_tables: this.selectedTableNames
}
}
},
filterField() {
return {
UserLoginReport: 'user_id',
UserChangePasswordReport: 'user_id',
AssetStatistics: 'asset_id',
AssetReport: 'asset_id',
AccountStatistics: 'account',
AccountAutomationReport: 'account'
}[this.name] || ''
}
},
watch: {
@@ -240,7 +235,7 @@ export default {
},
methods: {
async loadReportDetail() {
this.reportData = await this.$axios.get(`/api/v1/reports/reports/${this.reportId}/`)
this.reportData = await fetchReportDetailShared(this.$axios, this.reportId)
},
checkName() {
if (!this.name) {
@@ -250,7 +245,7 @@ export default {
return true
},
getDaysParam() {
return this.$route.query.days || localStorage.getItem(this.name) || '7'
return normalizeReportDays(this.$route.query.days || localStorage.getItem(this.name), '7')
},
exportPdf() {
if (!this.checkName()) {
@@ -302,8 +297,8 @@ export default {
this.showCreateDialog = true
},
handleCommand(command) {
if (command === 'history') {
this.showExecutionDrawer = true
if (command === 'edit' && this.canSaveReport) {
this.openEditor()
return
}
if (command === 'delete' && this.canDeleteReport) {
@@ -320,11 +315,15 @@ export default {
this.$router.replace({ path: this.$route.path, query: {} })
},
handleCreated(report) {
const query = {
...buildCustomReportRouteQuery(report)
}
if (this.$route.query.chart_key) {
query.chart_key = this.$route.query.chart_key
}
this.$router.push({
path: this.$route.path,
query: {
report_id: report.id
}
query
})
}
}

View File

@@ -1,13 +1,4 @@
import { appendQuery, pickReportQuery } from './reportUtils'
const FILTER_FIELD_MAP = {
UserLoginReport: 'user_id',
UserChangePasswordReport: 'user_id',
AssetReport: 'asset_id',
AssetStatistics: 'asset_id',
AccountStatistics: 'account',
AccountAutomationReport: 'account'
}
import { appendQuery, normalizeReportDays, normalizeVisibleFilterList, pickReportQuery, reportDebugLog, fetchReportDetailShared } from './reportUtils'
const TABLE_LABEL_KEY_MAP = {
user_stats: 'Overview',
@@ -18,6 +9,9 @@ const TABLE_LABEL_KEY_MAP = {
user_login_time_metrics: 'VisitTimeDistribution',
session_stats: 'Overview',
asset_login_log_metrics: 'AssetLoginTrends',
asset_login_by_type: 'OperatingSystemDistributionOfLoginAssets',
asset_login_by_from: 'DistributionOfAssetLoginMethods',
asset_login_by_protocol: 'RemoteLoginProtocolUsageDistribution',
user_asset_activity_metrics: 'UserAssetActivity',
asset_stats: 'Overview',
assets_by_type_category: 'AssetTypeDistribution',
@@ -73,17 +67,44 @@ export default {
reportDetail: null,
displayMode: ['chart', 'table'],
// now supports multiple tables: array of { name, columns, rows }
tableData: []
tableData: [],
reportFetchInFlight: Object.create(null),
reportFetchCache: Object.create(null),
lastGetDataRouteKey: '',
lastFetchedReportId: ''
}
},
watch: {
'$route.fullPath'() {
const routeKey = this.buildGetDataRouteKey(this.$route.query)
if (routeKey === this.lastGetDataRouteKey) {
return
}
// reportId 变了说明切换到了不同的报告,父组件会改变 :key 导致本组件被销毁并重建
// 新组件的 mounted() 会负责初始化加载,此处不重复 getData
if (this.reportId !== this.lastFetchedReportId) {
this.lastGetDataRouteKey = routeKey
this.lastFetchedReportId = this.reportId
return
}
this.lastGetDataRouteKey = routeKey
this.lastFetchedReportId = this.reportId
reportDebugLog('mixin.route.fullPath', {
name: this.name,
routePath: this.$route.path,
query: this.$route.query
})
this.reportDetail = null
if (typeof this.getData === 'function') {
this.getData()
}
}
},
created() {
// 预填当前路由 key防止 mounted() 调用 getData 后route watcher 对同一 key 重复触发
this.lastGetDataRouteKey = this.buildGetDataRouteKey(this.$route.query)
this.lastFetchedReportId = this.reportId
},
computed: {
displayModes() {
const modes = Array.isArray(this.displayMode) ? this.displayMode : [this.displayMode]
@@ -107,27 +128,48 @@ export default {
reportTitle() {
return this.reportDetail?.name || this.title
},
filterField() {
return FILTER_FIELD_MAP[this.name] || ''
},
filterLabel() {
return {
user_id: this.$t('UserFilterLabel'),
asset_id: this.$t('AssetFilterLabel'),
account: this.$t('AccountFilterLabel')
}[this.filterField] || ''
},
currentFilters() {
const reportFilters = this.reportDetail?.filters || {}
const reportDays = this.reportDetail?.days
const fallbackDays = this.days || reportDays || 7
return {
range_preset: this.$route.query.range_preset || reportFilters.range_preset || '',
start: this.$route.query.start || reportFilters.start || '',
end: this.$route.query.end || reportFilters.end || '',
filter_value: this.filterField ? (this.$route.query[this.filterField] || reportFilters[this.filterField] || '') : ''
days: normalizeReportDays(this.$route.query.days || fallbackDays, '7')
}
}
},
methods: {
buildGetDataRouteKey(query = {}) {
return JSON.stringify({
path: this.$route.path,
report_id: query.report_id || '',
days: query.days || '',
chart_key: query.chart_key || '',
visible_charts: query.visible_charts || '',
visible_tables: query.visible_tables || ''
})
},
async fetchWithDedupe(url) {
const now = Date.now()
const cached = this.reportFetchCache[url]
// Reuse recent same-url result to absorb rapid duplicate triggers.
if (cached && (now - cached.ts) < 600) {
reportDebugLog('mixin.fetch.cacheHit', { name: this.name, requestUrl: url })
return cached.data
}
if (this.reportFetchInFlight[url]) {
reportDebugLog('mixin.fetch.inFlightJoin', { name: this.name, requestUrl: url })
return this.reportFetchInFlight[url]
}
const request = this.$axios.get(url)
.then((res) => {
this.reportFetchCache[url] = { ts: Date.now(), data: res }
return res
})
.finally(() => {
delete this.reportFetchInFlight[url]
})
this.reportFetchInFlight[url] = request
return request
},
async ensureReportDetail(reportId = this.reportId) {
if (!reportId) {
return null
@@ -135,7 +177,7 @@ export default {
if (this.reportDetail?.id === reportId) {
return this.reportDetail
}
const data = await this.$axios.get(`/api/v1/reports/reports/${reportId}/`)
const data = await fetchReportDetailShared(this.$axios, reportId)
if (this.reportId !== reportId) {
return data
}
@@ -147,7 +189,7 @@ export default {
},
buildTemplateUrl(baseUrl) {
const query = pickReportQuery(this.$route.query)
if (!query.start && !query.end && !query.range_preset && this.days) {
if (!query.days && this.days) {
query.days = this.days
}
return appendQuery(baseUrl, query)
@@ -158,11 +200,32 @@ export default {
if (reportId) {
await this.ensureReportDetail(reportId)
if (this.reportId !== reportId) {
reportDebugLog('mixin.fetch.retry', {
name: this.name,
fromReportId: reportId,
toReportId: this.reportId,
routePath: this.$route.path,
query: this.$route.query
})
return this.fetchReportData(baseUrl)
}
return this.$axios.get(appendQuery(`/api/v1/reports/reports/${reportId}/data/`, query))
const requestUrl = appendQuery(`/api/v1/reports/reports/${reportId}/data/`, query)
reportDebugLog('mixin.fetch.custom', {
name: this.name,
requestUrl,
routePath: this.$route.path,
query: this.$route.query
})
return this.fetchWithDedupe(requestUrl)
}
return this.$axios.get(this.buildTemplateUrl(baseUrl))
const requestUrl = this.buildTemplateUrl(baseUrl)
reportDebugLog('mixin.fetch.template', {
name: this.name,
requestUrl,
routePath: this.$route.path,
query: this.$route.query
})
return this.fetchWithDedupe(requestUrl)
},
async loadTableData(baseUrl) {
const buildLabel = (k) => {
@@ -294,61 +357,32 @@ export default {
return
}
},
handleToolbarFilterChange({ range_preset, start, end, filter_value }) {
handleToolbarFilterChange({ days }) {
const query = {}
if (this.$route.query.chart_key) {
query.chart_key = this.$route.query.chart_key
}
if (this.reportId) {
query.report_id = this.reportId
}
if (range_preset && range_preset !== 'custom') {
query.range_preset = range_preset
if (days) {
query.days = normalizeReportDays(days, '7')
}
if (range_preset === 'custom') {
query.start = start
query.end = end
const visibleCharts = normalizeVisibleFilterList(this.$route.query.visible_charts || this.reportDetail?.filters?.visible_charts)
const visibleTables = normalizeVisibleFilterList(this.$route.query.visible_tables || this.reportDetail?.filters?.visible_tables)
if (visibleCharts.length) {
query.visible_charts = visibleCharts.join(',')
}
if (this.filterField && filter_value) {
query[this.filterField] = filter_value
if (visibleTables.length) {
query.visible_tables = visibleTables.join(',')
}
if (this.days !== undefined && days) {
this.days = normalizeReportDays(days, '7')
}
if (this.buildGetDataRouteKey(this.$route.query) === this.buildGetDataRouteKey(query)) {
return
}
this.$router.replace({ path: this.$route.path, query })
},
getFilterSelect() {
if (this.filterField === 'user_id') {
return {
multiple: false,
ajax: {
url: '/api/v1/users/users/suggestions/',
transformOption: (item) => ({ label: `${item.name}(${item.username})`, value: item.id })
}
}
}
if (this.filterField === 'asset_id') {
return {
multiple: false,
ajax: {
url: '/api/v1/assets/assets/?fields_size=mini',
transformOption: (item) => ({ label: item.name || item.address || item.hostname || item.id, value: item.id })
}
}
}
if (this.filterField === 'account') {
return {
multiple: false,
ajax: {
url: '/api/v1/accounts/accounts/?fields_size=mini',
transformOption: (item) => ({ label: item.asset ? `${item.username} @ ${item.asset.name}` : item.username, value: item.username })
}
}
}
return {}
},
async handleDeleteReport() {
if (!this.reportId) {
return
}
await this.$confirm(this.$t('ConfirmDeleteReport'), this.$t('Tip'), { type: 'warning' })
await this.$axios.delete(`/api/v1/reports/reports/${this.reportId}/`)
this.$message.success(this.$t('DeleteSuccessMsg'))
this.$router.replace({ path: this.$route.path, query: {} })
}
}
}

View File

@@ -1,22 +1,17 @@
import i18n from '@/i18n/i18n'
export const REPORT_DEBUG_SWITCH_KEY = '__REPORT_DEBUG_SWITCH__'
export const REPORT_RANGE_PRESET_OPTIONS = [
{ label: i18n.t('LastDay'), value: 'last_day', days: 1 },
{ label: i18n.t('Last7Days'), value: 'last_week', days: 7 },
{ label: i18n.t('Last30Days'), value: 'last_month', days: 30 },
{ label: i18n.t('LastThreeMonths'), value: 'last_three_months', days: 90 },
{ label: i18n.t('LastHalfYear'), value: 'last_half_year', days: 180 },
{ label: i18n.t('LastYear'), value: 'last_year', days: 365 },
{ label: i18n.t('Custom'), value: 'custom', days: null }
{ label: i18n.t('Today'), value: '1', days: 1 },
{ label: i18n.t('Last7Days'), value: '7', days: 7 },
{ label: i18n.t('Last30Days'), value: '30', days: 30 }
]
export const REPORT_ALLOWED_DAYS = REPORT_RANGE_PRESET_OPTIONS.map(item => String(item.value))
export const REPORT_FILTER_QUERY_KEYS = [
'start',
'end',
'range_preset',
'user_id',
'asset_id',
'account',
'days',
'report_id'
]
@@ -57,4 +52,89 @@ export function appendQuery(url, query = {}) {
export function getPresetLabel(value) {
const preset = REPORT_RANGE_PRESET_OPTIONS.find(item => item.value === value)
return preset ? preset.label : value
}
}
export function normalizeReportDays(value, fallback = '7') {
const normalizedFallback = REPORT_ALLOWED_DAYS.includes(String(fallback)) ? String(fallback) : '7'
const normalizedValue = String(value || '')
if (REPORT_ALLOWED_DAYS.includes(normalizedValue)) {
return normalizedValue
}
return normalizedFallback
}
export function normalizeVisibleFilterList(value) {
if (Array.isArray(value)) {
return value.map(item => String(item).trim()).filter(Boolean)
}
if (value === undefined || value === null || value === '') {
return []
}
return String(value)
.split(',')
.map(item => item.trim())
.filter(Boolean)
}
export function buildCustomReportRouteQuery(report = {}) {
const normalizedDays = normalizeReportDays(report.days, '7')
const query = {
report_id: report.id,
days: normalizedDays
}
const filters = report.filters || {}
const visibleCharts = normalizeVisibleFilterList(filters.visible_charts)
const visibleTables = normalizeVisibleFilterList(filters.visible_tables)
if (visibleCharts.length) {
query.visible_charts = visibleCharts.join(',')
}
if (visibleTables.length) {
query.visible_tables = visibleTables.join(',')
}
return query
}
export function isReportDebugEnabled() {
try {
const val = localStorage.getItem(REPORT_DEBUG_SWITCH_KEY)
return val === '1' || val === 'true'
} catch (e) {
return false
}
}
export function reportDebugLog(scope, payload = {}) {
if (!isReportDebugEnabled()) {
return
}
console.log(`[report-debug:${scope}]`, payload)
}
// 模块级 report detail 请求去重缓存
// 多个组件实例reportPageMixin + RightAction可能同时请求同一 reportId
// 通过此缓存保证同一 URL 在短时间内只发一次 HTTP 请求
const _reportDetailInFlight = Object.create(null)
const _reportDetailCache = Object.create(null)
const REPORT_DETAIL_CACHE_TTL = 1500
export function fetchReportDetailShared(axios, reportId) {
const url = `/api/v1/reports/reports/${reportId}/`
const now = Date.now()
const cached = _reportDetailCache[url]
if (cached && (now - cached.ts) < REPORT_DETAIL_CACHE_TTL) {
return Promise.resolve(cached.data)
}
if (_reportDetailInFlight[url]) {
return _reportDetailInFlight[url]
}
const request = axios.get(url)
.then((res) => {
_reportDetailCache[url] = { ts: Date.now(), data: res }
return res
})
.finally(() => {
delete _reportDetailInFlight[url]
})
_reportDetailInFlight[url] = request
return request
}

View File

@@ -4,6 +4,8 @@
:title="title"
:nav="nav"
:name="name"
:charts="charts"
:tables="tables"
:show-display-mode-toggle="true"
:display-mode.sync="displayMode"
v-bind="$attrs"
@@ -20,11 +22,7 @@
</div>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
@@ -70,16 +68,6 @@
</el-table>
</div>
</div>
<ReportToolbar
v-if="tableData.length"
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div v-for="(t, idx) in tableData.slice(1)" :key="t.name || idx" class="report-table-wrap chart-container full-width">
<div v-if="t.name" class="chart-container-title">
<div class="chart-container-title-text">{{ t.name }}</div>
@@ -92,15 +80,6 @@
</div>
</div>
<div v-else>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<el-table :data="tableData.rows" border>
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
@@ -140,6 +119,18 @@ export default {
return {
title: this.$t('UserChangePasswordReport'),
name: 'UserChangePasswordReport',
charts: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'UserModificationTrends', title: this.$t('UserModificationTrends') },
{ name: 'ModifyTheTargetUserTopTank', title: this.$t('ModifyTheTargetUserTopTank') },
{ name: 'TopRankOfOperateUsers', title: this.$t('TopRankOfOperateUsers') }
],
tables: [
{ name: 'Overview', title: this.$t('Overview') },
{ name: 'UserModificationTrends', title: this.$t('UserModificationTrends') },
{ name: 'ModifyTheTargetUserTopTank', title: this.$t('ModifyTheTargetUserTopTank') },
{ name: 'TopRankOfOperateUsers', title: this.$t('TopRankOfOperateUsers') }
],
days: localStorage.getItem(this.name) || '7',
total_count_change_password: {
'total': 0,
@@ -317,18 +308,10 @@ export default {
}
}
},
watch: {
days() {
this.getData()
}
},
async mounted() {
await this.getData()
},
methods: {
onChange(val) {
this.days = val
},
async getData() {
const data = await this.fetchReportData('/api/v1/reports/reports/user-change-password/')
await this.loadTableData('/api/v1/reports/reports/user-change-password/')

View File

@@ -10,7 +10,7 @@
>
<template #default>
<div class="charts-grid">
<div class="chart-container full-width" data-report-type="chart" data-report-name="UserLoginTrends">
<div class="chart-container full-width" data-report-type="chart" data-report-name="Overview">
<div class="chart-container-title">
<div class="chart-container-title-text">{{ $t('Overview') }}</div>
<SummaryCountCard
@@ -20,11 +20,7 @@
</div>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
@@ -95,16 +91,6 @@
<el-table-column v-for="column in tableData[0].columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
</div>
<ReportToolbar
v-if="tableData.length"
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<div
v-for="(t, idx) in tableData.slice(1)"
:key="t.name || idx"
@@ -121,15 +107,6 @@
</div>
</div>
<div v-else>
<ReportToolbar
:filter-field="filterField"
:filter-label="filterLabel"
:filter-select="getFilterSelect()"
:filters="currentFilters"
:is-custom-report="isCustomReport"
class="chart-container full-width report-toolbar-wrap"
@filter-change="handleToolbarFilterChange"
/>
<el-table :data="tableData.rows" border>
<el-table-column v-for="column in tableData.columns" :key="column.key" :label="column.label" :prop="column.key" min-width="140" />
</el-table>
@@ -168,6 +145,10 @@ export default {
title: this.$t('UserLoginReport'),
name: 'UserLoginReport',
charts: [
{
name: 'Overview',
title: this.$t('Overview')
},
{
name: 'UserLoginTrends',
title: this.$t('UserLoginTrends')
@@ -190,23 +171,22 @@ export default {
name: 'Overview',
title: this.$t('Overview')
},
{
name: 'LoginSource',
title: this.$t('LoginSource')
},
{
name: 'UserLoginTrends',
title: this.$t('UserLoginTrends')
},
{
name: 'LoginMethodStatistics',
title: this.$t('LoginMethodStatistics')
name: 'LoginSource',
title: this.$t('LoginSource')
},
{
name: 'VisitTimeDistribution',
title: this.$t('VisitTimeDistribution')
},
{
name: 'LoginMethodStatistics',
title: this.$t('LoginMethodStatistics')
}
],
days: localStorage.getItem(this.name) || '7',
user_stats: {
@@ -510,19 +490,10 @@ export default {
}
}
},
watch: {
days() {
this.getData()
}
},
async mounted() {
await this.getData()
},
methods: {
onChange(val) {
this.days = val
localStorage.setItem('reportDays', val)
},
async getData() {
const data = await this.fetchReportData('/api/v1/reports/reports/users/')
await this.loadTableData('/api/v1/reports/reports/users/')

View File

@@ -38,20 +38,25 @@
<script>
import Page from '@/layout/components/Page'
import { resolveRoute } from '@/utils/vue/index'
import { appendQuery, isSameReportQuery } from '@/views/reports/base/reportUtils'
import UserActivity from '@/views/reports/users/UserActivity.vue'
import ChangePassword from '@/views/reports/users/ChangePassword.vue'
import { appendQuery, buildCustomReportRouteQuery, reportDebugLog } from '@/views/reports/base/reportUtils'
const TEMPLATE_ROUTE_MAP = {
UserLoginReport: {
name: 'UserReport',
key: 'UserReport',
titleKey: 'UserLoginReport',
icon: 'fa fa-sign-in',
component: UserActivity,
path: '/reports/users/user-activity',
perm: 'rbac.view_userloginreport'
},
UserChangePasswordReport: {
name: 'ChangePassword',
key: 'ChangePassword',
titleKey: 'UserChangePasswordReport',
icon: 'fa fa-key',
component: ChangePassword,
path: '/reports/users/change-password',
perm: 'rbac.view_userchangepasswordreport'
}
}
@@ -68,33 +73,68 @@ export default {
component: '',
componentKey: '',
selectedChartKey: '',
chartItems: []
chartItems: [],
catalogLoaded: false,
lastSyncQueryKey: '',
isPageActive: true
}
},
watch: {
'$route.fullPath'() {
if (!this.isPageActive) return
const routeKey = this.buildRouteSyncKey(this.$route.query)
if (routeKey === this.lastSyncQueryKey) {
return
}
this.lastSyncQueryKey = routeKey
reportDebugLog('users.index.route.fullPath', {
routePath: this.$route.path,
query: this.$route.query,
selectedChartKey: this.selectedChartKey
})
this.syncSelectedFromRoute()
}
},
async created() {
await this.loadCatalog()
},
activated() {
this.isPageActive = true
this.syncSelectedFromRoute()
},
deactivated() {
this.isPageActive = false
},
methods: {
buildRouteSyncKey(query = {}) {
return JSON.stringify({
report_id: query.report_id || '',
chart_key: query.chart_key || '',
days: query.days || '',
visible_charts: query.visible_charts || '',
visible_tables: query.visible_tables || ''
})
},
getBuiltInTemplates() {
return Object.entries(TEMPLATE_ROUTE_MAP)
.filter(([, item]) => this.$hasPerm(item.perm))
.map(([reportType, item]) => ({
key: reportType,
key: item.key,
reportType,
title: this.$t(item.titleKey),
routeName: item.name,
component: item.component,
path: item.path,
icon: item.icon,
isCustom: false,
query: {},
query: {
chart_key: item.key
},
children: []
}))
},
async loadCatalog() {
reportDebugLog('users.index.loadCatalog.start', { routePath: this.$route.path, query: this.$route.query })
this.catalogLoaded = false
const templates = this.getBuiltInTemplates()
const chartMap = templates.reduce((acc, item) => {
acc[item.reportType] = item
@@ -110,16 +150,24 @@ export default {
target.children = (group.children || []).map(child => ({
key: `report-${child.id}`,
title: child.name,
routeName: target.routeName,
reportId: child.id,
component: target.component,
path: target.path,
reportId: String(child.id),
isCustom: true,
query: { report_id: child.id }
query: {
chart_key: target.key,
...buildCustomReportRouteQuery(child)
}
}))
})
} catch (error) {
console.error('load report catalog failed', error)
}
this.chartItems = templates
this.catalogLoaded = true
reportDebugLog('users.index.loadCatalog.done', {
items: templates.map(item => ({ key: item.key, childCount: (item.children || []).length }))
})
this.syncSelectedFromRoute()
},
syncSelectedFromRoute() {
@@ -129,32 +177,74 @@ export default {
if (reportId) {
target = this.chartItems
.flatMap(item => item.children || [])
.find(item => item.reportId === reportId)
.find(item => String(item.reportId) === String(reportId))
if (!target) {
this.loadCatalog()
if (!this.catalogLoaded) {
return
}
const nextQuery = { ...(this.$route.query || {}) }
delete nextQuery.report_id
this.$router.replace({ path: this.$route.path, query: nextQuery })
return
}
}
if (!target) {
target = this.chartItems[0]
const chartKey = this.$route.query.chart_key
target = this.chartItems.find(item => item.key === chartKey)
|| this.chartItems.find(item => item.key === this.selectedChartKey)
|| this.chartItems[0]
}
if (target) {
if (target && (this.selectedChartKey !== target.key || !this.component)) {
this.applyChart(target)
}
reportDebugLog('users.index.syncSelectedFromRoute', {
reportId,
selectedChartKey: this.selectedChartKey,
targetKey: target?.key || ''
})
},
isActive(item) {
return this.selectedChartKey === item.key
},
applyChart(chart) {
this.selectedChartKey = chart.key
const route = resolveRoute({ name: chart.routeName }, this.$router)
this.component = route.components.default
this.componentKey = `${chart.key}-${this.$route.fullPath}`
this.url = appendQuery('/ui/#' + route.path, chart.query || {})
if (!chart.component || !chart.path) {
return
}
const nextUrl = appendQuery('/ui/#' + chart.path, chart.query || {})
if (this.component === chart.component && this.componentKey === chart.key && this.url === nextUrl) {
return
}
this.component = chart.component
this.componentKey = chart.key
this.url = nextUrl
reportDebugLog('users.index.applyChart', {
chartKey: chart.key,
reportId: chart.reportId || '',
url: this.url,
routePath: this.$route.path,
query: this.$route.query
})
},
handleChangeChart(chart) {
const nextQuery = chart.query || {}
if (isSameReportQuery(this.$route.query, nextQuery)) {
const nextQuery = {
...(this.$route.query.days ? { days: this.$route.query.days } : {}),
...(chart.query || {})
}
reportDebugLog('users.index.handleChangeChart', {
chartKey: chart.key,
reportId: chart.reportId || '',
nextQuery,
currentQuery: this.$route.query
})
const normalize = (query = {}) => ({
days: query.days || '',
report_id: query.report_id || '',
chart_key: query.chart_key || '',
visible_charts: query.visible_charts || '',
visible_tables: query.visible_tables || ''
})
if (JSON.stringify(normalize(this.$route.query)) === JSON.stringify(normalize(nextQuery))) {
this.applyChart(chart)
return
}
@@ -225,7 +315,7 @@ h5 {
}
}
.folder-list li.active .menu-link {
.folder-list li.active > .menu-link {
color: var(--color-primary);
border-radius: 4px;
}

View File

@@ -69,7 +69,11 @@ export default {
headerActions: {
hasLeftActions: false,
hasImport: false,
hasDatePicker: true
hasReportExport: true,
hasDatePicker: true,
searchConfig: {
getUrlQuery: true
}
}
}
}

View File

@@ -13,6 +13,7 @@
import DetailCard from '@/components/Cards/DetailCard/index'
import { QuickActions } from '@/components'
import { terminateSession } from '@/api/sessions'
import { addBasePath } from '@/utils/common'
import { toSafeLocalDateStr } from '@/utils/common/time'
import TwoCol from '@/layout/components/Page/TwoColPage.vue'
@@ -70,7 +71,7 @@ export default {
click: function() {
// 跳转到luna页面
const joinUrl = '/luna/monitor/' + vm.session.id
window.open(joinUrl, 'height=600, width=800, top=400, left=400, toolbar=no, menubar=no, scrollbars=no, location=no, status=no')
window.open(addBasePath(joinUrl), 'height=600, width=800, top=400, left=400, toolbar=no, menubar=no, scrollbars=no, location=no, status=no')
}
}
}
@@ -89,7 +90,7 @@ export default {
callbacks: {
click: function() {
const replayUrl = '/luna/replay/' + vm.session.id
window.open(replayUrl)
window.open(addBasePath(replayUrl))
}
}
},

View File

@@ -182,6 +182,7 @@ export default {
headerActions: {
hasLeftActions: false,
hasImport: false,
hasReportExport: true,
hasDatePicker: true,
searchConfig: {
getUrlQuery: false,

View File

@@ -4,7 +4,7 @@
<script>
import BaseList from './BaseList'
import { download } from '@/utils/common/index'
import { addBasePath, download } from '@/utils/common/index'
export default {
name: 'OfflineList',
@@ -30,7 +30,7 @@ export default {
callback: function({ row, tableData }) {
// 跳转到luna页面
const replayUrl = '/luna/replay/' + row.id
window.open(replayUrl)
window.open(addBasePath(replayUrl))
}
},
{

View File

@@ -5,6 +5,7 @@
<script>
import BaseList from './BaseList'
import { terminateSession, toggleLockSession } from '@/api/sessions'
import { addBasePath } from '@/utils/common/index'
import { IsSupportPauseSessionType } from '@/utils/jms/index'
export default {
@@ -97,7 +98,7 @@ export default {
},
callback: function({ row, tableData }) {
const monitorUrl = '/luna/monitor/' + row.id
window.open(monitorUrl, '_blank', 'height=600, width=850, top=400, left=400, toolbar=no, menubar=no, scrollbars=no, location=no, status=no')
window.open(addBasePath(monitorUrl), '_blank', 'height=600, width=850, top=400, left=400, toolbar=no, menubar=no, scrollbars=no, location=no, status=no')
}
}
],

View File

@@ -29,6 +29,7 @@ export default {
[
this.$t('Basic'),
[
'SECURITY_PASSWORD_EXPIRATION_TIME_ADMIN',
'SECURITY_PASSWORD_EXPIRATION_TIME',
'OLD_PASSWORD_HISTORY_LIMIT_COUNT',
'SECURITY_EXPIRED_TOKEN_RECORD_KEEP_DAYS'

View File

@@ -11,7 +11,7 @@
import { GenericCreateUpdatePage } from '@/layout/components'
import { STORAGE_TYPE_META_MAP } from '@/views/sessions/const'
import { UploadSecret } from '@/components/Form/FormFields'
import { encryptPassword } from '@/utils/secure'
import { encryptPassword } from '@/utils/session-encrypt'
export default {
name: 'ReplayStorageUpdate',
@@ -51,19 +51,19 @@ export default {
fields: storageTypeMeta.meta,
fieldsMeta: {
SFTP_PASSWORD: {
hidden: (formValue) => formValue.STP_SECRET_TYPE !== 'password'
hidden: formValue => formValue.STP_SECRET_TYPE !== 'password'
},
STP_PRIVATE_KEY: {
component: UploadSecret,
hidden: (formValue) => formValue.STP_SECRET_TYPE !== 'ssh_key'
hidden: formValue => formValue.STP_SECRET_TYPE !== 'ssh_key'
},
STP_PASSPHRASE: {
hidden: (formValue) => formValue.STP_SECRET_TYPE !== 'ssh_key'
hidden: formValue => formValue.STP_SECRET_TYPE !== 'ssh_key'
}
}
},
is_default: {
hidden: (formValue) => formValue.type === 'sftp'
hidden: formValue => formValue.type === 'sftp'
},
comment: {
component: 'el-input',
@@ -102,6 +102,4 @@ export default {
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@@ -4,7 +4,6 @@
<script type="text/jsx">
import { ChoicesFormatter, DetailFormatter, SwitchFormatter } from '@/components/Table/TableFormatters'
import { BASE_URL } from '@/utils/common/index'
import { DrawerListTable as ListTable } from '@/components'
export default {
@@ -141,7 +140,7 @@ export default {
type: 'primary',
can: this.$hasPerm('ops.view_taskmonitor'),
callback: () => {
window.open(`${BASE_URL}/core/flower/?_=${Date.now()}`,)
window.open(`/core/flower/?_=${Date.now()}`,)
}
}
]

View File

@@ -193,6 +193,7 @@ export default {
},
defaultTicketActions: {
hasImport: false,
hasReportExport: true,
hasMoreActions: false,
hasLeftActions: true,
canCreate: this.$hasPerm('tickets.view_ticket'),

View File

@@ -69,6 +69,7 @@
<script>
import IBox from '@/components/Common/IBox'
import { addBasePath } from '@/utils/common/index'
import { IsSupportPauseSessionType } from '@/utils/jms/index'
export default {
@@ -138,7 +139,7 @@ export default {
},
onMonitor() {
const joinUrl = `/luna/monitor/${this.session.id}?ticket_id=${this.object.id}`
window.open(joinUrl, '_blank', 'height=600, width=850, top=400, left=400, toolbar=no, menubar=no, scrollbars=no, location=no, status=no')
window.open(addBasePath(joinUrl), '_blank', 'height=600, width=850, top=400, left=400, toolbar=no, menubar=no, scrollbars=no, location=no, status=no')
},
onToggleLock() {
const url = '/api/v1/terminal/tasks/toggle-lock-session-for-ticket/'

View File

@@ -16,7 +16,7 @@ import GrantedAssets from '@/components/Apps/GrantedAssets/index.vue'
import Page from '@/layout/components/Page/index.vue'
import { EditableInputFormatter } from '@/components/Table/TableFormatters'
import { getPreference } from '@/api/settings'
import { openNewWindow } from '@/utils/common/index'
import { addBasePath, openNewWindow } from '@/utils/common/index'
export default {
components: {
@@ -49,7 +49,7 @@ export default {
openNewWindow(url)
} else {
const url = `/luna/?login_to=${row.id}${oid ? `&oid=${oid}` : ''}`
window.open(url, '_blank')
window.open(addBasePath(url), '_blank')
}
}
},

View File

@@ -5,7 +5,7 @@
<script>
import HomeCard from './HomeCard.vue'
import { getPreference } from '@/api/settings'
import { openNewWindow } from '@/utils/common/index'
import { addBasePath, openNewWindow } from '@/utils/common/index'
export default {
name: 'Announcement',
@@ -79,7 +79,7 @@ export default {
if (this.preference?.basic?.connect_default_open_method === 'new') {
openNewWindow(`/luna/connect?login_to=${row.asset_id}&login_account=${row.account_id}`)
} else {
window.open(`/luna/?login_to=${row.asset_id}&login_account=${row.account_id}`, '_blank')
window.open(addBasePath(`/luna/?login_to=${row.asset_id}&login_account=${row.account_id}`), '_blank')
}
}
}

View File

@@ -8577,6 +8577,11 @@ js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.7.0, js-yaml@^3.9.
argparse "^1.0.7"
esprima "^4.0.0"
jsbn@^1.1.0:
version "1.1.0"
resolved "https://registry.npmmirror.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==
jsbn@~0.1.0:
version "0.1.1"
resolved "https://registry.npmmirror.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
@@ -12849,6 +12854,13 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0"
sm-crypto@^0.4.0:
version "0.4.0"
resolved "https://registry.npmmirror.com/sm-crypto/-/sm-crypto-0.4.0.tgz#13917f5b41e8428d764e9e6934840fdede6a4022"
integrity sha512-OexH2V1EqmhXuOIPGoCl55OjMF0wwPUM/zhUjT0Q6vHBeopSRvTNRy76/1eRoFs3VBKt39hdFnxwpFmooHYa2A==
dependencies:
jsbn "^1.1.0"
smart-buffer@^4.2.0:
version "4.2.0"
resolved "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"