refactor(admin): 收敛后台接口封装与页面状态逻辑

- 简化绑定和日志接口的查询、序列化与前端数据请求路径
- 统一登录流程与前端 API 调用层,补充后台图标依赖
- 抽取通用异步状态处理,减少多个管理页面的重复逻辑
This commit is contained in:
2026-03-04 00:18:47 +08:00
parent ab1bd90c65
commit 0a1eeb9ddf
12 changed files with 556 additions and 305 deletions

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.8.3",
"echarts": "^5.6.0",
"element-plus": "^2.9.6",

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
const TOKEN_KEY = 'sentinel_admin_token'
const LOGIN_PATH_SUFFIX = '/login'
export const api = axios.create({
baseURL: '/',
@@ -19,15 +20,23 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearAuthToken()
if (!window.location.pathname.endsWith('/login')) {
window.location.assign(`${import.meta.env.BASE_URL}login`)
}
redirectToLogin()
}
return Promise.reject(error)
},
)
function redirectToLogin() {
clearAuthToken()
if (!window.location.pathname.endsWith(LOGIN_PATH_SUFFIX)) {
window.location.assign(`${import.meta.env.BASE_URL}login`)
}
}
function withData(promise) {
return promise.then((response) => response.data)
}
export function getAuthToken() {
return localStorage.getItem(TOKEN_KEY)
}
@@ -45,59 +54,50 @@ export function humanizeError(error, fallback = 'Request failed.') {
}
export async function login(password) {
const { data } = await api.post('/admin/api/login', { password })
return data
return withData(api.post('/admin/api/login', { password }))
}
export async function fetchDashboard() {
const { data } = await api.get('/admin/api/dashboard')
return data
return withData(api.get('/admin/api/dashboard'))
}
export async function fetchBindings(params) {
const { data } = await api.get('/admin/api/bindings', { params })
return data
return withData(api.get('/admin/api/bindings', { params }))
}
export async function unbindBinding(id) {
const { data } = await api.post('/admin/api/bindings/unbind', { id })
return data
return withData(api.post('/admin/api/bindings/unbind', { id }))
}
export async function updateBindingIp(payload) {
const { data } = await api.put('/admin/api/bindings/ip', payload)
return data
return withData(api.put('/admin/api/bindings/ip', payload))
}
export async function banBinding(id) {
const { data } = await api.post('/admin/api/bindings/ban', { id })
return data
return withData(api.post('/admin/api/bindings/ban', { id }))
}
export async function unbanBinding(id) {
const { data } = await api.post('/admin/api/bindings/unban', { id })
return data
return withData(api.post('/admin/api/bindings/unban', { id }))
}
export async function fetchLogs(params) {
const { data } = await api.get('/admin/api/logs', { params })
return data
return withData(api.get('/admin/api/logs', { params }))
}
export async function exportLogs(params) {
const response = await api.get('/admin/api/logs/export', {
params,
responseType: 'blob',
})
return response.data
return withData(
api.get('/admin/api/logs/export', {
params,
responseType: 'blob',
}),
)
}
export async function fetchSettings() {
const { data } = await api.get('/admin/api/settings')
return data
return withData(api.get('/admin/api/settings'))
}
export async function updateSettings(payload) {
const { data } = await api.put('/admin/api/settings', payload)
return data
return withData(api.put('/admin/api/settings', payload))
}

View File

@@ -0,0 +1,25 @@
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { humanizeError } from '../api'
export function useAsyncAction() {
const loading = ref(false)
async function run(task, fallbackMessage) {
loading.value = true
try {
return await task()
} catch (error) {
ElMessage.error(humanizeError(error, fallbackMessage))
throw error
} finally {
loading.value = false
}
}
return {
loading,
run,
}
}

View File

@@ -195,6 +195,10 @@ body::before {
gap: 24px;
}
.content-grid--balanced {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
}
.chart-card,
.table-card,
.form-card {
@@ -235,6 +239,49 @@ body::before {
gap: 16px;
}
.support-card {
padding: 20px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(243, 251, 248, 0.72));
border: 1px solid rgba(255, 255, 255, 0.32);
}
.support-card h4 {
margin: 10px 0 8px;
font-size: 1.08rem;
}
.support-card p {
margin: 0;
color: var(--sentinel-ink-soft);
}
.support-list {
display: grid;
gap: 10px;
}
.support-list-item {
display: grid;
gap: 4px;
padding: 12px 0;
border-top: 1px solid rgba(9, 22, 30, 0.08);
}
.support-list-item:first-child {
padding-top: 0;
border-top: 0;
}
.support-kpi {
display: grid;
gap: 8px;
}
.support-kpi strong {
font-size: 1.55rem;
}
.insight-card {
padding: 18px 20px;
border-radius: 22px;
@@ -282,6 +329,7 @@ body::before {
padding: 42px;
display: flex;
flex-direction: column;
gap: 28px;
justify-content: space-between;
color: #f7fffe;
background:
@@ -328,6 +376,10 @@ body::before {
font-size: 0.82rem;
}
.status-chip .el-icon {
flex: 0 0 auto;
}
.stack {
display: grid;
gap: 24px;

View File

@@ -4,20 +4,23 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction'
import {
banBinding,
fetchBindings,
humanizeError,
unbanBinding,
unbindBinding,
updateBindingIp,
} from '../api'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const loading = ref(false)
const dialogVisible = ref(false)
const rows = ref([])
const total = ref(0)
const form = reactive({
id: null,
bound_ip: '',
})
const filters = reactive({
token_suffix: '',
ip: '',
@@ -25,10 +28,7 @@ const filters = reactive({
page: 1,
page_size: 20,
})
const form = reactive({
id: null,
bound_ip: '',
})
const { loading, run } = useAsyncAction()
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
@@ -38,6 +38,23 @@ const visibleProtectedRate = computed(() => {
}
return activeCount.value / rows.value.length
})
const opsCards = [
{
eyebrow: 'Unbind',
title: 'Reset first-use bind',
note: 'Deletes the authoritative record and the Redis cache entry so the next request can bind again.',
},
{
eyebrow: 'Edit CIDR',
title: 'Handle endpoint changes',
note: 'Update the bound IP or subnet when an internal user changes devices, locations, or network segments.',
},
{
eyebrow: 'Ban',
title: 'Freeze compromised tokens',
note: 'Banned tokens are blocked immediately even if the client IP still matches the stored CIDR.',
},
]
function requestParams() {
return {
@@ -50,16 +67,11 @@ function requestParams() {
}
async function loadBindings() {
loading.value = true
try {
await run(async () => {
const data = await fetchBindings(requestParams())
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load bindings.'))
} finally {
loading.value = false
}
}, 'Failed to load bindings.')
}
function resetFilters() {
@@ -82,13 +94,11 @@ async function submitEdit() {
return
}
try {
await updateBindingIp({ id: form.id, bound_ip: form.bound_ip })
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
ElMessage.success('Binding updated.')
dialogVisible.value = false
await loadBindings()
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to update binding.'))
}
} catch {}
}
async function confirmAction(title, action) {
@@ -98,13 +108,12 @@ async function confirmAction(title, action) {
cancelButtonText: 'Cancel',
type: 'warning',
})
await action()
await run(action, 'Operation failed.')
await loadBindings()
} catch (error) {
if (error === 'cancel') {
return
}
ElMessage.error(humanizeError(error, 'Operation failed.'))
}
}
@@ -166,88 +175,117 @@ onMounted(() => {
/>
</section>
<section class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" />
<el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" />
<el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;">
<el-option label="Active" :value="1" />
<el-option label="Banned" :value="2" />
</el-select>
<section class="content-grid content-grid--balanced">
<article class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" />
<el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" />
<el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;">
<el-option label="Active" :value="1" />
<el-option label="Banned" :value="2" />
</el-select>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadBindings()">Search</el-button>
</div>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadBindings()">Search</el-button>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
<el-table-column label="Status" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="first_used_at" label="First used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
</el-table-column>
<el-table-column prop="last_used_at" label="Last used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
</el-table-column>
<el-table-column label="Actions" min-width="280" fixed="right">
<template #default="{ row }">
<div class="toolbar-left">
<el-button size="small" @click="openEdit(row)">Edit IP</el-button>
<el-button
size="small"
type="danger"
plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
>
Unbind
</el-button>
<el-button
v-if="row.status === 1"
size="small"
type="warning"
plain
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
>
Ban
</el-button>
<el-button
v-else
size="small"
type="success"
plain
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
>
Unban
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
<el-table-column label="Status" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="first_used_at" label="First used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
</el-table-column>
<el-table-column prop="last_used_at" label="Last used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
</el-table-column>
<el-table-column label="Actions" min-width="280" fixed="right">
<template #default="{ row }">
<div class="toolbar-left">
<el-button size="small" @click="openEdit(row)">Edit IP</el-button>
<el-button
size="small"
type="danger"
plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
>
Unbind
</el-button>
<el-button
v-if="row.status === 1"
size="small"
type="warning"
plain
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
>
Ban
</el-button>
<el-button
v-else
size="small"
type="success"
plain
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
>
Unban
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/>
</div>
</article>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/>
</div>
<aside class="soft-grid">
<article class="support-card">
<p class="eyebrow">Operator guide</p>
<h4>Choose the least disruptive action first</h4>
<p>Prefer CIDR edits for normal workstation changes. Use unbind when you want the next successful request to re-register automatically.</p>
</article>
<article class="support-card">
<p class="eyebrow">Quick reference</p>
<div class="support-list">
<div v-for="item in opsCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span>
</div>
</div>
</article>
<article class="support-card">
<p class="eyebrow">Visible ratio</p>
<div class="support-kpi">
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
<p>Active records across the current page view.</p>
</div>
</article>
</aside>
</section>
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">

View File

@@ -1,15 +1,14 @@
<script setup>
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { fetchDashboard, humanizeError } from '../api'
import { useAsyncAction } from '../composables/useAsyncAction'
import { fetchDashboard } from '../api'
import { usePolling } from '../composables/usePolling'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const loading = ref(false)
const dashboard = ref({
today: { total: 0, allowed: 0, intercepted: 0 },
bindings: { active: 0, banned: 0 },
@@ -17,6 +16,7 @@ const dashboard = ref({
recent_intercepts: [],
})
const chartElement = ref(null)
const { loading, run } = useAsyncAction()
let chart
const interceptRate = computed(() => {
@@ -105,15 +105,10 @@ async function renderChart() {
}
async function loadDashboard() {
loading.value = true
try {
await run(async () => {
dashboard.value = await fetchDashboard()
await renderChart()
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load dashboard.'))
} finally {
loading.value = false
}
}, 'Failed to load dashboard.')
}
function resizeChart() {

View File

@@ -1,15 +1,33 @@
<script setup>
import { reactive, ref } from 'vue'
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { humanizeError, login, setAuthToken } from '../api'
import { useAsyncAction } from '../composables/useAsyncAction'
import { login, setAuthToken } from '../api'
const router = useRouter()
const loading = ref(false)
const form = reactive({
password: '',
})
const { loading, run } = useAsyncAction()
const loginSignals = [
{
eyebrow: 'Proxy path',
title: 'Streaming request relay',
note: 'Headers and body pass through to the downstream API without buffering full model responses.',
},
{
eyebrow: 'Key policy',
title: 'First-use IP binding',
note: 'Every bearer token is pinned to a trusted client IP or CIDR on its first successful call.',
},
{
eyebrow: 'Operator safety',
title: 'JWT + lockout',
note: 'Admin login is rate-limited by source IP and issues an 8-hour signed token on success.',
},
]
async function submit() {
if (!form.password) {
@@ -17,24 +35,19 @@ async function submit() {
return
}
loading.value = true
try {
const data = await login(form.password)
const data = await run(() => login(form.password), 'Login failed.')
setAuthToken(data.access_token)
ElMessage.success('Authentication complete.')
await router.push({ name: 'dashboard' })
} catch (error) {
ElMessage.error(humanizeError(error, 'Login failed.'))
} finally {
loading.value = false
}
} catch {}
}
</script>
<template>
<div class="login-shell">
<section class="login-stage panel">
<div>
<div class="login-stage-copy">
<p class="eyebrow">Edge enforcement</p>
<h1>Key-IP Sentinel</h1>
<p class="login-copy">
@@ -43,14 +56,22 @@ async function submit() {
</p>
</div>
<div class="login-signal-grid">
<article v-for="item in loginSignals" :key="item.title" class="login-signal-card">
<p class="eyebrow">{{ item.eyebrow }}</p>
<h3>{{ item.title }}</h3>
<p>{{ item.note }}</p>
</article>
</div>
<div class="stack">
<div class="status-chip">
<div class="status-chip status-chip--strong">
<el-icon><Lock /></el-icon>
Zero-trust binding perimeter
Zero-trust perimeter
</div>
<div class="status-chip">
<el-icon><Connection /></el-icon>
Live downstream relay with SSE passthrough
Live downstream relay
</div>
</div>
</section>
@@ -76,6 +97,13 @@ async function submit() {
Enter control plane
</el-button>
</el-form>
<div class="login-divider" />
<div class="login-footer-note">
<span class="eyebrow">Security note</span>
<p>Failed admin attempts are rate-limited by client IP before a JWT is issued.</p>
</div>
</div>
</section>
</div>
@@ -85,4 +113,46 @@ async function submit() {
.w-full {
width: 100%;
}
.login-stage-copy {
display: grid;
gap: 10px;
}
.login-signal-grid {
display: grid;
gap: 14px;
}
.login-signal-card {
padding: 18px 20px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
border: 1px solid rgba(255, 255, 255, 0.12);
}
.login-signal-card h3 {
margin: 10px 0 8px;
font-size: 1.08rem;
}
.login-signal-card p:last-child {
margin: 0;
color: rgba(247, 255, 254, 0.78);
}
.status-chip--strong {
background: rgba(255, 255, 255, 0.18);
}
.login-divider {
height: 1px;
margin: 24px 0 18px;
background: linear-gradient(90deg, rgba(9, 22, 30, 0.06), rgba(11, 158, 136, 0.28), rgba(9, 22, 30, 0.06));
}
.login-footer-note p {
margin: 8px 0 0;
color: var(--sentinel-ink-soft);
}
</style>

View File

@@ -1,14 +1,12 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { exportLogs, fetchLogs, humanizeError } from '../api'
import { useAsyncAction } from '../composables/useAsyncAction'
import { exportLogs, fetchLogs } from '../api'
import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters'
const loading = ref(false)
const exporting = ref(false)
const rows = ref([])
const total = ref(0)
const filters = reactive({
@@ -18,9 +16,24 @@ const filters = reactive({
page: 1,
page_size: 20,
})
const { loading, run } = useAsyncAction()
const { loading: exporting, run: runExport } = useAsyncAction()
const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
const pendingCount = computed(() => rows.value.length - alertedCount.value)
const intelCards = [
{
eyebrow: 'Escalation',
title: 'Alerted rows',
note: 'Rows marked alerted already crossed the Redis threshold window and were included in a webhook escalation.',
},
{
eyebrow: 'Forensics',
title: 'Attempt IP review',
note: 'Correlate repeated attempt IPs with internal NAT ranges or unknown external addresses before acting on the token.',
},
]
function requestParams() {
return {
@@ -34,33 +47,27 @@ function requestParams() {
}
async function loadLogs() {
loading.value = true
try {
await run(async () => {
const data = await fetchLogs(requestParams())
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load logs.'))
} finally {
loading.value = false
}
}, 'Failed to load logs.')
}
async function handleExport() {
exporting.value = true
try {
const blob = await exportLogs({
token: filters.token || undefined,
attempt_ip: filters.attempt_ip || undefined,
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
})
const blob = await runExport(
() =>
exportLogs({
token: filters.token || undefined,
attempt_ip: filters.attempt_ip || undefined,
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
}),
'Failed to export logs.',
)
downloadBlob(blob, 'sentinel-logs.csv')
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to export logs.'))
} finally {
exporting.value = false
}
} catch {}
}
function resetFilters() {
@@ -71,6 +78,11 @@ function resetFilters() {
loadLogs()
}
function onPageChange(value) {
filters.page = value
loadLogs()
}
onMounted(() => {
loadLogs()
})
@@ -128,56 +140,79 @@ onMounted(() => {
/>
</section>
<section class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" />
<el-input v-model="filters.attempt_ip" placeholder="Attempt IP" clearable style="width: 180px;" />
<el-date-picker
v-model="filters.time_range"
type="datetimerange"
range-separator="to"
start-placeholder="Start time"
end-placeholder="End time"
value-format="YYYY-MM-DDTHH:mm:ssZ"
<section class="content-grid content-grid--balanced">
<article class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" />
<el-input v-model="filters.attempt_ip" placeholder="Attempt IP" clearable style="width: 180px;" />
<el-date-picker
v-model="filters.time_range"
type="datetimerange"
range-separator="to"
start-placeholder="Start time"
end-placeholder="End time"
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadLogs()">Search</el-button>
</div>
</div>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="intercepted_at" label="Time" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
</el-table-column>
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" />
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" />
<el-table-column label="Alerted" width="120">
<template #default="{ row }">
<el-tag :type="row.alerted ? 'danger' : 'info'" round>
{{ row.alerted ? 'Yes' : 'No' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Total matching logs: {{ total }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/>
</div>
</article>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadLogs()">Search</el-button>
</div>
</div>
<aside class="soft-grid">
<article class="support-card">
<p class="eyebrow">On this page</p>
<div class="support-kpi">
<strong>{{ formatCompactNumber(pendingCount) }}</strong>
<p>Visible rows still below the escalation threshold or not yet marked as alerted.</p>
</div>
</article>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="intercepted_at" label="Time" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
</el-table-column>
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" />
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" />
<el-table-column label="Alerted" width="120">
<template #default="{ row }">
<el-tag :type="row.alerted ? 'danger' : 'info'" round>
{{ row.alerted ? 'Yes' : 'No' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Total matching logs: {{ total }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="(value) => { filters.page = value; loadLogs() }"
/>
</div>
<article class="support-card">
<p class="eyebrow">Incident review</p>
<div class="support-list">
<div v-for="item in intelCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span>
</div>
</div>
</article>
</aside>
</section>
</div>
</template>

View File

@@ -1,14 +1,13 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { fetchSettings, humanizeError, updateSettings } from '../api'
import { useAsyncAction } from '../composables/useAsyncAction'
import { fetchSettings, updateSettings } from '../api'
import { formatCompactNumber } from '../utils/formatters'
const loading = ref(false)
const saving = ref(false)
const form = reactive({
alert_webhook_url: '',
alert_threshold_count: 5,
@@ -16,42 +15,52 @@ const form = reactive({
archive_days: 90,
failsafe_mode: 'closed',
})
const { loading, run } = useAsyncAction()
const { loading: saving, run: runSave } = useAsyncAction()
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60))
const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled'))
const modeCards = computed(() => [
{
eyebrow: 'Closed mode',
title: 'Protect the perimeter',
note: 'Reject traffic if Redis and PostgreSQL are both unavailable. Choose this when abuse prevention has priority over service continuity.',
active: form.failsafe_mode === 'closed',
},
{
eyebrow: 'Open mode',
title: 'Preserve business flow',
note: 'Allow traffic to continue when the full binding backend is down. Choose this only when continuity requirements outweigh policy enforcement.',
active: form.failsafe_mode === 'open',
},
])
async function loadSettings() {
loading.value = true
try {
await run(async () => {
const data = await fetchSettings()
form.alert_webhook_url = data.alert_webhook_url || ''
form.alert_threshold_count = data.alert_threshold_count
form.alert_threshold_seconds = data.alert_threshold_seconds
form.archive_days = data.archive_days
form.failsafe_mode = data.failsafe_mode
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load runtime settings.'))
} finally {
loading.value = false
}
}, 'Failed to load runtime settings.')
}
async function saveSettings() {
saving.value = true
try {
await updateSettings({
alert_webhook_url: form.alert_webhook_url || null,
alert_threshold_count: form.alert_threshold_count,
alert_threshold_seconds: form.alert_threshold_seconds,
archive_days: form.archive_days,
failsafe_mode: form.failsafe_mode,
})
await runSave(
() =>
updateSettings({
alert_webhook_url: form.alert_webhook_url || null,
alert_threshold_count: form.alert_threshold_count,
alert_threshold_seconds: form.alert_threshold_seconds,
archive_days: form.archive_days,
failsafe_mode: form.failsafe_mode,
}),
'Failed to update runtime settings.',
)
ElMessage.success('Runtime settings updated.')
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to update runtime settings.'))
} finally {
saving.value = false
}
} catch {}
}
onMounted(() => {
@@ -111,7 +120,7 @@ onMounted(() => {
/>
</section>
<section class="content-grid">
<section class="content-grid content-grid--balanced">
<article class="form-card panel">
<p class="eyebrow">Alert window</p>
<h3 class="section-title">Thresholds and webhook delivery</h3>
@@ -138,32 +147,29 @@ onMounted(() => {
</el-form>
</article>
<article class="form-card panel">
<p class="eyebrow">Retention</p>
<h3 class="section-title">Archive stale bindings</h3>
<aside class="soft-grid">
<article class="form-card panel">
<p class="eyebrow">Retention</p>
<h3 class="section-title">Archive stale bindings</h3>
<el-form label-position="top" v-loading="loading">
<el-form-item label="Archive inactive bindings after N days">
<el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
</el-form-item>
</el-form>
<el-form label-position="top" v-loading="loading">
<el-form-item label="Archive inactive bindings after N days">
<el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
</el-form-item>
</el-form>
</article>
<div class="stack">
<div class="panel" style="padding: 16px; border-radius: 20px;">
<p class="eyebrow">Closed mode</p>
<p class="muted" style="margin: 10px 0 0;">
Reject traffic if both Redis and PostgreSQL are unavailable. Use this for security-first deployments.
</p>
</div>
<div class="panel" style="padding: 16px; border-radius: 20px;">
<p class="eyebrow">Open mode</p>
<p class="muted" style="margin: 10px 0 0;">
Allow requests to keep business traffic flowing when the full binding backend is unavailable.
</p>
</div>
</div>
</article>
<article
v-for="item in modeCards"
:key="item.title"
class="support-card"
:style="item.active ? 'box-shadow: inset 0 0 0 1px rgba(11, 158, 136, 0.36);' : ''"
>
<p class="eyebrow">{{ item.eyebrow }}</p>
<h4>{{ item.title }}</h4>
<p>{{ item.note }}</p>
</article>
</aside>
</section>
</div>
</template>