feat(core): 初始化 Key-IP Sentinel 服务与部署骨架

- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构
- 实现反向代理、首用绑定、拦截告警、归档任务和管理接口
- 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
2026-03-04 00:18:33 +08:00
commit ab1bd90c65
50 changed files with 5645 additions and 0 deletions

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Key-IP Sentinel</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "key-ip-sentinel-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.8.3",
"echarts": "^5.6.0",
"element-plus": "^2.9.6",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.2.1"
}
}

293
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,293 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { clearAuthToken } from './api'
const route = useRoute()
const router = useRouter()
const clockLabel = ref('')
let clockTimer
const navItems = [
{ label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' },
{ label: 'Bindings', name: 'bindings', icon: 'Connection' },
{ label: 'Logs', name: 'logs', icon: 'WarningFilled' },
{ label: 'Settings', name: 'settings', icon: 'Setting' },
]
const hideShell = computed(() => Boolean(route.meta.public))
const currentSection = computed(() => route.meta.kicker || 'Operations')
function updateClock() {
clockLabel.value = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date())
}
async function logout() {
clearAuthToken()
await router.push({ name: 'login' })
}
onMounted(() => {
updateClock()
clockTimer = window.setInterval(updateClock, 60000)
})
onBeforeUnmount(() => {
if (clockTimer) {
window.clearInterval(clockTimer)
}
})
</script>
<template>
<router-view v-if="hideShell" />
<div v-else class="shell">
<div class="shell-glow shell-glow--mint" />
<div class="shell-glow shell-glow--amber" />
<aside class="shell-sidebar panel">
<div class="brand-block">
<div class="brand-mark">S</div>
<div>
<p class="eyebrow">Key-IP Sentinel</p>
<h1 class="brand-title">Control Plane</h1>
<p class="brand-subtitle">First-use bind enforcement edge</p>
</div>
</div>
<nav class="nav-list">
<router-link
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="nav-link"
active-class="is-active"
>
<component :is="item.icon" class="nav-icon" />
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="sidebar-note">
<p class="eyebrow">Operating mode</p>
<h3>Zero-trust token perimeter</h3>
<p class="muted">
Every API key is pinned to the first observed client address or CIDR and inspected at the edge.
</p>
</div>
<div class="rail-grid">
<div class="rail-card">
<span class="rail-label">Surface</span>
<strong>Admin UI</strong>
<span class="rail-meta">JWT protected</span>
</div>
<div class="rail-card">
<span class="rail-label">Proxy</span>
<strong>Streaming</strong>
<span class="rail-meta">SSE passthrough</span>
</div>
</div>
</aside>
<main class="shell-main">
<header class="shell-header panel">
<div class="header-copy">
<p class="eyebrow">{{ currentSection }}</p>
<h2 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">
<div class="header-chip-group">
<div class="header-chip">
<span class="header-chip-label">Mode</span>
<strong>Secure Proxy</strong>
</div>
<div class="header-chip">
<span class="header-chip-label">Updated</span>
<strong>{{ clockLabel }}</strong>
</div>
</div>
<el-button type="primary" plain @click="logout">Logout</el-button>
</div>
</header>
<section class="shell-content">
<router-view />
</section>
</main>
</div>
</template>
<style scoped>
.shell {
position: relative;
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 24px;
min-height: 100vh;
padding: 24px;
}
.shell-sidebar,
.shell-header {
position: relative;
z-index: 1;
}
.shell-sidebar {
display: flex;
flex-direction: column;
gap: 28px;
padding: 28px;
}
.brand-block {
display: flex;
align-items: center;
gap: 16px;
}
.brand-mark {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(17, 231, 181, 0.95), rgba(21, 132, 214, 0.95));
color: #071016;
font-size: 1.45rem;
font-weight: 800;
}
.brand-title,
.page-title {
margin: 0;
font-size: clamp(1.5rem, 2vw, 2.1rem);
}
.nav-list {
display: grid;
gap: 10px;
}
.nav-link {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
color: var(--sentinel-ink-soft);
text-decoration: none;
transition: transform 160ms ease, background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sentinel-ink);
background: rgba(7, 176, 147, 0.14);
box-shadow: inset 0 0 0 1px rgba(7, 176, 147, 0.18);
transform: translateX(4px);
}
.nav-icon {
width: 18px;
}
.sidebar-note {
margin-top: auto;
padding: 18px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(10, 26, 35, 0.8));
color: #f3fffd;
}
.sidebar-note h3 {
margin: 10px 0;
font-size: 1.15rem;
}
.shell-main {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px 26px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.shell-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.shell-glow {
position: fixed;
inset: auto;
z-index: 0;
border-radius: 999px;
filter: blur(90px);
opacity: 0.7;
pointer-events: none;
}
.shell-glow--mint {
top: 80px;
right: 160px;
width: 240px;
height: 240px;
background: rgba(17, 231, 181, 0.22);
}
.shell-glow--amber {
bottom: 100px;
left: 420px;
width: 280px;
height: 280px;
background: rgba(255, 170, 76, 0.18);
}
@media (max-width: 1080px) {
.shell {
grid-template-columns: 1fr;
}
.shell-sidebar {
order: 2;
}
.shell-main {
order: 1;
}
}
@media (max-width: 720px) {
.shell {
padding: 16px;
}
.shell-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>

103
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,103 @@
import axios from 'axios'
const TOKEN_KEY = 'sentinel_admin_token'
export const api = axios.create({
baseURL: '/',
timeout: 20000,
})
api.interceptors.request.use((config) => {
const token = getAuthToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
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`)
}
}
return Promise.reject(error)
},
)
export function getAuthToken() {
return localStorage.getItem(TOKEN_KEY)
}
export function setAuthToken(token) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearAuthToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function humanizeError(error, fallback = 'Request failed.') {
return error?.response?.data?.detail || error?.message || fallback
}
export async function login(password) {
const { data } = await api.post('/admin/api/login', { password })
return data
}
export async function fetchDashboard() {
const { data } = await api.get('/admin/api/dashboard')
return data
}
export async function fetchBindings(params) {
const { data } = await api.get('/admin/api/bindings', { params })
return data
}
export async function unbindBinding(id) {
const { data } = await api.post('/admin/api/bindings/unbind', { id })
return data
}
export async function updateBindingIp(payload) {
const { data } = await api.put('/admin/api/bindings/ip', payload)
return data
}
export async function banBinding(id) {
const { data } = await api.post('/admin/api/bindings/ban', { id })
return data
}
export async function unbanBinding(id) {
const { data } = await api.post('/admin/api/bindings/unban', { id })
return data
}
export async function fetchLogs(params) {
const { data } = await api.get('/admin/api/logs', { params })
return data
}
export async function exportLogs(params) {
const response = await 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
}
export async function updateSettings(payload) {
const { data } = await api.put('/admin/api/settings', payload)
return data
}

View File

@@ -0,0 +1,28 @@
<script setup>
defineProps({
eyebrow: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
note: {
type: String,
required: true,
},
accent: {
type: String,
default: 'mint',
},
})
</script>
<template>
<article class="metric-card metric-card--enhanced panel" :data-accent="accent">
<p class="eyebrow">{{ eyebrow }}</p>
<div class="metric-value">{{ value }}</div>
<p class="metric-footnote">{{ note }}</p>
</article>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
defineProps({
eyebrow: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
})
</script>
<template>
<section class="hero-panel hero-layout panel">
<div class="hero-copy">
<p class="eyebrow">{{ eyebrow }}</p>
<h3>{{ title }}</h3>
<p class="muted hero-description">{{ description }}</p>
</div>
<div v-if="$slots.aside || $slots.actions" class="hero-side">
<div v-if="$slots.aside" class="hero-aside">
<slot name="aside" />
</div>
<div v-if="$slots.actions" class="hero-actions">
<slot name="actions" />
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,26 @@
import { onBeforeUnmount } from 'vue'
export function usePolling(task, intervalMs) {
let timerId = null
function stop() {
if (timerId) {
window.clearInterval(timerId)
timerId = null
}
}
function start() {
stop()
timerId = window.setInterval(() => {
task()
}, intervalMs)
}
onBeforeUnmount(stop)
return {
start,
stop,
}
}

18
frontend/src/main.js Normal file
View File

@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles.css'
const app = createApp(App)
Object.entries(ElementPlusIconsVue).forEach(([key, component]) => {
app.component(key, component)
})
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -0,0 +1,76 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getAuthToken } from '../api'
import Bindings from '../views/Bindings.vue'
import Dashboard from '../views/Dashboard.vue'
import Login from '../views/Login.vue'
import Logs from '../views/Logs.vue'
import Settings from '../views/Settings.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: Login,
meta: {
public: true,
title: 'Admin Login',
},
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/dashboard',
name: 'dashboard',
component: Dashboard,
meta: {
title: 'Traffic Pulse',
kicker: 'Observability',
},
},
{
path: '/bindings',
name: 'bindings',
component: Bindings,
meta: {
title: 'Token Bindings',
kicker: 'Control',
},
},
{
path: '/logs',
name: 'logs',
component: Logs,
meta: {
title: 'Intercept Logs',
kicker: 'Audit',
},
},
{
path: '/settings',
name: 'settings',
component: Settings,
meta: {
title: 'Runtime Settings',
kicker: 'Operations',
},
},
],
})
router.beforeEach((to) => {
const authed = Boolean(getAuthToken())
if (to.meta.public && authed) {
return { name: 'dashboard' }
}
if (!to.meta.public && !authed) {
return { name: 'login' }
}
return true
})
export default router

477
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,477 @@
:root {
--sentinel-bg: #08131c;
--sentinel-bg-soft: #102734;
--sentinel-panel: rgba(252, 255, 255, 0.82);
--sentinel-panel-strong: rgba(255, 255, 255, 0.9);
--sentinel-border: rgba(255, 255, 255, 0.24);
--sentinel-ink: #09161e;
--sentinel-ink-soft: #57717d;
--sentinel-accent: #07b093;
--sentinel-accent-deep: #0d7e8b;
--sentinel-warn: #ef7f41;
--sentinel-danger: #dc4f53;
--sentinel-shadow: 0 30px 80px rgba(2, 12, 18, 0.22);
--el-color-primary: #0b9e88;
--el-color-success: #1aa36f;
--el-color-warning: #ef7f41;
--el-color-danger: #dc4f53;
color: var(--sentinel-ink);
font-family: "Avenir Next", "Segoe UI Variable", "Segoe UI", "PingFang SC", sans-serif;
line-height: 1.5;
font-weight: 400;
}
* {
box-sizing: border-box;
}
*:focus-visible {
outline: 3px solid rgba(11, 158, 136, 0.34);
outline-offset: 2px;
}
html {
min-height: 100%;
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%),
linear-gradient(180deg, #09131d 0%, #0d1d29 35%, #112d3d 100%);
}
body {
margin: 0;
min-height: 100vh;
color: var(--sentinel-ink);
}
body::before {
content: "";
position: fixed;
inset: 0;
background:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.5), transparent 95%);
pointer-events: none;
}
#app {
min-height: 100vh;
}
.panel {
background: var(--sentinel-panel);
border: 1px solid var(--sentinel-border);
border-radius: 28px;
backdrop-filter: blur(18px);
box-shadow: var(--sentinel-shadow);
}
.glass-panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(250, 255, 252, 0.74));
}
.eyebrow {
margin: 0;
color: var(--sentinel-accent-deep);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.74rem;
font-weight: 700;
}
.muted {
color: var(--sentinel-ink-soft);
}
.page-grid {
display: grid;
gap: 24px;
}
.hero-panel {
position: relative;
padding: 26px;
overflow: hidden;
}
.hero-layout {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
gap: 20px;
align-items: stretch;
}
.hero-copy {
display: flex;
flex-direction: column;
justify-content: center;
}
.hero-description {
max-width: 60ch;
}
.hero-side {
display: grid;
gap: 12px;
align-content: start;
}
.hero-aside,
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.hero-panel::after {
content: "";
position: absolute;
top: -80px;
right: -40px;
width: 220px;
height: 220px;
background: radial-gradient(circle, rgba(7, 176, 147, 0.28), transparent 70%);
pointer-events: none;
}
.hero-panel h3,
.section-title {
margin: 10px 0 8px;
font-size: 1.4rem;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.metric-card {
position: relative;
overflow: hidden;
padding: 20px;
}
.metric-card--enhanced {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(244, 252, 249, 0.78));
}
.metric-card::before {
content: "";
position: absolute;
inset: auto -60px -60px auto;
width: 140px;
height: 140px;
border-radius: 999px;
background: radial-gradient(circle, rgba(7, 176, 147, 0.16), transparent 70%);
}
.metric-card[data-accent="amber"]::before {
background: radial-gradient(circle, rgba(239, 127, 65, 0.16), transparent 70%);
}
.metric-card[data-accent="slate"]::before {
background: radial-gradient(circle, rgba(54, 97, 135, 0.16), transparent 70%);
}
.metric-value {
margin: 10px 0 0;
font-size: clamp(1.8rem, 3vw, 2.5rem);
font-weight: 800;
}
.metric-footnote {
margin: 10px 0 0;
color: var(--sentinel-ink-soft);
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
gap: 24px;
}
.chart-card,
.table-card,
.form-card {
padding: 24px;
}
.chart-surface {
width: 100%;
min-height: 340px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.toolbar-left,
.toolbar-right {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.data-table .el-table {
--el-table-border-color: rgba(9, 22, 30, 0.08);
--el-table-header-bg-color: rgba(7, 176, 147, 0.08);
--el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05);
border-radius: 18px;
overflow: hidden;
}
.soft-grid {
display: grid;
gap: 16px;
}
.insight-card {
padding: 18px 20px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(12, 24, 33, 0.8));
color: #f2fffd;
}
.insight-value {
margin: 6px 0 0;
font-size: 1.65rem;
font-weight: 800;
}
.insight-note {
margin: 8px 0 0;
color: rgba(242, 255, 253, 0.72);
}
.table-stack {
display: grid;
gap: 14px;
}
.inline-meta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.login-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 24px;
padding: 24px;
}
.login-stage,
.login-card {
position: relative;
z-index: 1;
}
.login-stage {
padding: 42px;
display: flex;
flex-direction: column;
justify-content: space-between;
color: #f7fffe;
background:
radial-gradient(circle at top left, rgba(17, 231, 181, 0.24), transparent 28%),
linear-gradient(160deg, rgba(8, 24, 34, 0.95), rgba(15, 37, 50, 0.92));
}
.login-stage h1 {
margin: 12px 0;
font-size: clamp(2.4rem, 4vw, 4rem);
line-height: 0.96;
}
.login-copy {
max-width: 520px;
font-size: 1rem;
color: rgba(247, 255, 254, 0.78);
}
.login-card {
display: grid;
place-items: center;
padding: 36px;
}
.login-card-inner {
width: min(100%, 460px);
padding: 34px;
background: var(--sentinel-panel-strong);
border-radius: 32px;
border: 1px solid var(--sentinel-border);
box-shadow: var(--sentinel-shadow);
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(7, 176, 147, 0.12);
color: var(--sentinel-accent-deep);
font-weight: 700;
font-size: 0.82rem;
}
.stack {
display: grid;
gap: 24px;
}
.empty-state {
padding: 28px;
text-align: center;
color: var(--sentinel-ink-soft);
}
.brand-subtitle {
margin: 6px 0 0;
color: var(--sentinel-ink-soft);
font-size: 0.92rem;
}
.rail-grid {
display: grid;
gap: 10px;
}
.rail-card {
display: grid;
gap: 4px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.44);
border: 1px solid rgba(255, 255, 255, 0.26);
}
.rail-label,
.header-chip-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--sentinel-ink-soft);
}
.rail-meta {
color: var(--sentinel-ink-soft);
font-size: 0.86rem;
}
.header-copy {
display: grid;
gap: 4px;
}
.header-note {
margin: 0;
}
.header-chip-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.header-chip {
display: grid;
gap: 2px;
min-width: 140px;
padding: 10px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.56);
border: 1px solid rgba(255, 255, 255, 0.26);
}
.hero-stat-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.hero-stat {
padding: 14px 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 255, 252, 0.64));
border: 1px solid rgba(255, 255, 255, 0.36);
}
.hero-stat strong {
display: block;
margin-top: 6px;
font-size: 1.35rem;
}
.table-toolbar-block {
display: grid;
gap: 8px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
@media (max-width: 1180px) {
.metric-grid,
.content-grid,
.login-shell {
grid-template-columns: 1fr;
}
.hero-layout {
grid-template-columns: 1fr;
}
.hero-side {
justify-items: start;
}
.hero-aside,
.hero-actions {
justify-content: flex-start;
}
}
@media (max-width: 760px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-card,
.table-card,
.form-card,
.hero-panel {
padding: 18px;
}
}
@media (max-width: 560px) {
.metric-grid {
grid-template-columns: 1fr;
}
.login-shell {
padding: 16px;
}
}

View File

@@ -0,0 +1,51 @@
const integerFormatter = new Intl.NumberFormat()
const compactFormatter = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
})
const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
})
export function formatInteger(value) {
return integerFormatter.format(Number(value || 0))
}
export function formatCompactNumber(value) {
return compactFormatter.format(Number(value || 0))
}
export function formatDateTime(value) {
if (!value) {
return '--'
}
return dateTimeFormatter.format(new Date(value))
}
export function formatDate(value) {
if (!value) {
return '--'
}
return dateFormatter.format(new Date(value))
}
export function formatPercent(value, digits = 1) {
return `${(Number(value || 0) * 100).toFixed(digits)}%`
}
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
export function nonEmptyCount(items, predicate) {
return items.filter(predicate).length
}

View File

@@ -0,0 +1,266 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
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 filters = reactive({
token_suffix: '',
ip: '',
status: '',
page: 1,
page_size: 20,
})
const form = reactive({
id: null,
bound_ip: '',
})
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
const visibleProtectedRate = computed(() => {
if (!rows.value.length) {
return 0
}
return activeCount.value / rows.value.length
})
function requestParams() {
return {
page: filters.page,
page_size: filters.page_size,
token_suffix: filters.token_suffix || undefined,
ip: filters.ip || undefined,
status: filters.status || undefined,
}
}
async function loadBindings() {
loading.value = true
try {
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
}
}
function resetFilters() {
filters.token_suffix = ''
filters.ip = ''
filters.status = ''
filters.page = 1
loadBindings()
}
function openEdit(row) {
form.id = row.id
form.bound_ip = row.bound_ip
dialogVisible.value = true
}
async function submitEdit() {
if (!form.bound_ip) {
ElMessage.warning('Provide a CIDR or single IP.')
return
}
try {
await updateBindingIp({ id: form.id, bound_ip: form.bound_ip })
ElMessage.success('Binding updated.')
dialogVisible.value = false
await loadBindings()
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to update binding.'))
}
}
async function confirmAction(title, action) {
try {
await ElMessageBox.confirm(title, 'Confirm action', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
})
await action()
await loadBindings()
} catch (error) {
if (error === 'cancel') {
return
}
ElMessage.error(humanizeError(error, 'Operation failed.'))
}
}
function onPageChange(value) {
filters.page = value
loadBindings()
}
onMounted(() => {
loadBindings()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Binding control"
title="Inspect first-use bindings and intervene without touching proxy workers"
description="Edit CIDRs for device changes, remove stale registrations, or move leaked keys into a banned state."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Visible active share</span>
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Page volume</span>
<strong>{{ formatCompactNumber(rows.length) }}</strong>
</div>
</div>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Visible rows"
:value="formatCompactNumber(rows.length)"
note="Records loaded on the current page."
accent="slate"
/>
<MetricTile
eyebrow="Matching total"
:value="formatCompactNumber(total)"
note="Bindings matching current filters."
accent="mint"
/>
<MetricTile
eyebrow="Active rows"
:value="formatCompactNumber(activeCount)"
note="Active items visible in the current slice."
accent="mint"
/>
<MetricTile
eyebrow="Banned rows"
:value="formatCompactNumber(bannedCount)"
note="Banned items currently visible in the table."
accent="amber"
/>
</section>
<section class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" />
<el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" />
<el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;">
<el-option label="Active" :value="1" />
<el-option label="Banned" :value="2" />
</el-select>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadBindings()">Search</el-button>
</div>
</div>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
<el-table-column label="Status" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="first_used_at" label="First used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
</el-table-column>
<el-table-column prop="last_used_at" label="Last used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
</el-table-column>
<el-table-column label="Actions" min-width="280" fixed="right">
<template #default="{ row }">
<div class="toolbar-left">
<el-button size="small" @click="openEdit(row)">Edit IP</el-button>
<el-button
size="small"
type="danger"
plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
>
Unbind
</el-button>
<el-button
v-if="row.status === 1"
size="small"
type="warning"
plain
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
>
Ban
</el-button>
<el-button
v-else
size="small"
type="success"
plain
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
>
Unban
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/>
</div>
</section>
<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-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="submitEdit">Save</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,235 @@
<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 { 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 },
trend: [],
recent_intercepts: [],
})
const chartElement = ref(null)
let chart
const interceptRate = computed(() => {
const total = dashboard.value.today.total || 0
if (!total) {
return 0
}
return dashboard.value.today.intercepted / total
})
const bindingCoverage = computed(() => {
const active = dashboard.value.bindings.active || 0
const banned = dashboard.value.bindings.banned || 0
const total = active + banned
if (!total) {
return 0
}
return active / total
})
async function renderChart() {
await nextTick()
if (!chartElement.value) {
return
}
chart ||= echarts.init(chartElement.value)
chart.setOption({
animationDuration: 500,
color: ['#0b9e88', '#ef7f41'],
grid: {
left: 24,
right: 24,
top: 40,
bottom: 28,
containLabel: true,
},
legend: {
top: 0,
textStyle: {
color: '#516a75',
fontWeight: 600,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(8, 24, 34, 0.9)',
borderWidth: 0,
textStyle: {
color: '#f7fffe',
},
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dashboard.value.trend.map((item) => item.date.slice(5)),
axisLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.18)' } },
axisLabel: { color: '#516a75', fontWeight: 600 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.08)' } },
axisLabel: { color: '#516a75' },
},
series: [
{
name: 'Allowed',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(11, 158, 136, 0.14)' },
lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.allowed),
},
{
name: 'Intercepted',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(239, 127, 65, 0.12)' },
lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.intercepted),
},
],
})
}
async function loadDashboard() {
loading.value = true
try {
dashboard.value = await fetchDashboard()
await renderChart()
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load dashboard.'))
} finally {
loading.value = false
}
}
function resizeChart() {
chart?.resize()
}
const { start: startPolling, stop: stopPolling } = usePolling(loadDashboard, 30000)
onMounted(async () => {
await loadDashboard()
startPolling()
window.addEventListener('resize', resizeChart)
})
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('resize', resizeChart)
chart?.dispose()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Traffic pulse"
title="Edge decisions and security drift in one pass"
description="The dashboard combines live proxy metrics with persisted intercept records so security events remain visible even if Redis rolls over."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Intercept rate</span>
<strong>{{ formatPercent(interceptRate) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Active share</span>
<strong>{{ formatPercent(bindingCoverage) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button :loading="loading" type="primary" plain @click="loadDashboard">Refresh dashboard</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Today"
:value="formatCompactNumber(dashboard.today.total)"
note="Total edge decisions recorded today."
accent="slate"
/>
<MetricTile
eyebrow="Allowed"
:value="formatCompactNumber(dashboard.today.allowed)"
note="Requests that passed binding enforcement."
accent="mint"
/>
<MetricTile
eyebrow="Intercepted"
:value="formatCompactNumber(dashboard.today.intercepted)"
note="Requests blocked for CIDR mismatch or banned keys."
accent="amber"
/>
<MetricTile
eyebrow="Bindings"
:value="formatCompactNumber(dashboard.bindings.active)"
:note="`Active bindings, with ${formatCompactNumber(dashboard.bindings.banned)} banned keys in reserve.`"
accent="slate"
/>
</section>
<section class="content-grid">
<article class="chart-card panel">
<div class="toolbar">
<div>
<p class="eyebrow">7-day trend</p>
<h3 class="section-title">Allowed vs intercepted flow</h3>
</div>
<div class="inline-meta">
<el-tag round effect="plain" type="success">30s auto refresh</el-tag>
<span class="muted">Redis metrics with PostgreSQL intercept backfill.</span>
</div>
</div>
<div ref="chartElement" class="chart-surface" />
</article>
<article class="table-card panel">
<div class="table-toolbar-block">
<p class="eyebrow">Recent blocks</p>
<h3 class="section-title">Latest intercepted requests</h3>
<p class="muted">Operators can triage repeated misuse and verify whether alert escalation has already fired.</p>
</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-for="item in dashboard.recent_intercepts"
:key="item.id"
class="insight-card"
style="padding: 16px; border-radius: 20px;"
>
<div class="toolbar">
<strong>{{ item.token_display }}</strong>
<el-tag :type="item.alerted ? 'danger' : 'warning'" round>
{{ item.alerted ? 'Alerted' : 'Pending' }}
</el-tag>
</div>
<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>
</div>
</article>
</section>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { humanizeError, login, setAuthToken } from '../api'
const router = useRouter()
const loading = ref(false)
const form = reactive({
password: '',
})
async function submit() {
if (!form.password) {
ElMessage.warning('Enter the admin password first.')
return
}
loading.value = true
try {
const data = await login(form.password)
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
}
}
</script>
<template>
<div class="login-shell">
<section class="login-stage panel">
<div>
<p class="eyebrow">Edge enforcement</p>
<h1>Key-IP Sentinel</h1>
<p class="login-copy">
Lock every model API key to its first trusted origin. Monitor drift, inspect misuse, and react from one
hardened control surface.
</p>
</div>
<div class="stack">
<div class="status-chip">
<el-icon><Lock /></el-icon>
Zero-trust binding perimeter
</div>
<div class="status-chip">
<el-icon><Connection /></el-icon>
Live downstream relay with SSE passthrough
</div>
</div>
</section>
<section class="login-card">
<div class="login-card-inner">
<p class="eyebrow">Admin access</p>
<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"
show-password
size="large"
autocomplete="current-password"
@keyup.enter="submit"
/>
</el-form-item>
<el-button type="primary" size="large" :loading="loading" class="w-full" @click="submit">
Enter control plane
</el-button>
</el-form>
</div>
</section>
</div>
</template>
<style scoped>
.w-full {
width: 100%;
}
</style>

183
frontend/src/views/Logs.vue Normal file
View File

@@ -0,0 +1,183 @@
<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 { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters'
const loading = ref(false)
const exporting = ref(false)
const rows = ref([])
const total = ref(0)
const filters = reactive({
token: '',
attempt_ip: '',
time_range: [],
page: 1,
page_size: 20,
})
const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
function requestParams() {
return {
page: filters.page,
page_size: filters.page_size,
token: filters.token || undefined,
attempt_ip: filters.attempt_ip || undefined,
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
}
}
async function loadLogs() {
loading.value = true
try {
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
}
}
async function handleExport() {
exporting.value = true
try {
const blob = await exportLogs({
token: filters.token || undefined,
attempt_ip: filters.attempt_ip || undefined,
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
})
downloadBlob(blob, 'sentinel-logs.csv')
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to export logs.'))
} finally {
exporting.value = false
}
}
function resetFilters() {
filters.token = ''
filters.attempt_ip = ''
filters.time_range = []
filters.page = 1
loadLogs()
}
onMounted(() => {
loadLogs()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Audit trail"
title="Review blocked requests, escalation state, and repeated misuse patterns"
description="Intercept records stay in PostgreSQL even if Redis counters reset, so operators can reconstruct activity across the full retention window."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Alerted on page</span>
<strong>{{ formatCompactNumber(alertedCount) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Unique IPs</span>
<strong>{{ formatCompactNumber(uniqueAttempts) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button type="primary" plain :loading="exporting" @click="handleExport">Export CSV</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Page rows"
:value="formatCompactNumber(rows.length)"
note="Intercept rows visible in the current result page."
accent="slate"
/>
<MetricTile
eyebrow="Matching total"
:value="formatCompactNumber(total)"
note="Persisted intercepts matching the active filters."
accent="amber"
/>
<MetricTile
eyebrow="Alerted"
:value="formatCompactNumber(alertedCount)"
note="Visible rows that already triggered webhook escalation."
accent="amber"
/>
<MetricTile
eyebrow="Attempt sources"
:value="formatCompactNumber(uniqueAttempts)"
note="Distinct attempt IPs visible on the current page."
accent="mint"
/>
</section>
<section class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" />
<el-input v-model="filters.attempt_ip" placeholder="Attempt IP" clearable style="width: 180px;" />
<el-date-picker
v-model="filters.time_range"
type="datetimerange"
range-separator="to"
start-placeholder="Start time"
end-placeholder="End time"
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadLogs()">Search</el-button>
</div>
</div>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="intercepted_at" label="Time" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
</el-table-column>
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" />
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" />
<el-table-column label="Alerted" width="120">
<template #default="{ row }">
<el-tag :type="row.alerted ? 'danger' : 'info'" round>
{{ row.alerted ? 'Yes' : 'No' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Total matching logs: {{ total }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="(value) => { filters.page = value; loadLogs() }"
/>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<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 { fetchSettings, humanizeError, 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,
alert_threshold_seconds: 300,
archive_days: 90,
failsafe_mode: 'closed',
})
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60))
const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled'))
async function loadSettings() {
loading.value = true
try {
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
}
}
async function saveSettings() {
saving.value = true
try {
await updateSettings({
alert_webhook_url: form.alert_webhook_url || null,
alert_threshold_count: form.alert_threshold_count,
alert_threshold_seconds: form.alert_threshold_seconds,
archive_days: form.archive_days,
failsafe_mode: form.failsafe_mode,
})
ElMessage.success('Runtime settings updated.')
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to update runtime settings.'))
} finally {
saving.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Runtime controls"
title="Adjust alerting and retention without redeploying the app"
description="These values are persisted in Redis and applied live by the proxy, alerting, and archive scheduler services."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Failsafe</span>
<strong>{{ form.failsafe_mode }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Webhook</span>
<strong>{{ webhookState }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button type="primary" :loading="saving" @click="saveSettings">Apply runtime settings</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Threshold count"
:value="formatCompactNumber(form.alert_threshold_count)"
note="Intercepts needed before alert escalation fires."
accent="amber"
/>
<MetricTile
eyebrow="Threshold window"
:value="`${formatCompactNumber(thresholdMinutes)}m`"
note="Rolling window used by the Redis alert counter."
accent="slate"
/>
<MetricTile
eyebrow="Archive after"
:value="`${formatCompactNumber(form.archive_days)}d`"
note="Bindings older than this are pruned from the active table."
accent="mint"
/>
<MetricTile
eyebrow="Delivery"
:value="webhookState"
note="Webhook POST is optional and can be disabled."
accent="slate"
/>
</section>
<section class="content-grid">
<article class="form-card panel">
<p class="eyebrow">Alert window</p>
<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-form-item>
<el-form-item label="Intercept count threshold">
<el-input-number v-model="form.alert_threshold_count" :min="1" :max="100" />
</el-form-item>
<el-form-item label="Threshold window (seconds)">
<el-slider v-model="form.alert_threshold_seconds" :min="60" :max="3600" :step="30" show-input />
</el-form-item>
<el-form-item label="Failsafe mode">
<el-radio-group v-model="form.failsafe_mode">
<el-radio-button value="closed">Closed</el-radio-button>
<el-radio-button value="open">Open</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
</article>
<article class="form-card panel">
<p class="eyebrow">Retention</p>
<h3 class="section-title">Archive stale bindings</h3>
<el-form label-position="top" v-loading="loading">
<el-form-item label="Archive inactive bindings after N days">
<el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
</el-form-item>
</el-form>
<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>
</section>
</div>
</template>

21
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
base: '/admin/ui/',
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/admin/api': {
target: 'http://127.0.0.1:7000',
changeOrigin: true,
},
'/health': {
target: 'http://127.0.0.1:7000',
changeOrigin: true,
},
},
},
})