mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
451 lines
13 KiB
Vue
451 lines
13 KiB
Vue
<template>
|
|
<div class="settings-section">
|
|
<div class="section-header">
|
|
<div>
|
|
<h2 class="section-title">{{ t('security.audit.title') }}</h2>
|
|
<p class="section-desc">{{ t('security.audit.desc') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit Config -->
|
|
<div class="config-panel">
|
|
<h3 class="config-title">{{ t('security.audit.config.title') }}</h3>
|
|
<div class="config-grid">
|
|
<div class="config-item">
|
|
<div class="config-label-row">
|
|
<label>{{ t('security.audit.config.enabled') }}</label>
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" v-model="auditConfig.auditEnabled" @change="saveAuditConfig" />
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<p class="config-hint">{{ t('security.audit.config.enabledHint') }}</p>
|
|
</div>
|
|
<div class="config-item">
|
|
<label>{{ t('security.audit.config.minSeverity') }}</label>
|
|
<select v-model="auditConfig.auditMinSeverity" @change="saveAuditConfig" class="filter-select">
|
|
<option value="INFO">{{ t('security.severity.INFO') }}</option>
|
|
<option value="LOW">{{ t('security.severity.LOW') }}</option>
|
|
<option value="MEDIUM">{{ t('security.severity.MEDIUM') }}</option>
|
|
<option value="HIGH">{{ t('security.severity.HIGH') }}</option>
|
|
<option value="CRITICAL">{{ t('security.severity.CRITICAL') }}</option>
|
|
</select>
|
|
<p class="config-hint">{{ t('security.audit.config.minSeverityHint') }}</p>
|
|
</div>
|
|
<div class="config-item">
|
|
<label>{{ t('security.audit.config.retentionDays') }}</label>
|
|
<input
|
|
type="number"
|
|
v-model.number="auditConfig.auditRetentionDays"
|
|
@change="saveAuditConfig"
|
|
class="filter-input retention-input"
|
|
min="0"
|
|
max="3650"
|
|
/>
|
|
<p class="config-hint">{{ t('security.audit.config.retentionDaysHint') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Cards -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ auditStats.total || 0 }}</div>
|
|
<div class="stat-label">{{ t('security.audit.stats.total') }}</div>
|
|
</div>
|
|
<div class="stat-card stat-blocked">
|
|
<div class="stat-value">{{ auditStats.blocked || 0 }}</div>
|
|
<div class="stat-label">{{ t('security.audit.stats.blocked') }}</div>
|
|
</div>
|
|
<div class="stat-card stat-approval">
|
|
<div class="stat-value">{{ auditStats.needsApproval || 0 }}</div>
|
|
<div class="stat-label">{{ t('security.audit.stats.needsApproval') }}</div>
|
|
</div>
|
|
<div class="stat-card stat-allowed">
|
|
<div class="stat-value">{{ auditStats.allowed || 0 }}</div>
|
|
<div class="stat-label">{{ t('security.audit.stats.allowed') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filter-row">
|
|
<input
|
|
v-model="auditFilters.toolName"
|
|
:placeholder="t('security.audit.filters.toolName')"
|
|
class="filter-input"
|
|
/>
|
|
<select v-model="auditFilters.decision" class="filter-select">
|
|
<option value="">{{ t('security.audit.filters.decision') }}</option>
|
|
<option value="ALLOW">{{ t('security.decision.ALLOW') }}</option>
|
|
<option value="NEEDS_APPROVAL">{{ t('security.decision.NEEDS_APPROVAL') }}</option>
|
|
<option value="BLOCK">{{ t('security.decision.BLOCK') }}</option>
|
|
</select>
|
|
<button class="btn-secondary" @click="loadAuditLogs">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="1 4 1 10 7 10"/>
|
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Audit Table -->
|
|
<div class="rules-table-wrapper">
|
|
<table class="rules-table">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t('security.audit.columns.time') }}</th>
|
|
<th>{{ t('security.audit.columns.tool') }}</th>
|
|
<th>{{ t('security.audit.columns.decision') }}</th>
|
|
<th>{{ t('security.audit.columns.severity') }}</th>
|
|
<th>{{ t('security.audit.columns.conversationId') }}</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-for="log in auditLogs" :key="log.id">
|
|
<tr>
|
|
<td class="cell-time">{{ formatTime(log.createTime) }}</td>
|
|
<td><code class="tool-name-code">{{ log.toolName }}</code></td>
|
|
<td>
|
|
<span class="decision-badge" :class="'decision-' + log.decision?.toLowerCase()">
|
|
{{ t('security.decision.' + log.decision) || log.decision }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span v-if="log.maxSeverity" class="severity-badge" :class="'severity-' + log.maxSeverity?.toLowerCase()">
|
|
{{ t('security.severity.' + log.maxSeverity) || log.maxSeverity }}
|
|
</span>
|
|
</td>
|
|
<td class="cell-conv">{{ truncateConvId(log.conversationId) }}</td>
|
|
<td>
|
|
<button
|
|
v-if="log.findingsJson"
|
|
class="action-btn"
|
|
@click="toggleExpand(log.id)"
|
|
:title="t('security.audit.expandFindings')"
|
|
>
|
|
<svg
|
|
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2"
|
|
:style="{ transform: expandedRows.has(log.id) ? 'rotate(180deg)' : '' }"
|
|
>
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="expandedRows.has(log.id)" class="expanded-row">
|
|
<td colspan="6">
|
|
<div class="findings-detail">
|
|
<div v-for="(finding, idx) in parseFindings(log.findingsJson)" :key="idx" class="finding-item">
|
|
<span class="severity-badge severity-sm" :class="'severity-' + finding.severity?.toLowerCase()">
|
|
{{ finding.severity }}
|
|
</span>
|
|
<span class="finding-category">{{ finding.category }}</span>
|
|
<span class="finding-title">{{ finding.title }}</span>
|
|
<span v-if="finding.remediation" class="finding-remediation">{{ finding.remediation }}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
<div v-if="!auditLogs.length" class="empty-state">{{ t('security.audit.noLogs') }}</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="auditTotal > auditPageSize" class="pagination">
|
|
<button
|
|
class="btn-secondary btn-sm"
|
|
:disabled="auditPage <= 1"
|
|
@click="auditPage--; loadAuditLogs()"
|
|
>«</button>
|
|
<span class="page-info">{{ auditPage }} / {{ Math.ceil(auditTotal / auditPageSize) }}</span>
|
|
<button
|
|
class="btn-secondary btn-sm"
|
|
:disabled="auditPage >= Math.ceil(auditTotal / auditPageSize)"
|
|
@click="auditPage++; loadAuditLogs()"
|
|
>»</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { securityApi } from '@/api'
|
|
import { parseFindings, formatTime, truncateConvId } from '../composables/helpers'
|
|
import type { AuditStats } from '@/types'
|
|
|
|
const { t } = useI18n()
|
|
|
|
const auditLogs = ref<any[]>([])
|
|
const auditStats = reactive<AuditStats>({ total: 0, blocked: 0, needsApproval: 0, allowed: 0 })
|
|
const auditPage = ref(1)
|
|
const auditPageSize = 20
|
|
const auditTotal = ref(0)
|
|
const auditFilters = reactive({ toolName: '', decision: '' })
|
|
const expandedRows = ref(new Set<number>())
|
|
|
|
// Audit config
|
|
const auditConfig = reactive({
|
|
auditEnabled: true,
|
|
auditMinSeverity: 'INFO',
|
|
auditRetentionDays: 90,
|
|
})
|
|
|
|
async function loadAuditConfig() {
|
|
try {
|
|
const res: any = await securityApi.getGuardConfig()
|
|
const data = res.data || {}
|
|
auditConfig.auditEnabled = data.auditEnabled ?? true
|
|
auditConfig.auditMinSeverity = data.auditMinSeverity || 'INFO'
|
|
auditConfig.auditRetentionDays = data.auditRetentionDays ?? 90
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function saveAuditConfig() {
|
|
try {
|
|
await securityApi.updateGuardConfig({
|
|
auditEnabled: auditConfig.auditEnabled,
|
|
auditMinSeverity: auditConfig.auditMinSeverity,
|
|
auditRetentionDays: auditConfig.auditRetentionDays,
|
|
})
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function loadAuditLogs() {
|
|
try {
|
|
const params: any = {
|
|
page: auditPage.value,
|
|
size: auditPageSize,
|
|
}
|
|
if (auditFilters.toolName) params.toolName = auditFilters.toolName
|
|
if (auditFilters.decision) params.decision = auditFilters.decision
|
|
const res: any = await securityApi.listAuditLogs(params)
|
|
auditLogs.value = res.data?.records || []
|
|
auditTotal.value = res.data?.total || 0
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function loadAuditStats() {
|
|
try {
|
|
const res: any = await securityApi.getAuditStats()
|
|
Object.assign(auditStats, res.data || {})
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function toggleExpand(id: number) {
|
|
if (expandedRows.value.has(id)) {
|
|
expandedRows.value.delete(id)
|
|
} else {
|
|
expandedRows.value.add(id)
|
|
}
|
|
expandedRows.value = new Set(expandedRows.value)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadAuditConfig(), loadAuditLogs(), loadAuditStats()])
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
@import '../shared.css';
|
|
</style>
|
|
|
|
<style scoped>
|
|
/* Config Panel */
|
|
.config-panel {
|
|
background: var(--mc-bg-elevated);
|
|
border: 1px solid var(--mc-border-light);
|
|
border-radius: 10px;
|
|
padding: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.config-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--mc-text-primary);
|
|
margin: 0 0 16px;
|
|
}
|
|
|
|
.config-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 20px;
|
|
}
|
|
|
|
.config-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.config-item label {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--mc-text-primary);
|
|
}
|
|
|
|
.config-label-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.config-hint {
|
|
font-size: 11px;
|
|
color: var(--mc-text-tertiary);
|
|
margin: 0;
|
|
}
|
|
|
|
.retention-input {
|
|
max-width: 120px;
|
|
}
|
|
|
|
/* Toggle Switch */
|
|
.toggle-switch {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 40px;
|
|
height: 22px;
|
|
}
|
|
|
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
|
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
inset: 0;
|
|
background: var(--mc-border);
|
|
border-radius: 22px;
|
|
transition: 0.2s;
|
|
}
|
|
|
|
.toggle-slider::before {
|
|
content: '';
|
|
position: absolute;
|
|
width: 16px;
|
|
height: 16px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background: white;
|
|
border-radius: 50%;
|
|
transition: 0.2s;
|
|
}
|
|
|
|
.toggle-switch input:checked + .toggle-slider {
|
|
background: var(--mc-accent, #3b82f6);
|
|
}
|
|
|
|
.toggle-switch input:checked + .toggle-slider::before {
|
|
transform: translateX(18px);
|
|
}
|
|
|
|
/* Stats Grid */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--mc-bg-elevated);
|
|
border: 1px solid var(--mc-border-light);
|
|
border-radius: 10px;
|
|
padding: 16px 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value { font-size: 28px; font-weight: 700; color: var(--mc-text-primary); }
|
|
.stat-label { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 4px; }
|
|
.stat-blocked .stat-value { color: #ef4444; }
|
|
.stat-approval .stat-value { color: #f59e0b; }
|
|
.stat-allowed .stat-value { color: #10b981; }
|
|
|
|
/* Filters */
|
|
.filter-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.filter-input {
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 6px;
|
|
background: var(--mc-bg);
|
|
color: var(--mc-text-primary);
|
|
font-size: 13px;
|
|
flex: 1;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.filter-select {
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 6px;
|
|
background: var(--mc-bg);
|
|
color: var(--mc-text-primary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
/* Audit table specific */
|
|
.cell-time { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; }
|
|
.cell-conv { font-size: 12px; font-family: 'SF Mono', monospace; color: var(--mc-text-tertiary); }
|
|
.tool-name-code {
|
|
padding: 2px 6px;
|
|
background: var(--mc-bg-sunken);
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.expanded-row td {
|
|
padding: 0 14px 14px;
|
|
background: var(--mc-bg-sunken);
|
|
}
|
|
|
|
.findings-detail {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.finding-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.finding-category {
|
|
font-family: 'SF Mono', monospace;
|
|
color: var(--mc-text-tertiary);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.finding-title { color: var(--mc-text-primary); }
|
|
.finding-remediation { color: var(--mc-text-tertiary); font-style: italic; }
|
|
|
|
.pagination {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.page-info { font-size: 13px; color: var(--mc-text-tertiary); }
|
|
</style>
|