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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#09131d" />
<title>Key-IP Sentinel</title> <title>Key-IP Sentinel</title>
</head> </head>
<body> <body>

View File

@@ -3,11 +3,15 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { clearAuthToken } from './api' import { clearAuthToken } from './api'
import { subscribeToAnnouncements } from './utils/liveRegion'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const clockLabel = ref('') const clockLabel = ref('')
const liveMessage = ref('')
let clockTimer let clockTimer
let clearAnnouncementTimer
let unsubscribeAnnouncements = () => {}
const navItems = [ const navItems = [
{ label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' }, { label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' },
@@ -34,19 +38,34 @@ async function logout() {
onMounted(() => { onMounted(() => {
updateClock() updateClock()
clockTimer = window.setInterval(updateClock, 60000) clockTimer = window.setInterval(updateClock, 60000)
unsubscribeAnnouncements = subscribeToAnnouncements((message) => {
liveMessage.value = message
if (clearAnnouncementTimer) {
window.clearTimeout(clearAnnouncementTimer)
}
clearAnnouncementTimer = window.setTimeout(() => {
liveMessage.value = ''
}, 3000)
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (clockTimer) { if (clockTimer) {
window.clearInterval(clockTimer) window.clearInterval(clockTimer)
} }
if (clearAnnouncementTimer) {
window.clearTimeout(clearAnnouncementTimer)
}
unsubscribeAnnouncements()
}) })
</script> </script>
<template> <template>
<p class="sr-only" aria-live="polite" role="status">{{ liveMessage }}</p>
<router-view v-if="hideShell" /> <router-view v-if="hideShell" />
<div v-else class="shell"> <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--mint" />
<div class="shell-glow shell-glow--amber" /> <div class="shell-glow shell-glow--amber" />
@@ -60,7 +79,7 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<nav class="nav-list"> <nav class="nav-list" aria-label="Primary">
<router-link <router-link
v-for="item in navItems" v-for="item in navItems"
:key="item.name" :key="item.name"
@@ -95,11 +114,11 @@ onBeforeUnmount(() => {
</div> </div>
</aside> </aside>
<main class="shell-main"> <main class="shell-main" aria-labelledby="page-title">
<header class="shell-header panel"> <header class="shell-header panel">
<div class="header-copy"> <div class="header-copy">
<p class="eyebrow">{{ currentSection }}</p> <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> <p class="muted header-note">Edge policy, runtime settings, and operator visibility in one secure surface.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
@@ -108,7 +127,7 @@ onBeforeUnmount(() => {
<span class="header-chip-label">Mode</span> <span class="header-chip-label">Mode</span>
<strong>Secure Proxy</strong> <strong>Secure Proxy</strong>
</div> </div>
<div class="header-chip"> <div class="header-chip" aria-live="polite">
<span class="header-chip-label">Updated</span> <span class="header-chip-label">Updated</span>
<strong>{{ clockLabel }}</strong> <strong>{{ clockLabel }}</strong>
</div> </div>
@@ -117,7 +136,7 @@ onBeforeUnmount(() => {
</div> </div>
</header> </header>
<section class="shell-content"> <section id="main-content" class="shell-content" tabindex="-1">
<router-view /> <router-view />
</section> </section>
</main> </main>
@@ -218,6 +237,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
min-width: 0;
} }
.shell-header { .shell-header {
@@ -238,6 +258,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
min-width: 0;
} }
.shell-glow { .shell-glow {

View File

@@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
const ADMIN_API_BASE = '/admin/api'
const TOKEN_KEY = 'sentinel_admin_token' const TOKEN_KEY = 'sentinel_admin_token'
const LOGIN_PATH_SUFFIX = '/login' const LOGIN_PATH_SUFFIX = '/login'
@@ -33,8 +34,20 @@ function redirectToLogin() {
} }
} }
function withData(promise) { function responseData(response) {
return promise.then((response) => response.data) 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() { export function getAuthToken() {
@@ -54,50 +67,48 @@ export function humanizeError(error, fallback = 'Request failed.') {
} }
export async function login(password) { export async function login(password) {
return withData(api.post('/admin/api/login', { password })) return postData(`${ADMIN_API_BASE}/login`, { password })
} }
export async function fetchDashboard() { export async function fetchDashboard() {
return withData(api.get('/admin/api/dashboard')) return getData(`${ADMIN_API_BASE}/dashboard`)
} }
export async function fetchBindings(params) { 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) { 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) { 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) { 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) { 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) { 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) { export async function exportLogs(params) {
return withData( return getData(`${ADMIN_API_BASE}/logs/export`, {
api.get('/admin/api/logs/export', {
params, params,
responseType: 'blob', responseType: 'blob',
}), })
)
} }
export async function fetchSettings() { export async function fetchSettings() {
return withData(api.get('/admin/api/settings')) return getData(`${ADMIN_API_BASE}/settings`)
} }
export async function updateSettings(payload) { 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 { ElMessage } from 'element-plus'
import { humanizeError } from '../api' import { humanizeError } from '../api'
import { announcePolite } from '../utils/liveRegion'
export function useAsyncAction() { export function useAsyncAction() {
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('')
function clearError() {
errorMessage.value = ''
}
async function run(task, fallbackMessage) { async function run(task, fallbackMessage) {
loading.value = true loading.value = true
clearError()
try { try {
return await task() return await task()
} catch (error) { } catch (error) {
ElMessage.error(humanizeError(error, fallbackMessage)) errorMessage.value = humanizeError(error, fallbackMessage)
announcePolite(errorMessage.value)
ElMessage.error(errorMessage.value)
throw error throw error
} finally { } finally {
loading.value = false loading.value = false
@@ -19,6 +28,8 @@ export function useAsyncAction() {
} }
return { return {
clearError,
errorMessage,
loading, loading,
run, run,
} }

View File

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

View File

@@ -32,6 +32,7 @@
html { html {
min-height: 100%; min-height: 100%;
color-scheme: dark;
background: background:
radial-gradient(circle at top left, rgba(12, 193, 152, 0.22), transparent 34%), 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%), radial-gradient(circle at top right, rgba(255, 170, 76, 0.18), transparent 30%),
@@ -42,6 +43,18 @@ body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
color: var(--sentinel-ink); 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 { body::before {
@@ -60,12 +73,42 @@ body::before {
min-height: 100vh; 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 { .panel {
background: var(--sentinel-panel); background: var(--sentinel-panel);
border: 1px solid var(--sentinel-border); border: 1px solid var(--sentinel-border);
border-radius: 28px; border-radius: 28px;
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
box-shadow: var(--sentinel-shadow); box-shadow: var(--sentinel-shadow);
min-width: 0;
} }
.glass-panel { .glass-panel {
@@ -139,9 +182,13 @@ body::before {
} }
.hero-panel h3, .hero-panel h3,
.section-title { .section-title,
.brand-title,
.page-title,
.login-stage h1 {
margin: 10px 0 8px; margin: 10px 0 8px;
font-size: 1.4rem; font-size: 1.4rem;
text-wrap: balance;
} }
.metric-grid { .metric-grid {
@@ -182,6 +229,7 @@ body::before {
margin: 10px 0 0; margin: 10px 0 0;
font-size: clamp(1.8rem, 3vw, 2.5rem); font-size: clamp(1.8rem, 3vw, 2.5rem);
font-weight: 800; font-weight: 800;
font-variant-numeric: tabular-nums;
} }
.metric-footnote { .metric-footnote {
@@ -210,6 +258,39 @@ body::before {
min-height: 340px; 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 { .toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -224,6 +305,7 @@ body::before {
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
min-width: 0;
} }
.data-table .el-table { .data-table .el-table {
@@ -234,6 +316,17 @@ body::before {
overflow: hidden; 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 { .soft-grid {
display: grid; display: grid;
gap: 16px; gap: 16px;
@@ -256,9 +349,16 @@ body::before {
color: var(--sentinel-ink-soft); color: var(--sentinel-ink-soft);
} }
.support-card--active {
box-shadow: inset 0 0 0 1px rgba(11, 158, 136, 0.36);
}
.support-list { .support-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
margin: 0;
padding: 0;
list-style: none;
} }
.support-list-item { .support-list-item {
@@ -280,6 +380,7 @@ body::before {
.support-kpi strong { .support-kpi strong {
font-size: 1.55rem; font-size: 1.55rem;
font-variant-numeric: tabular-nums;
} }
.insight-card { .insight-card {
@@ -305,6 +406,15 @@ body::before {
gap: 14px; gap: 14px;
} }
.table-stack--spaced {
margin-top: 18px;
}
.insight-card--compact {
padding: 16px;
border-radius: 20px;
}
.inline-meta { .inline-meta {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -374,6 +484,7 @@ body::before {
color: var(--sentinel-accent-deep); color: var(--sentinel-accent-deep);
font-weight: 700; font-weight: 700;
font-size: 0.82rem; font-size: 0.82rem;
font-variant-numeric: tabular-nums;
} }
.status-chip .el-icon { .status-chip .el-icon {
@@ -449,6 +560,10 @@ body::before {
border: 1px solid rgba(255, 255, 255, 0.26); border: 1px solid rgba(255, 255, 255, 0.26);
} }
.header-chip strong {
font-variant-numeric: tabular-nums;
}
.hero-stat-pair { .hero-stat-pair {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -466,6 +581,7 @@ body::before {
display: block; display: block;
margin-top: 6px; margin-top: 6px;
font-size: 1.35rem; font-size: 1.35rem;
font-variant-numeric: tabular-nums;
} }
.table-toolbar-block { .table-toolbar-block {
@@ -473,6 +589,57 @@ body::before {
gap: 8px; 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) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -510,6 +677,10 @@ body::before {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.hero-stat-pair {
grid-template-columns: 1fr;
}
.chart-card, .chart-card,
.table-card, .table-card,
.form-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> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import MetricTile from '../components/MetricTile.vue' import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
@@ -14,9 +15,12 @@ import {
} from '../api' } from '../api'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters' import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const defaultPageSize = 20
const dialogVisible = ref(false) const dialogVisible = ref(false)
const rows = ref([]) const rows = ref([])
const total = ref(0) const total = ref(0)
const route = useRoute()
const router = useRouter()
const form = reactive({ const form = reactive({
id: null, id: null,
bound_ip: '', bound_ip: '',
@@ -26,12 +30,13 @@ const filters = reactive({
ip: '', ip: '',
status: '', status: '',
page: 1, page: 1,
page_size: 20, page_size: defaultPageSize,
}) })
const { loading, run } = useAsyncAction() const { loading, run } = useAsyncAction()
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length) const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).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(() => { const visibleProtectedRate = computed(() => {
if (!rows.value.length) { if (!rows.value.length) {
return 0 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() { function requestParams() {
return { return {
page: filters.page, page: filters.page,
@@ -74,12 +136,33 @@ async function loadBindings() {
}, 'Failed to load bindings.') }, '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.token_suffix = ''
filters.ip = '' filters.ip = ''
filters.status = '' filters.status = ''
filters.page = 1 filters.page = 1
loadBindings() await syncBindings()
}
async function searchBindings() {
filters.page = 1
await syncBindings()
} }
function openEdit(row) { 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.') await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
ElMessage.success('Binding updated.') ElMessage.success('Binding updated.')
dialogVisible.value = false dialogVisible.value = false
await loadBindings() await refreshBindings()
} catch {} } catch {}
} }
@@ -109,7 +192,7 @@ async function confirmAction(title, action) {
type: 'warning', type: 'warning',
}) })
await run(action, 'Operation failed.') await run(action, 'Operation failed.')
await loadBindings() await refreshBindings()
} catch (error) { } catch (error) {
if (error === 'cancel') { if (error === 'cancel') {
return return
@@ -117,14 +200,19 @@ async function confirmAction(title, action) {
} }
} }
function onPageChange(value) { async function onPageChange(value) {
filters.page = value filters.page = value
loadBindings() await syncBindings()
} }
onMounted(() => { watch(
loadBindings() () => route.query,
}) (query) => {
applyRouteQuery(query)
refreshBindings()
},
{ immediate: true },
)
</script> </script>
<template> <template>
@@ -179,21 +267,54 @@ onMounted(() => {
<article class="table-card panel"> <article class="table-card panel">
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" /> <div class="filter-field field-sm">
<el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" /> <label class="filter-label" for="binding-token-suffix">Token Suffix</label>
<el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;"> <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="Active" :value="1" />
<el-option label="Banned" :value="2" /> <el-option label="Banned" :value="2" />
</el-select> </el-select>
</div> </div>
</div>
<div class="toolbar-right"> <div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button> <el-button @click="resetFilters">Reset Filters</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadBindings()">Search</el-button> <el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</el-button>
</div> </div>
</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 :data="rows" v-loading="loading">
<el-table-column prop="id" label="ID" width="90" /> <el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="token_display" label="Token" min-width="170" /> <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"> <el-table-column label="Actions" min-width="280" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="toolbar-left"> <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 <el-button
size="small"
type="danger" type="danger"
plain plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))" @click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
> >
Unbind Remove Binding
</el-button> </el-button>
<el-button <el-button
v-if="row.status === 1" v-if="row.status === 1"
size="small"
type="warning" type="warning"
plain plain
@click="confirmAction('Ban this token?', () => banBinding(row.id))" @click="confirmAction('Ban this token?', () => banBinding(row.id))"
> >
Ban Ban Token
</el-button> </el-button>
<el-button <el-button
v-else v-else
size="small"
type="success" type="success"
plain plain
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))" @click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
> >
Unban Restore Token
</el-button> </el-button>
</div> </div>
</template> </template>
@@ -247,8 +365,8 @@ onMounted(() => {
</el-table> </el-table>
</div> </div>
<div class="toolbar" style="margin-top: 18px;"> <div class="toolbar pagination-toolbar">
<span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span> <span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
<el-pagination <el-pagination
background background
layout="prev, pager, next" layout="prev, pager, next"
@@ -269,13 +387,13 @@ onMounted(() => {
<article class="support-card"> <article class="support-card">
<p class="eyebrow">Quick reference</p> <p class="eyebrow">Quick reference</p>
<div class="support-list"> <ul class="support-list" role="list">
<div v-for="item in opsCards" :key="item.title" class="support-list-item"> <li v-for="item in opsCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span> <span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span> <span class="muted">{{ item.note }}</span>
</div> </li>
</div> </ul>
</article> </article>
<article class="support-card"> <article class="support-card">
@@ -291,7 +409,13 @@ onMounted(() => {
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px"> <el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="CIDR or single IP"> <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-item>
</el-form> </el-form>

View File

@@ -7,7 +7,7 @@ import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction' import { useAsyncAction } from '../composables/useAsyncAction'
import { fetchDashboard } from '../api' import { fetchDashboard } from '../api'
import { usePolling } from '../composables/usePolling' import { usePolling } from '../composables/usePolling'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters' import { formatCompactNumber, formatDate, formatDateTime, formatPercent } from '../utils/formatters'
const dashboard = ref({ const dashboard = ref({
today: { total: 0, allowed: 0, intercepted: 0 }, today: { total: 0, allowed: 0, intercepted: 0 },
@@ -111,14 +111,20 @@ async function loadDashboard() {
}, 'Failed to load dashboard.') }, 'Failed to load dashboard.')
} }
async function refreshDashboard() {
try {
await loadDashboard()
} catch {}
}
function resizeChart() { function resizeChart() {
chart?.resize() chart?.resize()
} }
const { start: startPolling, stop: stopPolling } = usePolling(loadDashboard, 30000) const { start: startPolling, stop: stopPolling } = usePolling(refreshDashboard, 30000)
onMounted(async () => { onMounted(async () => {
await loadDashboard() await refreshDashboard()
startPolling() startPolling()
window.addEventListener('resize', resizeChart) window.addEventListener('resize', resizeChart)
}) })
@@ -151,7 +157,7 @@ onBeforeUnmount(() => {
</template> </template>
<template #actions> <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> </template>
</PageHero> </PageHero>
@@ -195,6 +201,27 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div ref="chartElement" class="chart-surface" /> <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>
<article class="table-card panel"> <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-if="!dashboard.recent_intercepts.length" class="empty-state">No intercepts recorded yet.</div>
<div v-else class="table-stack" style="margin-top: 18px;"> <div v-else class="table-stack table-stack--spaced">
<div <article
v-for="item in dashboard.recent_intercepts" v-for="item in dashboard.recent_intercepts"
:key="item.id" :key="item.id"
class="insight-card" class="insight-card insight-card--compact"
style="padding: 16px; border-radius: 20px;"
> >
<div class="toolbar"> <div class="toolbar">
<strong>{{ item.token_display }}</strong> <strong>{{ item.token_display }}</strong>
@@ -222,7 +248,7 @@ onBeforeUnmount(() => {
<p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p> <p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p>
<p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p> <p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p>
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p> <p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
</div> </article>
</div> </div>
</article> </article>
</section> </section>

View File

@@ -10,7 +10,7 @@ const router = useRouter()
const form = reactive({ const form = reactive({
password: '', password: '',
}) })
const { loading, run } = useAsyncAction() const { clearError, errorMessage, loading, run } = useAsyncAction()
const loginSignals = [ const loginSignals = [
{ {
eyebrow: 'Proxy path', eyebrow: 'Proxy path',
@@ -36,6 +36,7 @@ async function submit() {
} }
try { try {
clearError()
const data = await run(() => login(form.password), 'Login failed.') const data = await run(() => login(form.password), 'Login failed.')
setAuthToken(data.access_token) setAuthToken(data.access_token)
ElMessage.success('Authentication complete.') ElMessage.success('Authentication complete.')
@@ -79,22 +80,28 @@ async function submit() {
<section class="login-card"> <section class="login-card">
<div class="login-card-inner"> <div class="login-card-inner">
<p class="eyebrow">Admin access</p> <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> <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 label-position="top" @submit.prevent="submit">
<el-form-item label="Admin password"> <el-form-item label="Admin password">
<el-input <el-input
v-model="form.password" v-model="form.password"
:aria-describedby="errorMessage ? 'login-error' : undefined"
:aria-invalid="Boolean(errorMessage)"
show-password show-password
size="large" size="large"
autocomplete="current-password" autocomplete="current-password"
@keyup.enter="submit" name="admin_password"
placeholder="Enter deployment password"
@input="clearError"
/> />
</el-form-item> </el-form-item>
<el-button type="primary" size="large" :loading="loading" class="w-full" @click="submit"> <p v-if="errorMessage" id="login-error" class="form-feedback" role="alert">{{ errorMessage }}</p>
Enter control plane
<el-button native-type="submit" type="primary" size="large" :loading="loading" class="w-full">
Enter Control Plane
</el-button> </el-button>
</el-form> </el-form>

View File

@@ -1,5 +1,6 @@
<script setup> <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 MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
@@ -7,14 +8,17 @@ import { useAsyncAction } from '../composables/useAsyncAction'
import { exportLogs, fetchLogs } from '../api' import { exportLogs, fetchLogs } from '../api'
import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters' import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters'
const defaultPageSize = 20
const rows = ref([]) const rows = ref([])
const total = ref(0) const total = ref(0)
const route = useRoute()
const router = useRouter()
const filters = reactive({ const filters = reactive({
token: '', token: '',
attempt_ip: '', attempt_ip: '',
time_range: [], time_range: [],
page: 1, page: 1,
page_size: 20, page_size: defaultPageSize,
}) })
const { loading, run } = useAsyncAction() const { loading, run } = useAsyncAction()
const { loading: exporting, run: runExport } = 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 alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size) const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
const pendingCount = computed(() => rows.value.length - alertedCount.value) const pendingCount = computed(() => rows.value.length - alertedCount.value)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
const intelCards = [ const intelCards = [
{ {
eyebrow: 'Escalation', 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() { function requestParams() {
return { return {
page: filters.page, page: filters.page,
@@ -54,6 +110,22 @@ async function loadLogs() {
}, 'Failed to load logs.') }, '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() { async function handleExport() {
try { try {
const blob = await runExport( const blob = await runExport(
@@ -70,22 +142,32 @@ async function handleExport() {
} catch {} } catch {}
} }
function resetFilters() { async function resetFilters() {
filters.token = '' filters.token = ''
filters.attempt_ip = '' filters.attempt_ip = ''
filters.time_range = [] filters.time_range = []
filters.page = 1 filters.page = 1
loadLogs() await syncLogs()
} }
function onPageChange(value) { async function searchLogs() {
filters.page = 1
await syncLogs()
}
async function onPageChange(value) {
filters.page = value filters.page = value
loadLogs() await syncLogs()
} }
onMounted(() => { watch(
loadLogs() () => route.query,
}) (query) => {
applyRouteQuery(query)
refreshLogs()
},
{ immediate: true },
)
</script> </script>
<template> <template>
@@ -144,25 +226,54 @@ onMounted(() => {
<article class="table-card panel"> <article class="table-card panel">
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" /> <div class="filter-field field-sm">
<el-input v-model="filters.attempt_ip" placeholder="Attempt IP" clearable style="width: 180px;" /> <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 <el-date-picker
id="log-time-range"
v-model="filters.time_range" v-model="filters.time_range"
aria-label="Filter by intercepted time range"
type="datetimerange" type="datetimerange"
range-separator="to" range-separator="to"
start-placeholder="Start time" start-placeholder="Start Time"
end-placeholder="End time" end-placeholder="End Time"
value-format="YYYY-MM-DDTHH:mm:ssZ" value-format="YYYY-MM-DDTHH:mm:ssZ"
/> />
</div> </div>
</div>
<div class="toolbar-right"> <div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button> <el-button @click="resetFilters">Reset Filters</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadLogs()">Search</el-button> <el-button type="primary" :loading="loading" @click="searchLogs">Search Logs</el-button>
</div> </div>
</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 :data="rows" v-loading="loading">
<el-table-column prop="intercepted_at" label="Time" min-width="190"> <el-table-column prop="intercepted_at" label="Time" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template> <template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
@@ -180,8 +291,8 @@ onMounted(() => {
</el-table> </el-table>
</div> </div>
<div class="toolbar" style="margin-top: 18px;"> <div class="toolbar pagination-toolbar">
<span class="muted">Total matching logs: {{ total }}</span> <span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
<el-pagination <el-pagination
background background
layout="prev, pager, next" layout="prev, pager, next"
@@ -204,13 +315,13 @@ onMounted(() => {
<article class="support-card"> <article class="support-card">
<p class="eyebrow">Incident review</p> <p class="eyebrow">Incident review</p>
<div class="support-list"> <ul class="support-list" role="list">
<div v-for="item in intelCards" :key="item.title" class="support-list-item"> <li v-for="item in intelCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span> <span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span> <span class="muted">{{ item.note }}</span>
</div> </li>
</div> </ul>
</article> </article>
</aside> </aside>
</section> </section>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, reactive } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import MetricTile from '../components/MetricTile.vue' import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
@@ -15,11 +16,13 @@ const form = reactive({
archive_days: 90, archive_days: 90,
failsafe_mode: 'closed', failsafe_mode: 'closed',
}) })
const initialSnapshot = ref('')
const { loading, run } = useAsyncAction() const { loading, run } = useAsyncAction()
const { loading: saving, run: runSave } = useAsyncAction() const { loading: saving, run: runSave } = useAsyncAction()
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60)) const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60))
const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled')) const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled'))
const hasUnsavedChanges = computed(() => Boolean(initialSnapshot.value) && buildSnapshot() !== initialSnapshot.value)
const modeCards = computed(() => [ const modeCards = computed(() => [
{ {
eyebrow: 'Closed mode', 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() { async function loadSettings() {
await run(async () => { await run(async () => {
const data = await fetchSettings() const data = await fetchSettings()
@@ -43,6 +77,7 @@ async function loadSettings() {
form.alert_threshold_seconds = data.alert_threshold_seconds form.alert_threshold_seconds = data.alert_threshold_seconds
form.archive_days = data.archive_days form.archive_days = data.archive_days
form.failsafe_mode = data.failsafe_mode form.failsafe_mode = data.failsafe_mode
syncSnapshot()
}, 'Failed to load runtime settings.') }, 'Failed to load runtime settings.')
} }
@@ -59,13 +94,21 @@ async function saveSettings() {
}), }),
'Failed to update runtime settings.', 'Failed to update runtime settings.',
) )
syncSnapshot()
ElMessage.success('Runtime settings updated.') ElMessage.success('Runtime settings updated.')
} catch {} } catch {}
} }
onMounted(() => { onMounted(() => {
loadSettings() loadSettings().catch(() => {})
window.addEventListener('beforeunload', handleBeforeUnload)
}) })
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(() => confirmDiscardChanges())
</script> </script>
<template> <template>
@@ -89,7 +132,9 @@ onMounted(() => {
</template> </template>
<template #actions> <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> </template>
</PageHero> </PageHero>
@@ -123,11 +168,16 @@ onMounted(() => {
<section class="content-grid content-grid--balanced"> <section class="content-grid content-grid--balanced">
<article class="form-card panel"> <article class="form-card panel">
<p class="eyebrow">Alert window</p> <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 label-position="top" v-loading="loading">
<el-form-item label="Webhook URL"> <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>
<el-form-item label="Intercept count threshold"> <el-form-item label="Intercept count threshold">
@@ -145,12 +195,16 @@ onMounted(() => {
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-form> </el-form>
<p class="form-feedback form-feedback--status" role="status">
{{ hasUnsavedChanges ? 'You have unsaved runtime changes.' : 'Runtime settings are in sync.' }}
</p>
</article> </article>
<aside class="soft-grid"> <aside class="soft-grid">
<article class="form-card panel"> <article class="form-card panel">
<p class="eyebrow">Retention</p> <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 label-position="top" v-loading="loading">
<el-form-item label="Archive inactive bindings after N days"> <el-form-item label="Archive inactive bindings after N days">
@@ -162,8 +216,7 @@ onMounted(() => {
<article <article
v-for="item in modeCards" v-for="item in modeCards"
:key="item.title" :key="item.title"
class="support-card" :class="['support-card', { 'support-card--active': item.active }]"
:style="item.active ? 'box-shadow: inset 0 0 0 1px rgba(11, 158, 136, 0.36);' : ''"
> >
<p class="eyebrow">{{ item.eyebrow }}</p> <p class="eyebrow">{{ item.eyebrow }}</p>
<h4>{{ item.title }}</h4> <h4>{{ item.title }}</h4>