feat(frontend): 打磨管理台交互体验与可访问性

- 优化 Dashboard、Bindings、Logs、Settings 的布局、筛选区与信息层级
- 增加筛选状态同步、未保存提醒、运行时反馈和趋势表视图
- 补充跳转主内容、aria live、键盘导航与移动端触控细节
This commit is contained in:
2026-03-04 00:18:59 +08:00
parent 0a1eeb9ddf
commit 380a78283e
12 changed files with 675 additions and 110 deletions

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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,
}

View File

@@ -13,7 +13,7 @@ export function usePolling(task, intervalMs) {
function start() {
stop()
timerId = window.setInterval(() => {
task()
Promise.resolve(task()).catch(() => {})
}, intervalMs)
}

View File

@@ -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,

View 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)
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>