feat(core): 初始化 Key-IP Sentinel 服务与部署骨架
- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构 - 实现反向代理、首用绑定、拦截告警、归档任务和管理接口 - 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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
22
frontend/package.json
Normal 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
293
frontend/src/App.vue
Normal 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
103
frontend/src/api/index.js
Normal 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
|
||||
}
|
||||
28
frontend/src/components/MetricTile.vue
Normal file
28
frontend/src/components/MetricTile.vue
Normal 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>
|
||||
35
frontend/src/components/PageHero.vue
Normal file
35
frontend/src/components/PageHero.vue
Normal 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>
|
||||
26
frontend/src/composables/usePolling.js
Normal file
26
frontend/src/composables/usePolling.js
Normal 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
18
frontend/src/main.js
Normal 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')
|
||||
76
frontend/src/router/index.js
Normal file
76
frontend/src/router/index.js
Normal 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
477
frontend/src/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
frontend/src/utils/formatters.js
Normal file
51
frontend/src/utils/formatters.js
Normal 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
|
||||
}
|
||||
266
frontend/src/views/Bindings.vue
Normal file
266
frontend/src/views/Bindings.vue
Normal 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>
|
||||
235
frontend/src/views/Dashboard.vue
Normal file
235
frontend/src/views/Dashboard.vue
Normal 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>
|
||||
88
frontend/src/views/Login.vue
Normal file
88
frontend/src/views/Login.vue
Normal 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
183
frontend/src/views/Logs.vue
Normal 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>
|
||||
169
frontend/src/views/Settings.vue
Normal file
169
frontend/src/views/Settings.vue
Normal 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
21
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user