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

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

4
.gitignore vendored
View File

@@ -8,3 +8,7 @@ wheels/
# Virtual environments
.venv
# Node-generated files
node_modules/
npm-debug.log*

View File

@@ -36,6 +36,21 @@ def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> B
)
def apply_binding_filters(
statement,
token_suffix: str | None,
ip: str | None,
status_filter: int | None,
):
if token_suffix:
statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%"))
if ip:
statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%"))
if status_filter in {STATUS_ACTIVE, STATUS_BANNED}:
statement = statement.where(TokenBinding.status == status_filter)
return statement
async def get_binding_or_404(session: AsyncSession, binding_id: int) -> TokenBinding:
binding = await session.get(TokenBinding, binding_id)
if binding is None:
@@ -54,6 +69,21 @@ def log_admin_action(request: Request, settings: Settings, action: str, binding_
)
async def commit_binding_cache(binding: TokenBinding, binding_service: BindingService) -> None:
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
async def update_binding_status(
session: AsyncSession,
binding: TokenBinding,
status_code: int,
binding_service: BindingService,
) -> None:
binding.status = status_code
await session.commit()
await commit_binding_cache(binding, binding_service)
@router.get("", response_model=BindingListResponse)
async def list_bindings(
page: int = Query(default=1, ge=1),
@@ -64,13 +94,7 @@ async def list_bindings(
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
) -> BindingListResponse:
statement = select(TokenBinding)
if token_suffix:
statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%"))
if ip:
statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%"))
if status_filter in {STATUS_ACTIVE, STATUS_BANNED}:
statement = statement.where(TokenBinding.status == status_filter)
statement = apply_binding_filters(select(TokenBinding), token_suffix, ip, status_filter)
total_result = await session.execute(select(func.count()).select_from(statement.subquery()))
total = int(total_result.scalar_one())
@@ -116,7 +140,7 @@ async def update_bound_ip(
binding = await get_binding_or_404(session, payload.id)
binding.bound_ip = payload.bound_ip
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
await commit_binding_cache(binding, binding_service)
log_admin_action(request, settings, "update_ip", payload.id)
return {"success": True}
@@ -130,9 +154,7 @@ async def ban_token(
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_BANNED
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
await update_binding_status(session, binding, STATUS_BANNED, binding_service)
log_admin_action(request, settings, "ban", payload.id)
return {"success": True}
@@ -146,8 +168,6 @@ async def unban_token(
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_ACTIVE
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
await update_binding_status(session, binding, STATUS_ACTIVE, binding_service)
log_admin_action(request, settings, "unban", payload.id)
return {"success": True}

View File

@@ -34,6 +34,33 @@ def apply_log_filters(
return statement
def to_log_item(item: InterceptLog) -> InterceptLogItem:
return InterceptLogItem(
id=item.id,
token_display=item.token_display,
bound_ip=str(item.bound_ip),
attempt_ip=str(item.attempt_ip),
alerted=item.alerted,
intercepted_at=item.intercepted_at,
)
def write_log_csv(buffer: io.StringIO, logs: list[InterceptLog]) -> None:
writer = csv.writer(buffer)
writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"])
for item in logs:
writer.writerow(
[
item.id,
item.token_display,
str(item.bound_ip),
str(item.attempt_ip),
item.alerted,
item.intercepted_at.isoformat(),
]
)
@router.get("", response_model=LogListResponse)
async def list_logs(
page: int = Query(default=1, ge=1),
@@ -54,17 +81,7 @@ async def list_logs(
).all()
return LogListResponse(
items=[
InterceptLogItem(
id=item.id,
token_display=item.token_display,
bound_ip=str(item.bound_ip),
attempt_ip=str(item.attempt_ip),
alerted=item.alerted,
intercepted_at=item.intercepted_at,
)
for item in logs
],
items=[to_log_item(item) for item in logs],
total=total,
page=page,
page_size=page_size,
@@ -85,19 +102,7 @@ async def export_logs(
logs = (await session.scalars(statement)).all()
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"])
for item in logs:
writer.writerow(
[
item.id,
item.token_display,
str(item.bound_ip),
str(item.attempt_ip),
item.alerted,
item.intercepted_at.isoformat(),
]
)
write_log_csv(buffer, logs)
filename = f"sentinel-logs-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.csv"
return StreamingResponse(

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,20 +4,23 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction'
import {
banBinding,
fetchBindings,
humanizeError,
unbanBinding,
unbindBinding,
updateBindingIp,
} from '../api'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const loading = ref(false)
const dialogVisible = ref(false)
const rows = ref([])
const total = ref(0)
const form = reactive({
id: null,
bound_ip: '',
})
const filters = reactive({
token_suffix: '',
ip: '',
@@ -25,10 +28,7 @@ const filters = reactive({
page: 1,
page_size: 20,
})
const form = reactive({
id: null,
bound_ip: '',
})
const { loading, run } = useAsyncAction()
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
@@ -38,6 +38,23 @@ const visibleProtectedRate = computed(() => {
}
return activeCount.value / rows.value.length
})
const opsCards = [
{
eyebrow: 'Unbind',
title: 'Reset first-use bind',
note: 'Deletes the authoritative record and the Redis cache entry so the next request can bind again.',
},
{
eyebrow: 'Edit CIDR',
title: 'Handle endpoint changes',
note: 'Update the bound IP or subnet when an internal user changes devices, locations, or network segments.',
},
{
eyebrow: 'Ban',
title: 'Freeze compromised tokens',
note: 'Banned tokens are blocked immediately even if the client IP still matches the stored CIDR.',
},
]
function requestParams() {
return {
@@ -50,16 +67,11 @@ function requestParams() {
}
async function loadBindings() {
loading.value = true
try {
await run(async () => {
const data = await fetchBindings(requestParams())
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load bindings.'))
} finally {
loading.value = false
}
}, 'Failed to load bindings.')
}
function resetFilters() {
@@ -82,13 +94,11 @@ async function submitEdit() {
return
}
try {
await updateBindingIp({ id: form.id, bound_ip: form.bound_ip })
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
ElMessage.success('Binding updated.')
dialogVisible.value = false
await loadBindings()
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to update binding.'))
}
} catch {}
}
async function confirmAction(title, action) {
@@ -98,13 +108,12 @@ async function confirmAction(title, action) {
cancelButtonText: 'Cancel',
type: 'warning',
})
await action()
await run(action, 'Operation failed.')
await loadBindings()
} catch (error) {
if (error === 'cancel') {
return
}
ElMessage.error(humanizeError(error, 'Operation failed.'))
}
}
@@ -166,7 +175,8 @@ onMounted(() => {
/>
</section>
<section class="table-card panel">
<section class="content-grid content-grid--balanced">
<article class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" />
@@ -248,6 +258,34 @@ onMounted(() => {
@current-change="onPageChange"
/>
</div>
</article>
<aside class="soft-grid">
<article class="support-card">
<p class="eyebrow">Operator guide</p>
<h4>Choose the least disruptive action first</h4>
<p>Prefer CIDR edits for normal workstation changes. Use unbind when you want the next successful request to re-register automatically.</p>
</article>
<article class="support-card">
<p class="eyebrow">Quick reference</p>
<div class="support-list">
<div v-for="item in opsCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span>
</div>
</div>
</article>
<article class="support-card">
<p class="eyebrow">Visible ratio</p>
<div class="support-kpi">
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
<p>Active records across the current page view.</p>
</div>
</article>
</aside>
</section>
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">

View File

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

View File

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

View File

@@ -1,14 +1,12 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { exportLogs, fetchLogs, humanizeError } from '../api'
import { useAsyncAction } from '../composables/useAsyncAction'
import { exportLogs, fetchLogs } from '../api'
import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters'
const loading = ref(false)
const exporting = ref(false)
const rows = ref([])
const total = ref(0)
const filters = reactive({
@@ -18,9 +16,24 @@ const filters = reactive({
page: 1,
page_size: 20,
})
const { loading, run } = useAsyncAction()
const { loading: exporting, run: runExport } = useAsyncAction()
const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
const pendingCount = computed(() => rows.value.length - alertedCount.value)
const intelCards = [
{
eyebrow: 'Escalation',
title: 'Alerted rows',
note: 'Rows marked alerted already crossed the Redis threshold window and were included in a webhook escalation.',
},
{
eyebrow: 'Forensics',
title: 'Attempt IP review',
note: 'Correlate repeated attempt IPs with internal NAT ranges or unknown external addresses before acting on the token.',
},
]
function requestParams() {
return {
@@ -34,33 +47,27 @@ function requestParams() {
}
async function loadLogs() {
loading.value = true
try {
await run(async () => {
const data = await fetchLogs(requestParams())
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load logs.'))
} finally {
loading.value = false
}
}, 'Failed to load logs.')
}
async function handleExport() {
exporting.value = true
try {
const blob = await exportLogs({
const blob = await runExport(
() =>
exportLogs({
token: filters.token || undefined,
attempt_ip: filters.attempt_ip || undefined,
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
})
}),
'Failed to export logs.',
)
downloadBlob(blob, 'sentinel-logs.csv')
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to export logs.'))
} finally {
exporting.value = false
}
} catch {}
}
function resetFilters() {
@@ -71,6 +78,11 @@ function resetFilters() {
loadLogs()
}
function onPageChange(value) {
filters.page = value
loadLogs()
}
onMounted(() => {
loadLogs()
})
@@ -128,7 +140,8 @@ onMounted(() => {
/>
</section>
<section class="table-card panel">
<section class="content-grid content-grid--balanced">
<article class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" />
@@ -175,9 +188,31 @@ onMounted(() => {
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="(value) => { filters.page = value; loadLogs() }"
@current-change="onPageChange"
/>
</div>
</article>
<aside class="soft-grid">
<article class="support-card">
<p class="eyebrow">On this page</p>
<div class="support-kpi">
<strong>{{ formatCompactNumber(pendingCount) }}</strong>
<p>Visible rows still below the escalation threshold or not yet marked as alerted.</p>
</div>
</article>
<article class="support-card">
<p class="eyebrow">Incident review</p>
<div class="support-list">
<div v-for="item in intelCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span>
</div>
</div>
</article>
</aside>
</section>
</div>
</template>

View File

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