feat(frontend): 打磨管理台交互体验与可访问性
- 优化 Dashboard、Bindings、Logs、Settings 的布局、筛选区与信息层级 - 增加筛选状态同步、未保存提醒、运行时反馈和趋势表视图 - 补充跳转主内容、aria live、键盘导航与移动端触控细节
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#09131d" />
|
||||
<title>Key-IP Sentinel</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -3,11 +3,15 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { clearAuthToken } from './api'
|
||||
import { subscribeToAnnouncements } from './utils/liveRegion'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const clockLabel = ref('')
|
||||
const liveMessage = ref('')
|
||||
let clockTimer
|
||||
let clearAnnouncementTimer
|
||||
let unsubscribeAnnouncements = () => {}
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' },
|
||||
@@ -34,19 +38,34 @@ async function logout() {
|
||||
onMounted(() => {
|
||||
updateClock()
|
||||
clockTimer = window.setInterval(updateClock, 60000)
|
||||
unsubscribeAnnouncements = subscribeToAnnouncements((message) => {
|
||||
liveMessage.value = message
|
||||
if (clearAnnouncementTimer) {
|
||||
window.clearTimeout(clearAnnouncementTimer)
|
||||
}
|
||||
clearAnnouncementTimer = window.setTimeout(() => {
|
||||
liveMessage.value = ''
|
||||
}, 3000)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (clockTimer) {
|
||||
window.clearInterval(clockTimer)
|
||||
}
|
||||
if (clearAnnouncementTimer) {
|
||||
window.clearTimeout(clearAnnouncementTimer)
|
||||
}
|
||||
unsubscribeAnnouncements()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="sr-only" aria-live="polite" role="status">{{ liveMessage }}</p>
|
||||
<router-view v-if="hideShell" />
|
||||
|
||||
<div v-else class="shell">
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="shell-glow shell-glow--mint" />
|
||||
<div class="shell-glow shell-glow--amber" />
|
||||
|
||||
@@ -60,7 +79,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-list">
|
||||
<nav class="nav-list" aria-label="Primary">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
@@ -95,11 +114,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="shell-main">
|
||||
<main class="shell-main" aria-labelledby="page-title">
|
||||
<header class="shell-header panel">
|
||||
<div class="header-copy">
|
||||
<p class="eyebrow">{{ currentSection }}</p>
|
||||
<h2 class="page-title">{{ route.meta.title || 'Sentinel' }}</h2>
|
||||
<h2 id="page-title" class="page-title">{{ route.meta.title || 'Sentinel' }}</h2>
|
||||
<p class="muted header-note">Edge policy, runtime settings, and operator visibility in one secure surface.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@@ -108,7 +127,7 @@ onBeforeUnmount(() => {
|
||||
<span class="header-chip-label">Mode</span>
|
||||
<strong>Secure Proxy</strong>
|
||||
</div>
|
||||
<div class="header-chip">
|
||||
<div class="header-chip" aria-live="polite">
|
||||
<span class="header-chip-label">Updated</span>
|
||||
<strong>{{ clockLabel }}</strong>
|
||||
</div>
|
||||
@@ -117,7 +136,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="shell-content">
|
||||
<section id="main-content" class="shell-content" tabindex="-1">
|
||||
<router-view />
|
||||
</section>
|
||||
</main>
|
||||
@@ -218,6 +237,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
@@ -238,6 +258,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell-glow {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const ADMIN_API_BASE = '/admin/api'
|
||||
const TOKEN_KEY = 'sentinel_admin_token'
|
||||
const LOGIN_PATH_SUFFIX = '/login'
|
||||
|
||||
@@ -33,8 +34,20 @@ function redirectToLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
function withData(promise) {
|
||||
return promise.then((response) => response.data)
|
||||
function responseData(response) {
|
||||
return response.data
|
||||
}
|
||||
|
||||
function getData(url, config) {
|
||||
return api.get(url, config).then(responseData)
|
||||
}
|
||||
|
||||
function postData(url, data, config) {
|
||||
return api.post(url, data, config).then(responseData)
|
||||
}
|
||||
|
||||
function putData(url, data, config) {
|
||||
return api.put(url, data, config).then(responseData)
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
@@ -54,50 +67,48 @@ export function humanizeError(error, fallback = 'Request failed.') {
|
||||
}
|
||||
|
||||
export async function login(password) {
|
||||
return withData(api.post('/admin/api/login', { password }))
|
||||
return postData(`${ADMIN_API_BASE}/login`, { password })
|
||||
}
|
||||
|
||||
export async function fetchDashboard() {
|
||||
return withData(api.get('/admin/api/dashboard'))
|
||||
return getData(`${ADMIN_API_BASE}/dashboard`)
|
||||
}
|
||||
|
||||
export async function fetchBindings(params) {
|
||||
return withData(api.get('/admin/api/bindings', { params }))
|
||||
return getData(`${ADMIN_API_BASE}/bindings`, { params })
|
||||
}
|
||||
|
||||
export async function unbindBinding(id) {
|
||||
return withData(api.post('/admin/api/bindings/unbind', { id }))
|
||||
return postData(`${ADMIN_API_BASE}/bindings/unbind`, { id })
|
||||
}
|
||||
|
||||
export async function updateBindingIp(payload) {
|
||||
return withData(api.put('/admin/api/bindings/ip', payload))
|
||||
return putData(`${ADMIN_API_BASE}/bindings/ip`, payload)
|
||||
}
|
||||
|
||||
export async function banBinding(id) {
|
||||
return withData(api.post('/admin/api/bindings/ban', { id }))
|
||||
return postData(`${ADMIN_API_BASE}/bindings/ban`, { id })
|
||||
}
|
||||
|
||||
export async function unbanBinding(id) {
|
||||
return withData(api.post('/admin/api/bindings/unban', { id }))
|
||||
return postData(`${ADMIN_API_BASE}/bindings/unban`, { id })
|
||||
}
|
||||
|
||||
export async function fetchLogs(params) {
|
||||
return withData(api.get('/admin/api/logs', { params }))
|
||||
return getData(`${ADMIN_API_BASE}/logs`, { params })
|
||||
}
|
||||
|
||||
export async function exportLogs(params) {
|
||||
return withData(
|
||||
api.get('/admin/api/logs/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
}),
|
||||
)
|
||||
return getData(`${ADMIN_API_BASE}/logs/export`, {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSettings() {
|
||||
return withData(api.get('/admin/api/settings'))
|
||||
return getData(`${ADMIN_API_BASE}/settings`)
|
||||
}
|
||||
|
||||
export async function updateSettings(payload) {
|
||||
return withData(api.put('/admin/api/settings', payload))
|
||||
return putData(`${ADMIN_API_BASE}/settings`, payload)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,25 @@ import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { humanizeError } from '../api'
|
||||
import { announcePolite } from '../utils/liveRegion'
|
||||
|
||||
export function useAsyncAction() {
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
function clearError() {
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
async function run(task, fallbackMessage) {
|
||||
loading.value = true
|
||||
clearError()
|
||||
try {
|
||||
return await task()
|
||||
} catch (error) {
|
||||
ElMessage.error(humanizeError(error, fallbackMessage))
|
||||
errorMessage.value = humanizeError(error, fallbackMessage)
|
||||
announcePolite(errorMessage.value)
|
||||
ElMessage.error(errorMessage.value)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -19,6 +28,8 @@ export function useAsyncAction() {
|
||||
}
|
||||
|
||||
return {
|
||||
clearError,
|
||||
errorMessage,
|
||||
loading,
|
||||
run,
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function usePolling(task, intervalMs) {
|
||||
function start() {
|
||||
stop()
|
||||
timerId = window.setInterval(() => {
|
||||
task()
|
||||
Promise.resolve(task()).catch(() => {})
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
color-scheme: dark;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(12, 193, 152, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(255, 170, 76, 0.18), transparent 30%),
|
||||
@@ -42,6 +43,18 @@ body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--sentinel-ink);
|
||||
font-size: 16px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[role="button"] {
|
||||
-webkit-tap-highlight-color: rgba(11, 158, 136, 0.18);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -60,12 +73,42 @@ body::before {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 100;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 31, 45, 0.96);
|
||||
color: #f7fffe;
|
||||
transform: translateY(-140%);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--sentinel-panel);
|
||||
border: 1px solid var(--sentinel-border);
|
||||
border-radius: 28px;
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--sentinel-shadow);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
@@ -139,9 +182,13 @@ body::before {
|
||||
}
|
||||
|
||||
.hero-panel h3,
|
||||
.section-title {
|
||||
.section-title,
|
||||
.brand-title,
|
||||
.page-title,
|
||||
.login-stage h1 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 1.4rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@@ -182,6 +229,7 @@ body::before {
|
||||
margin: 10px 0 0;
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metric-footnote {
|
||||
@@ -210,6 +258,39 @@ body::before {
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.trend-summary {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.trend-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.trend-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
color: var(--sentinel-ink-soft);
|
||||
}
|
||||
|
||||
.trend-table th,
|
||||
.trend-table td {
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid rgba(9, 22, 30, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.trend-table th {
|
||||
color: var(--sentinel-ink);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.trend-table td:nth-child(2),
|
||||
.trend-table td:nth-child(3) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -224,6 +305,7 @@ body::before {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-table .el-table {
|
||||
@@ -234,6 +316,17 @@ body::before {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-date-editor .el-input__wrapper {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.soft-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -256,9 +349,16 @@ body::before {
|
||||
color: var(--sentinel-ink-soft);
|
||||
}
|
||||
|
||||
.support-card--active {
|
||||
box-shadow: inset 0 0 0 1px rgba(11, 158, 136, 0.36);
|
||||
}
|
||||
|
||||
.support-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.support-list-item {
|
||||
@@ -280,6 +380,7 @@ body::before {
|
||||
|
||||
.support-kpi strong {
|
||||
font-size: 1.55rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
@@ -305,6 +406,15 @@ body::before {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.table-stack--spaced {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.insight-card--compact {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.inline-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -374,6 +484,7 @@ body::before {
|
||||
color: var(--sentinel-accent-deep);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.status-chip .el-icon {
|
||||
@@ -449,6 +560,10 @@ body::before {
|
||||
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
.header-chip strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hero-stat-pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -466,6 +581,7 @@ body::before {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 1.35rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table-toolbar-block {
|
||||
@@ -473,6 +589,57 @@ body::before {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-sm {
|
||||
width: min(100%, 180px);
|
||||
}
|
||||
|
||||
.field-md {
|
||||
width: min(100%, 220px);
|
||||
}
|
||||
|
||||
.field-status {
|
||||
width: min(100%, 150px);
|
||||
}
|
||||
|
||||
.field-range {
|
||||
width: min(100%, 360px);
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-field .el-input,
|
||||
.filter-field .el-select,
|
||||
.filter-field .el-date-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: var(--sentinel-ink);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-block {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination-toolbar {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.form-feedback {
|
||||
margin: 12px 0 0;
|
||||
color: var(--sentinel-danger);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.form-feedback--status {
|
||||
color: var(--sentinel-accent-deep);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -510,6 +677,10 @@ body::before {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hero-stat-pair {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-card,
|
||||
.table-card,
|
||||
.form-card,
|
||||
|
||||
29
frontend/src/utils/liveRegion.js
Normal file
29
frontend/src/utils/liveRegion.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const EVENT_NAME = 'sentinel:announce'
|
||||
|
||||
export function announcePolite(message) {
|
||||
if (!message || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EVENT_NAME, {
|
||||
detail: message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function subscribeToAnnouncements(handler) {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
function handleAnnouncement(event) {
|
||||
handler(event.detail || '')
|
||||
}
|
||||
|
||||
window.addEventListener(EVENT_NAME, handleAnnouncement)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(EVENT_NAME, handleAnnouncement)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import PageHero from '../components/PageHero.vue'
|
||||
@@ -14,9 +15,12 @@ import {
|
||||
} from '../api'
|
||||
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
|
||||
|
||||
const defaultPageSize = 20
|
||||
const dialogVisible = ref(false)
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const form = reactive({
|
||||
id: null,
|
||||
bound_ip: '',
|
||||
@@ -26,12 +30,13 @@ const filters = reactive({
|
||||
ip: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
page_size: defaultPageSize,
|
||||
})
|
||||
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)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
|
||||
const visibleProtectedRate = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return 0
|
||||
@@ -56,6 +61,63 @@ const opsCards = [
|
||||
},
|
||||
]
|
||||
|
||||
function parsePositiveInteger(value, fallbackValue) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isInteger(parsed) && parsed > 0) {
|
||||
return parsed
|
||||
}
|
||||
return fallbackValue
|
||||
}
|
||||
|
||||
function parseStatus(value) {
|
||||
if (value === '1' || value === 1) {
|
||||
return 1
|
||||
}
|
||||
if (value === '2' || value === 2) {
|
||||
return 2
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function applyRouteQuery(query) {
|
||||
filters.token_suffix = typeof query.token_suffix === 'string' ? query.token_suffix : ''
|
||||
filters.ip = typeof query.ip === 'string' ? query.ip : ''
|
||||
filters.status = parseStatus(query.status)
|
||||
filters.page = parsePositiveInteger(query.page, 1)
|
||||
filters.page_size = parsePositiveInteger(query.page_size, defaultPageSize)
|
||||
}
|
||||
|
||||
function buildRouteQuery() {
|
||||
const query = {
|
||||
page: String(filters.page),
|
||||
}
|
||||
|
||||
if (filters.page_size !== defaultPageSize) {
|
||||
query.page_size = String(filters.page_size)
|
||||
}
|
||||
if (filters.token_suffix) {
|
||||
query.token_suffix = filters.token_suffix
|
||||
}
|
||||
if (filters.ip) {
|
||||
query.ip = filters.ip
|
||||
}
|
||||
if (filters.status !== '') {
|
||||
query.status = String(filters.status)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
function sameQuery(nextQuery) {
|
||||
const normalize = (query) =>
|
||||
Object.entries(query)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${String(value)}`)
|
||||
.join('|')
|
||||
|
||||
return normalize(route.query) === normalize(nextQuery)
|
||||
}
|
||||
|
||||
function requestParams() {
|
||||
return {
|
||||
page: filters.page,
|
||||
@@ -74,12 +136,33 @@ async function loadBindings() {
|
||||
}, 'Failed to load bindings.')
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
async function refreshBindings() {
|
||||
try {
|
||||
await loadBindings()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function syncBindings() {
|
||||
const nextQuery = buildRouteQuery()
|
||||
if (sameQuery(nextQuery)) {
|
||||
await refreshBindings()
|
||||
return
|
||||
}
|
||||
|
||||
await router.replace({ query: nextQuery })
|
||||
}
|
||||
|
||||
async function resetFilters() {
|
||||
filters.token_suffix = ''
|
||||
filters.ip = ''
|
||||
filters.status = ''
|
||||
filters.page = 1
|
||||
loadBindings()
|
||||
await syncBindings()
|
||||
}
|
||||
|
||||
async function searchBindings() {
|
||||
filters.page = 1
|
||||
await syncBindings()
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
@@ -97,7 +180,7 @@ async function submitEdit() {
|
||||
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
|
||||
ElMessage.success('Binding updated.')
|
||||
dialogVisible.value = false
|
||||
await loadBindings()
|
||||
await refreshBindings()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -109,7 +192,7 @@ async function confirmAction(title, action) {
|
||||
type: 'warning',
|
||||
})
|
||||
await run(action, 'Operation failed.')
|
||||
await loadBindings()
|
||||
await refreshBindings()
|
||||
} catch (error) {
|
||||
if (error === 'cancel') {
|
||||
return
|
||||
@@ -117,14 +200,19 @@ async function confirmAction(title, action) {
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(value) {
|
||||
async function onPageChange(value) {
|
||||
filters.page = value
|
||||
loadBindings()
|
||||
await syncBindings()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBindings()
|
||||
})
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
applyRouteQuery(query)
|
||||
refreshBindings()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -179,21 +267,54 @@ onMounted(() => {
|
||||
<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 class="filter-field field-sm">
|
||||
<label class="filter-label" for="binding-token-suffix">Token Suffix</label>
|
||||
<el-input
|
||||
id="binding-token-suffix"
|
||||
v-model="filters.token_suffix"
|
||||
aria-label="Filter by token suffix"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_token_suffix"
|
||||
placeholder="Token suffix..."
|
||||
@keyup.enter="searchBindings"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-md">
|
||||
<label class="filter-label" for="binding-ip-filter">Bound CIDR</label>
|
||||
<el-input
|
||||
id="binding-ip-filter"
|
||||
v-model="filters.ip"
|
||||
aria-label="Filter by bound CIDR"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_ip_filter"
|
||||
placeholder="192.168.1.0/24..."
|
||||
@keyup.enter="searchBindings"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-status">
|
||||
<label class="filter-label" for="binding-status-filter">Status</label>
|
||||
<el-select
|
||||
id="binding-status-filter"
|
||||
v-model="filters.status"
|
||||
aria-label="Filter by binding status"
|
||||
clearable
|
||||
placeholder="Status..."
|
||||
>
|
||||
<el-option label="Active" :value="1" />
|
||||
<el-option label="Banned" :value="2" />
|
||||
</el-select>
|
||||
</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>
|
||||
<el-button @click="resetFilters">Reset Filters</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table" style="margin-top: 20px;">
|
||||
<div class="data-table table-block">
|
||||
<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" />
|
||||
@@ -214,32 +335,29 @@ onMounted(() => {
|
||||
<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 @click="openEdit(row)">Edit CIDR</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
||||
>
|
||||
Unbind
|
||||
Remove Binding
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
|
||||
>
|
||||
Ban
|
||||
Ban Token
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
size="small"
|
||||
type="success"
|
||||
plain
|
||||
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
|
||||
>
|
||||
Unban
|
||||
Restore Token
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -247,8 +365,8 @@ onMounted(() => {
|
||||
</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>
|
||||
<div class="toolbar pagination-toolbar">
|
||||
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
@@ -269,13 +387,13 @@ onMounted(() => {
|
||||
|
||||
<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">
|
||||
<ul class="support-list" role="list">
|
||||
<li 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>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="support-card">
|
||||
@@ -291,7 +409,13 @@ onMounted(() => {
|
||||
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="CIDR or single IP">
|
||||
<el-input v-model="form.bound_ip" placeholder="192.168.1.0/24" />
|
||||
<el-input
|
||||
v-model="form.bound_ip"
|
||||
autocomplete="off"
|
||||
name="bound_ip"
|
||||
placeholder="192.168.1.0/24"
|
||||
@keyup.enter="submitEdit"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import PageHero from '../components/PageHero.vue'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction'
|
||||
import { fetchDashboard } from '../api'
|
||||
import { usePolling } from '../composables/usePolling'
|
||||
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
|
||||
import { formatCompactNumber, formatDate, formatDateTime, formatPercent } from '../utils/formatters'
|
||||
|
||||
const dashboard = ref({
|
||||
today: { total: 0, allowed: 0, intercepted: 0 },
|
||||
@@ -111,14 +111,20 @@ async function loadDashboard() {
|
||||
}, 'Failed to load dashboard.')
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
try {
|
||||
await loadDashboard()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function resizeChart() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
const { start: startPolling, stop: stopPolling } = usePolling(loadDashboard, 30000)
|
||||
const { start: startPolling, stop: stopPolling } = usePolling(refreshDashboard, 30000)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard()
|
||||
await refreshDashboard()
|
||||
startPolling()
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
@@ -151,7 +157,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<el-button :loading="loading" type="primary" plain @click="loadDashboard">Refresh dashboard</el-button>
|
||||
<el-button :loading="loading" type="primary" plain @click="refreshDashboard">Refresh Dashboard</el-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
@@ -195,6 +201,27 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-surface" />
|
||||
<div class="trend-summary">
|
||||
<p class="eyebrow">Trend table</p>
|
||||
<div class="trend-table-wrap">
|
||||
<table class="trend-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Allowed</th>
|
||||
<th scope="col">Intercepted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in dashboard.trend" :key="item.date">
|
||||
<td>{{ formatDate(item.date) }}</td>
|
||||
<td>{{ formatCompactNumber(item.allowed) }}</td>
|
||||
<td>{{ formatCompactNumber(item.intercepted) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="table-card panel">
|
||||
@@ -206,12 +233,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">No intercepts recorded yet.</div>
|
||||
|
||||
<div v-else class="table-stack" style="margin-top: 18px;">
|
||||
<div
|
||||
<div v-else class="table-stack table-stack--spaced">
|
||||
<article
|
||||
v-for="item in dashboard.recent_intercepts"
|
||||
:key="item.id"
|
||||
class="insight-card"
|
||||
style="padding: 16px; border-radius: 20px;"
|
||||
class="insight-card insight-card--compact"
|
||||
>
|
||||
<div class="toolbar">
|
||||
<strong>{{ item.token_display }}</strong>
|
||||
@@ -222,7 +248,7 @@ onBeforeUnmount(() => {
|
||||
<p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p>
|
||||
<p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p>
|
||||
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -10,7 +10,7 @@ const router = useRouter()
|
||||
const form = reactive({
|
||||
password: '',
|
||||
})
|
||||
const { loading, run } = useAsyncAction()
|
||||
const { clearError, errorMessage, loading, run } = useAsyncAction()
|
||||
const loginSignals = [
|
||||
{
|
||||
eyebrow: 'Proxy path',
|
||||
@@ -36,6 +36,7 @@ async function submit() {
|
||||
}
|
||||
|
||||
try {
|
||||
clearError()
|
||||
const data = await run(() => login(form.password), 'Login failed.')
|
||||
setAuthToken(data.access_token)
|
||||
ElMessage.success('Authentication complete.')
|
||||
@@ -79,22 +80,28 @@ async function submit() {
|
||||
<section class="login-card">
|
||||
<div class="login-card-inner">
|
||||
<p class="eyebrow">Admin access</p>
|
||||
<h2 class="section-title">Secure operator login</h2>
|
||||
<h2 class="section-title">Secure Operator Login</h2>
|
||||
<p class="muted">Use the runtime password from your deployment environment to obtain an 8-hour admin token.</p>
|
||||
|
||||
<el-form label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="Admin password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:aria-describedby="errorMessage ? 'login-error' : undefined"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
show-password
|
||||
size="large"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="submit"
|
||||
name="admin_password"
|
||||
placeholder="Enter deployment password"
|
||||
@input="clearError"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" size="large" :loading="loading" class="w-full" @click="submit">
|
||||
Enter control plane
|
||||
<p v-if="errorMessage" id="login-error" class="form-feedback" role="alert">{{ errorMessage }}</p>
|
||||
|
||||
<el-button native-type="submit" type="primary" size="large" :loading="loading" class="w-full">
|
||||
Enter Control Plane
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import PageHero from '../components/PageHero.vue'
|
||||
@@ -7,14 +8,17 @@ import { useAsyncAction } from '../composables/useAsyncAction'
|
||||
import { exportLogs, fetchLogs } from '../api'
|
||||
import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters'
|
||||
|
||||
const defaultPageSize = 20
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const filters = reactive({
|
||||
token: '',
|
||||
attempt_ip: '',
|
||||
time_range: [],
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
page_size: defaultPageSize,
|
||||
})
|
||||
const { loading, run } = useAsyncAction()
|
||||
const { loading: exporting, run: runExport } = useAsyncAction()
|
||||
@@ -22,6 +26,7 @@ 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 pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
|
||||
const intelCards = [
|
||||
{
|
||||
eyebrow: 'Escalation',
|
||||
@@ -35,6 +40,57 @@ const intelCards = [
|
||||
},
|
||||
]
|
||||
|
||||
function parsePositiveInteger(value, fallbackValue) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isInteger(parsed) && parsed > 0) {
|
||||
return parsed
|
||||
}
|
||||
return fallbackValue
|
||||
}
|
||||
|
||||
function applyRouteQuery(query) {
|
||||
filters.token = typeof query.token === 'string' ? query.token : ''
|
||||
filters.attempt_ip = typeof query.attempt_ip === 'string' ? query.attempt_ip : ''
|
||||
filters.time_range =
|
||||
typeof query.start_time === 'string' && typeof query.end_time === 'string'
|
||||
? [query.start_time, query.end_time]
|
||||
: []
|
||||
filters.page = parsePositiveInteger(query.page, 1)
|
||||
filters.page_size = parsePositiveInteger(query.page_size, defaultPageSize)
|
||||
}
|
||||
|
||||
function buildRouteQuery() {
|
||||
const query = {
|
||||
page: String(filters.page),
|
||||
}
|
||||
|
||||
if (filters.page_size !== defaultPageSize) {
|
||||
query.page_size = String(filters.page_size)
|
||||
}
|
||||
if (filters.token) {
|
||||
query.token = filters.token
|
||||
}
|
||||
if (filters.attempt_ip) {
|
||||
query.attempt_ip = filters.attempt_ip
|
||||
}
|
||||
if (filters.time_range?.[0] && filters.time_range?.[1]) {
|
||||
query.start_time = filters.time_range[0]
|
||||
query.end_time = filters.time_range[1]
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
function sameQuery(nextQuery) {
|
||||
const normalize = (query) =>
|
||||
Object.entries(query)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${String(value)}`)
|
||||
.join('|')
|
||||
|
||||
return normalize(route.query) === normalize(nextQuery)
|
||||
}
|
||||
|
||||
function requestParams() {
|
||||
return {
|
||||
page: filters.page,
|
||||
@@ -54,6 +110,22 @@ async function loadLogs() {
|
||||
}, 'Failed to load logs.')
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
await loadLogs()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function syncLogs() {
|
||||
const nextQuery = buildRouteQuery()
|
||||
if (sameQuery(nextQuery)) {
|
||||
await refreshLogs()
|
||||
return
|
||||
}
|
||||
|
||||
await router.replace({ query: nextQuery })
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const blob = await runExport(
|
||||
@@ -70,22 +142,32 @@ async function handleExport() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
async function resetFilters() {
|
||||
filters.token = ''
|
||||
filters.attempt_ip = ''
|
||||
filters.time_range = []
|
||||
filters.page = 1
|
||||
loadLogs()
|
||||
await syncLogs()
|
||||
}
|
||||
|
||||
function onPageChange(value) {
|
||||
async function searchLogs() {
|
||||
filters.page = 1
|
||||
await syncLogs()
|
||||
}
|
||||
|
||||
async function onPageChange(value) {
|
||||
filters.page = value
|
||||
loadLogs()
|
||||
await syncLogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
applyRouteQuery(query)
|
||||
refreshLogs()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,25 +226,54 @@ onMounted(() => {
|
||||
<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 class="filter-field field-sm">
|
||||
<label class="filter-label" for="log-token-filter">Masked Token</label>
|
||||
<el-input
|
||||
id="log-token-filter"
|
||||
v-model="filters.token"
|
||||
aria-label="Filter by masked token"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="log_token_filter"
|
||||
placeholder="Masked token..."
|
||||
@keyup.enter="searchLogs"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-sm">
|
||||
<label class="filter-label" for="log-attempt-ip-filter">Attempt IP</label>
|
||||
<el-input
|
||||
id="log-attempt-ip-filter"
|
||||
v-model="filters.attempt_ip"
|
||||
aria-label="Filter by attempt IP"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="log_attempt_ip_filter"
|
||||
placeholder="10.0.0.8..."
|
||||
@keyup.enter="searchLogs"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-range">
|
||||
<label class="filter-label" for="log-time-range">Time Range</label>
|
||||
<el-date-picker
|
||||
id="log-time-range"
|
||||
v-model="filters.time_range"
|
||||
aria-label="Filter by intercepted time range"
|
||||
type="datetimerange"
|
||||
range-separator="to"
|
||||
start-placeholder="Start Time"
|
||||
end-placeholder="End Time"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<el-button @click="resetFilters">Reset Filters</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="searchLogs">Search Logs</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table" style="margin-top: 20px;">
|
||||
<div class="data-table table-block">
|
||||
<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>
|
||||
@@ -180,8 +291,8 @@ onMounted(() => {
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" style="margin-top: 18px;">
|
||||
<span class="muted">Total matching logs: {{ total }}</span>
|
||||
<div class="toolbar pagination-toolbar">
|
||||
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
@@ -204,13 +315,13 @@ onMounted(() => {
|
||||
|
||||
<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">
|
||||
<ul class="support-list" role="list">
|
||||
<li 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>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import PageHero from '../components/PageHero.vue'
|
||||
@@ -15,11 +16,13 @@ const form = reactive({
|
||||
archive_days: 90,
|
||||
failsafe_mode: 'closed',
|
||||
})
|
||||
const initialSnapshot = ref('')
|
||||
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 hasUnsavedChanges = computed(() => Boolean(initialSnapshot.value) && buildSnapshot() !== initialSnapshot.value)
|
||||
const modeCards = computed(() => [
|
||||
{
|
||||
eyebrow: 'Closed mode',
|
||||
@@ -35,6 +38,37 @@ const modeCards = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
function buildSnapshot() {
|
||||
return JSON.stringify({
|
||||
alert_webhook_url: form.alert_webhook_url,
|
||||
alert_threshold_count: form.alert_threshold_count,
|
||||
alert_threshold_seconds: form.alert_threshold_seconds,
|
||||
archive_days: form.archive_days,
|
||||
failsafe_mode: form.failsafe_mode,
|
||||
})
|
||||
}
|
||||
|
||||
function syncSnapshot() {
|
||||
initialSnapshot.value = buildSnapshot()
|
||||
}
|
||||
|
||||
function confirmDiscardChanges() {
|
||||
if (!hasUnsavedChanges.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
return window.confirm('You have unsaved runtime settings. Leave this page and discard them?')
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event) {
|
||||
if (!hasUnsavedChanges.value) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
await run(async () => {
|
||||
const data = await fetchSettings()
|
||||
@@ -43,6 +77,7 @@ async function loadSettings() {
|
||||
form.alert_threshold_seconds = data.alert_threshold_seconds
|
||||
form.archive_days = data.archive_days
|
||||
form.failsafe_mode = data.failsafe_mode
|
||||
syncSnapshot()
|
||||
}, 'Failed to load runtime settings.')
|
||||
}
|
||||
|
||||
@@ -59,13 +94,21 @@ async function saveSettings() {
|
||||
}),
|
||||
'Failed to update runtime settings.',
|
||||
)
|
||||
syncSnapshot()
|
||||
ElMessage.success('Runtime settings updated.')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadSettings().catch(() => {})
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(() => confirmDiscardChanges())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,7 +132,9 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">Apply runtime settings</el-button>
|
||||
<el-button type="primary" :disabled="loading || !hasUnsavedChanges" :loading="saving" @click="saveSettings">
|
||||
Save Runtime Settings
|
||||
</el-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
@@ -123,11 +168,16 @@ onMounted(() => {
|
||||
<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>
|
||||
<h3 class="section-title">Thresholds and Webhook Delivery</h3>
|
||||
|
||||
<el-form label-position="top" v-loading="loading">
|
||||
<el-form-item label="Webhook URL">
|
||||
<el-input v-model="form.alert_webhook_url" placeholder="https://hooks.example.internal/sentinel" />
|
||||
<el-input
|
||||
v-model="form.alert_webhook_url"
|
||||
autocomplete="off"
|
||||
name="alert_webhook_url"
|
||||
placeholder="https://hooks.example.internal/sentinel..."
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Intercept count threshold">
|
||||
@@ -145,12 +195,16 @@ onMounted(() => {
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="form-feedback form-feedback--status" role="status">
|
||||
{{ hasUnsavedChanges ? 'You have unsaved runtime changes.' : 'Runtime settings are in sync.' }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<aside class="soft-grid">
|
||||
<article class="form-card panel">
|
||||
<p class="eyebrow">Retention</p>
|
||||
<h3 class="section-title">Archive stale bindings</h3>
|
||||
<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">
|
||||
@@ -162,8 +216,7 @@ onMounted(() => {
|
||||
<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);' : ''"
|
||||
:class="['support-card', { 'support-card--active': item.active }]"
|
||||
>
|
||||
<p class="eyebrow">{{ item.eyebrow }}</p>
|
||||
<h4>{{ item.title }}</h4>
|
||||
|
||||
Reference in New Issue
Block a user