feat: add embedded web UI

Adds an optional web dashboard served by OpenGFW itself, enabled with a new
`web` section in the config file.

Backend (web package, decoupled from engine/io so it builds on any OS):
- hub.go collects statistics off the engine logger callbacks: atomic counters,
  two ring-buffered time series (10s and 1min buckets), top N hosts/blocked
  destinations/rules/analyzers, and a 512 entry event buffer fanned out to
  connected clients over SSE. Slow clients drop frames instead of blocking
  the engine.
- api.go exposes /api/v1 for info, meta, metrics, events, the SSE stream and
  ruleset read/validate/replace.
- auth.go implements password login with in-memory session tokens and login
  rate limiting. Mutating endpoints require the bearer token (the session
  cookie is only accepted for GET), which makes them CSRF-safe.
- cmd/web.go implements the rule manager: rules are compiled before anything
  is written, the file is replaced atomically and the engine is hot reloaded.
  The SIGHUP handler now shares that same path.
- web/devserver serves the UI with synthetic traffic for frontend work on
  machines where the engine itself cannot be built.

Frontend (web/frontend, Vue 3 + Vite + Tailwind CSS v4 + Reka UI):
dashboard, live event feed with analyzer property inspection, visual and YAML
rule editors, analyzer overview and settings. Responsive down to phone sizes
with a bottom tab bar and bottom-sheet dialogs, plus light/dark themes and
English/Chinese translations.

The built UI in web/dist is committed and embedded with go:embed so that
`go build` works without Node; CI builds the frontend and checks that the
committed output is up to date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mei
2026-07-27 08:09:11 +08:00
co-authored by Claude Opus 5
parent 393c29bd2d
commit 5a7722d1d2
94 changed files with 8534 additions and 23 deletions
+90
View File
@@ -0,0 +1,90 @@
# OpenGFW Web UI
A Vue 3 + Tailwind CSS dashboard for OpenGFW, built with [shadcn/ui](https://ui.shadcn.com)
style components on top of [Reka UI](https://reka-ui.com) primitives. It is embedded into
the OpenGFW binary and served by the `web` package.
- **Dashboard** — live counters, traffic chart, verdict/protocol split, top hosts,
blocked destinations and triggered rules
- **Events** — real-time feed of verdicts, rule logs and errors with filters and a
detail view showing raw analyzer properties
- **Rules** — visual rule editor and a raw YAML editor, both validated by the engine
itself; saving writes the rule file and hot reloads the running engine
- **Analyzers** — which analyzers are compiled in and how much traffic each one saw
- **Settings** — theme (light/dark/system), language (English/中文) and instance info
The layout is responsive: a sidebar on desktop, a bottom tab bar and bottom-sheet
dialogs on phones.
## Enabling it
```yaml
# config.yaml
web:
enabled: true
listen: :8080
secret: your-password-here
# cert: /path/to/fullchain.pem
# key: /path/to/privkey.pem
```
If `secret` is empty a random password is generated and printed to the log on startup.
## Layout
```
web/
├── api.go HTTP handlers (JSON API + SSE)
├── auth.go password login, session tokens
├── hub.go statistics collection and the live event fan-out
├── server.go routes, static file serving, public types
├── embed.go //go:embed of dist
├── devserver/ standalone server with synthetic data (any OS)
├── dist/ built UI, embedded into the binary (committed)
└── frontend/ Vue sources
```
## Development
The engine only builds on Linux, so for UI work there is a standalone server that
feeds the UI synthetic traffic and an in-memory ruleset:
```bash
go run ./web/devserver # http://127.0.0.1:8080, password: opengfw
```
Then, in another terminal, run Vite with hot reload (it proxies `/api` to `:8080`):
```bash
cd web/frontend
npm install
npm run dev
```
To produce the embedded build (this is what `make web` runs):
```bash
cd web/frontend && npm run build # writes ../dist
```
`web/dist` is committed so that `go build` works without Node installed. Rebuild it
whenever you change the frontend.
## API
All endpoints live under `/api/v1` and return JSON. Except for `login`, every request
must carry `Authorization: Bearer <token>`; `GET` endpoints also accept the session
cookie set at login, which is what the `EventSource` connection uses. Mutating
endpoints only accept the bearer token, which makes them immune to CSRF.
| Method | Path | Description |
| ---------- | -------------------- | -------------------------------------------------- |
| `POST` | `/login` | exchange the password for a session token |
| `POST` | `/logout` | invalidate the current session |
| `GET` | `/info` | version, platform and engine configuration |
| `GET` | `/meta` | available analyzers, modifiers, actions, functions |
| `GET` | `/metrics` | counters, time series and top N lists |
| `GET` | `/events?limit=` | recent events from the ring buffer |
| `GET` | `/live` | server-sent events: `event` and `metrics` frames |
| `GET/PUT` | `/rules` | read / replace the ruleset (`raw` YAML or `rules`) |
| `POST` | `/rules/validate` | compile without applying; also converts YAML ⇄ rules |
+294
View File
@@ -0,0 +1,294 @@
package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
)
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, apiError{Error: msg})
}
func methodAllowed(w http.ResponseWriter, r *http.Request, methods ...string) bool {
for _, m := range methods {
if r.Method == m {
return true
}
}
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return false
}
func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
r.Body = http.MaxBytesReader(w, r.Body, 4<<20) // 4 MiB is plenty for a rule file
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return false
}
return true
}
// POST /api/v1/login
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
var req struct {
Password string `json:"password"`
}
if !decodeBody(w, r, &req) {
return
}
token, expiry, err := s.auth.login(r.RemoteAddr, req.Password)
if err != nil {
code := http.StatusUnauthorized
if errors.Is(err, errTooManyAttempts) {
code = http.StatusTooManyRequests
}
writeError(w, code, err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: expiry,
HttpOnly: true,
Secure: s.TLS(),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"expiresAt": expiry.UnixMilli(),
})
}
// POST /api/v1/logout
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
s.auth.logout(bearerToken(r))
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.TLS(),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GET /api/v1/info
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
var info Info
if s.config.Info != nil {
info = s.config.Info()
}
writeJSON(w, http.StatusOK, info)
}
// GET /api/v1/meta
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, s.config.Meta)
}
// GET /api/v1/metrics
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, s.config.Hub.Metrics())
}
// GET /api/v1/events?limit=200
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
limit := 200
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"events": s.config.Hub.Events(limit),
})
}
// GET /api/v1/live - server-sent events carrying live events and metrics.
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
events, unsubscribe := s.config.Hub.Subscribe()
defer unsubscribe()
metricsTicker := time.NewTicker(2 * time.Second)
defer metricsTicker.Stop()
keepAlive := time.NewTicker(20 * time.Second)
defer keepAlive.Stop()
send := func(event string, v interface{}) bool {
data, err := json.Marshal(v)
if err != nil {
return true
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data); err != nil {
return false
}
flusher.Flush()
return true
}
if !send("metrics", s.config.Hub.Metrics()) {
return
}
for {
select {
case <-r.Context().Done():
return
case ev, ok := <-events:
if !ok {
return
}
if !send("event", ev) {
return
}
case <-metricsTicker.C:
if !send("metrics", s.config.Hub.Metrics()) {
return
}
case <-keepAlive.C:
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
type rulesRequest struct {
Raw string `json:"raw"`
Rules []Rule `json:"rules"`
}
type rulesResponse struct {
Path string `json:"path"`
Raw string `json:"raw"`
Rules []Rule `json:"rules"`
}
// resolveRaw turns a request into rule file content.
func (s *Server) resolveRaw(req rulesRequest) (string, error) {
if req.Rules != nil {
return s.config.Rules.Marshal(req.Rules)
}
return req.Raw, nil
}
// GET/PUT /api/v1/rules
func (s *Server) handleRules(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet, http.MethodPut) {
return
}
if s.config.Rules == nil {
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
return
}
if r.Method == http.MethodGet {
raw, rules, err := s.config.Rules.Load()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
return
}
var req rulesRequest
if !decodeBody(w, r, &req) {
return
}
raw, err := s.resolveRaw(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rules, err := s.config.Rules.Apply(raw)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.config.Logf("ruleset updated from web UI (%d rules)", len(rules))
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
}
// POST /api/v1/rules/validate
func (s *Server) handleRulesValidate(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
if s.config.Rules == nil {
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
return
}
var req rulesRequest
if !decodeBody(w, r, &req) {
return
}
raw, err := s.resolveRaw(req)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": err.Error()})
return
}
rules, err := s.config.Rules.Validate(raw)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": false,
"error": err.Error(),
"raw": raw,
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"rules": rules,
"raw": raw,
})
}
+152
View File
@@ -0,0 +1,152 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"net"
"net/http"
"sync"
"time"
)
const (
sessionCookieName = "opengfw_session"
sessionTTL = 7 * 24 * time.Hour
maxLoginFailures = 8
loginBanDuration = 5 * time.Minute
)
type authenticator struct {
secret string
mu sync.Mutex
sessions map[string]time.Time // token -> expiry
failures map[string]*failureRecord
}
type failureRecord struct {
count int
until time.Time
}
func newAuthenticator(secret string) *authenticator {
return &authenticator{
secret: secret,
sessions: make(map[string]time.Time),
failures: make(map[string]*failureRecord),
}
}
// RandomSecret generates a secret to be used when the user did not set one.
func RandomSecret() string {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "opengfw"
}
return hex.EncodeToString(b)
}
func newToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}
// login verifies the password and returns a new session token.
func (a *authenticator) login(remoteAddr, password string) (string, time.Time, error) {
ip := hostOnly(remoteAddr)
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
if rec, ok := a.failures[ip]; ok && rec.count >= maxLoginFailures && now.Before(rec.until) {
return "", time.Time{}, errTooManyAttempts
}
if subtle.ConstantTimeCompare([]byte(password), []byte(a.secret)) != 1 {
rec, ok := a.failures[ip]
if !ok || now.After(rec.until) {
rec = &failureRecord{}
a.failures[ip] = rec
}
rec.count++
rec.until = now.Add(loginBanDuration)
return "", time.Time{}, errBadCredentials
}
delete(a.failures, ip)
token := newToken()
if token == "" {
return "", time.Time{}, errInternal
}
expiry := now.Add(sessionTTL)
a.sessions[token] = expiry
a.gcLocked(now)
return token, expiry, nil
}
func (a *authenticator) logout(token string) {
if token == "" {
return
}
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
func (a *authenticator) valid(token string) bool {
if token == "" {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
expiry, ok := a.sessions[token]
if !ok {
return false
}
if time.Now().After(expiry) {
delete(a.sessions, token)
return false
}
return true
}
func (a *authenticator) gcLocked(now time.Time) {
for t, exp := range a.sessions {
if now.After(exp) {
delete(a.sessions, t)
}
}
for ip, rec := range a.failures {
if now.After(rec.until) {
delete(a.failures, ip)
}
}
}
// bearerToken returns the token carried by the Authorization header, if any.
func bearerToken(r *http.Request) string {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
return h[len(prefix):]
}
return ""
}
// cookieToken returns the token carried by the session cookie, if any.
func cookieToken(r *http.Request) string {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return ""
}
return c.Value
}
func hostOnly(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
+229
View File
@@ -0,0 +1,229 @@
// Command devserver runs the OpenGFW web UI against synthetic data.
//
// The engine itself only builds on Linux (it needs NFQueue), so this little
// program exists to let the frontend be developed and reviewed anywhere:
//
// go run ./web/devserver
//
// It serves the embedded UI on :8080 with the password "opengfw".
package main
import (
"context"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/apernet/OpenGFW/web"
"gopkg.in/yaml.v3"
)
const password = "opengfw"
func main() {
hub := web.NewHub()
rm := &memoryRules{}
if err := rm.init(); err != nil {
log.Fatal(err)
}
srv, err := web.NewServer(web.Config{
Listen: ":8080",
Secret: password,
Hub: hub,
Rules: rm,
Meta: web.MetaInfo{
Analyzers: []web.AnalyzerInfo{
{Name: "http", Proto: "tcp"}, {Name: "tls", Proto: "tcp"},
{Name: "ssh", Proto: "tcp"}, {Name: "socks", Proto: "tcp"},
{Name: "trojan", Proto: "tcp"}, {Name: "fet", Proto: "tcp"},
{Name: "dns", Proto: "udp"}, {Name: "quic", Proto: "udp"},
{Name: "openvpn", Proto: "udp"}, {Name: "wireguard", Proto: "udp"},
},
Modifiers: []string{"dns"},
Actions: []string{"allow", "block", "drop", "modify"},
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
},
Info: func() web.Info {
host, _ := os.Hostname()
return web.Info{
Version: "devserver",
Platform: runtime.GOOS + "/" + runtime.GOARCH,
GoVersion: runtime.Version(),
Hostname: host,
RuleFile: "rules.yaml (in memory)",
Config: web.ConfigDigest{
IOQueueSize: 1024, IORST: true, Workers: 4,
WorkerQueue: 64, UDPMaxStreams: 4096,
GeoIP: "geoip.dat", GeoSite: "geosite.dat",
},
}
},
Logf: log.Printf,
})
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
<-ch
cancel()
}()
for i := 0; i < 4; i++ {
hub.WorkerStarted()
}
go generate(ctx, hub)
log.Printf("web UI on http://127.0.0.1:8080 (password: %s)", password)
if err := srv.Run(ctx); err != nil {
log.Fatal(err)
}
}
var (
hosts = []string{
"www.google.com", "github.com", "cdn.jsdelivr.net", "telegram.org",
"ads.example.net", "tracker.evil.test", "api.openai.com", "www.wikipedia.org",
"registry.npmjs.org", "malware.bad.test",
}
ips = []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "104.16.132.229", "2606:4700::6810:84e5"}
)
// generate feeds the hub with plausible looking traffic.
func generate(ctx context.Context, hub *web.Hub) {
rng := rand.New(rand.NewSource(42))
ticker := time.NewTicker(120 * time.Millisecond)
defer ticker.Stop()
var id int64
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for n := rng.Intn(6); n >= 0; n-- {
id++
udp := rng.Intn(3) == 0
proto := "tcp"
if udp {
proto = "udp"
}
hub.StreamNew(proto)
host := hosts[rng.Intn(len(hosts))]
props := web.Props{}
if udp {
props["dns"] = web.PropMap{
"qr": false,
"questions": []map[string]interface{}{{"name": host, "type": 1}},
}
} else {
props["tls"] = web.PropMap{"req": map[string]interface{}{
"sni": host, "version": 771,
}}
}
hub.PropUpdate(props)
info := web.StreamInfo{
ID: id,
Proto: proto,
SrcIP: fmt.Sprintf("192.168.1.%d", 2+rng.Intn(60)),
SrcPort: uint16(20000 + rng.Intn(40000)),
DstIP: ips[rng.Intn(len(ips))],
DstPort: 443,
Props: props,
}
switch {
case rng.Intn(10) == 0:
hub.RuleLog(info, "log-suspicious")
hub.StreamAction(info, "block")
case rng.Intn(12) == 0:
hub.StreamAction(info, "drop")
case rng.Intn(14) == 0:
hub.StreamAction(info, "modify")
case rng.Intn(30) == 0:
hub.Error(info, "geoip-rule", "lookup timeout")
default:
hub.StreamAction(info, "allow")
}
}
}
}
}
// memoryRules is an in-memory web.RuleManager: it validates the YAML shape but
// does not compile expressions, which is enough for UI work.
type memoryRules struct {
raw string
rules []web.Rule
}
const seedRules = `- name: block-malware
action: block
log: true
expr: 'tls != nil && tls.req != nil && string(tls.req.sni) endsWith ".bad.test"'
- name: block-ads-dns
action: drop
expr: 'dns != nil && any(dns.questions, {.name endsWith "ads.example.net"})'
- name: log-ssh
log: true
expr: 'ssh != nil'
`
func (m *memoryRules) init() error {
rules, err := parse(seedRules)
if err != nil {
return err
}
m.raw, m.rules = seedRules, rules
return nil
}
func (m *memoryRules) Path() string { return "rules.yaml" }
func (m *memoryRules) Load() (string, []web.Rule, error) { return m.raw, m.rules, nil }
func (m *memoryRules) Validate(raw string) ([]web.Rule, error) { return parse(raw) }
func (m *memoryRules) Marshal(rules []web.Rule) (string, error) {
bs, err := yaml.Marshal(rules)
return string(bs), err
}
func (m *memoryRules) Apply(raw string) ([]web.Rule, error) {
rules, err := parse(raw)
if err != nil {
return nil, err
}
m.raw, m.rules = raw, rules
return rules, nil
}
func parse(raw string) ([]web.Rule, error) {
var rules []web.Rule
if err := yaml.Unmarshal([]byte(raw), &rules); err != nil {
return nil, err
}
for i, r := range rules {
if r.Name == "" {
return nil, fmt.Errorf("rule #%d has no name", i+1)
}
if r.Expr == "" {
return nil, fmt.Errorf("rule %q has no expression", r.Name)
}
if r.Action == "" && !r.Log {
return nil, fmt.Errorf("rule %q must have at least one of action or log", r.Name)
}
}
return rules, nil
}
+11
View File
@@ -0,0 +1,11 @@
import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js";import{_ as y}from"./Badge.vue_vue_type_script_setup_true_lang--8bF8KCa.js";import{c as M,d as B,K as L,M as j,a as o,p as t,u as e,l as r,F as f,r as p,b as u,w as s,_ as g,m as F,g as v,o as n,h,t as l,k as i,j as N,q}from"./index-D3ZCYstt.js";import{_ as k,a as x,b as z,c as w}from"./CardTitle.vue_vue_type_script_setup_true_lang-BcE23HB-.js";import{_ as A}from"./Skeleton.vue_vue_type_script_setup_true_lang-C9uTg8Ty.js";import{c as C}from"./format-B6sc_rZ4.js";/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const D=M("BoxesIcon",[["path",{d:"M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z",key:"lc1i9w"}],["path",{d:"m7 16.5-4.74-2.85",key:"1o9zyk"}],["path",{d:"m7 16.5 5-3",key:"va8pkn"}],["path",{d:"M7 16.5v5.17",key:"jnp8gn"}],["path",{d:"M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z",key:"8zsnat"}],["path",{d:"m17 16.5-5-3",key:"8arw3v"}],["path",{d:"m17 16.5 4.74-2.85",key:"8rfmw"}],["path",{d:"M17 16.5v5.17",key:"k6z78m"}],["path",{d:"M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z",key:"1xygjf"}],["path",{d:"M12 8 7.26 5.15",key:"1vbdud"}],["path",{d:"m12 8 4.74-2.85",key:"3rx089"}],["path",{d:"M12 13.5V8",key:"1io7kd"}]]);/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const I=M("SquareFunctionIcon",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["path",{d:"M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3",key:"m1af9g"}],["path",{d:"M9 11.2h5.7",key:"3zgcl2"}]]),S={key:0,class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},Z={class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},E={class:"flex items-center justify-between gap-2"},K={class:"truncate font-mono text-sm font-medium"},T={class:"mt-2 text-xl font-semibold tabular-nums"},G={class:"text-muted-foreground text-xs"},H={class:"mt-3 grid gap-3 md:grid-cols-2"},J={key:0,class:"text-muted-foreground text-xs"},Y=B({__name:"AnalyzersView",setup(O){const{metrics:V}=q(),c=F(null);L(async()=>{try{c.value=await j.meta()}catch{c.value={analyzers:[],modifiers:[],actions:[],functions:[]}}});const _=v(()=>{var m;const d=new Map;for(const a of((m=V.value)==null?void 0:m.analyzers)??[])d.set(a.name,a.count);return d}),$=v(()=>{var d;return[...((d=c.value)==null?void 0:d.analyzers)??[]].sort((m,a)=>(_.value.get(a.name)??0)-(_.value.get(m.name)??0))});return(d,m)=>(n(),o("div",null,[t(b,{title:e(r)("analyzers.title"),description:e(r)("analyzers.subtitle")},null,8,["title","description"]),c.value?(n(),o(f,{key:1},[u("div",Z,[(n(!0),o(f,null,p($.value,a=>(n(),h(e(g),{key:a.name,class:"p-3.5 sm:p-4"},{default:s(()=>[u("div",E,[u("span",K,l(a.name),1),t(e(y),{variant:a.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:s(()=>[i(l(a.proto),1)]),_:2},1032,["variant"])]),u("p",T,l(e(C)(_.value.get(a.name)??0)),1),u("p",G,l(e(r)("analyzers.hits")),1)]),_:2},1024))),128))]),u("div",H,[t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(D),{class:"size-4"}),i(" "+l(e(r)("analyzers.modifiers")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.modifiersDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.modifiers,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a),1)]),_:2},1024))),128)),c.value.modifiers.length?N("",!0):(n(),o("span",J,"—"))]),_:1})]),_:1}),t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(I),{class:"size-4"}),i(" "+l(e(r)("analyzers.functions")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.functionsDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.functions,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a)+"() ",1)]),_:2},1024))),128))]),_:1})]),_:1})])],64)):(n(),o("div",S,[(n(),o(f,null,p(8,a=>t(e(A),{key:a,class:"h-24"})),64))]))]))}});export{Y as default};
@@ -0,0 +1 @@
import{d as o,a as n,e as d,u as e,f as s,H as l,o as p,a0 as c}from"./index-D3ZCYstt.js";const b=c("inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground",secondary:"border-transparent bg-secondary text-secondary-foreground",outline:"text-foreground",muted:"border-transparent bg-muted text-muted-foreground",allow:"border-allow/25 bg-allow/12 text-allow",block:"border-block/25 bg-block/12 text-block",drop:"border-drop/25 bg-drop/12 text-drop",modify:"border-modify/25 bg-modify/12 text-modify",log:"border-log/25 bg-log/12 text-log",tcp:"border-tcp/25 bg-tcp/12 text-tcp",udp:"border-udp/25 bg-udp/12 text-udp"}},defaultVariants:{variant:"default"}}),g=o({__name:"Badge",props:{variant:{},class:{}},setup(t){const r=t;return(a,u)=>(p(),n("span",{class:d(e(s)(e(b)({variant:r.variant}),r.class))},[l(a.$slots,"default")],2))}});export{g as _};
@@ -0,0 +1 @@
import{d as a,o as n,a as r,e as c,u as o,f as p,H as l}from"./index-D3ZCYstt.js";const u=a({__name:"CardContent",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("px-4 pb-4 sm:px-5 sm:pb-5",e.class))},[l(t.$slots,"default")],2))}}),d=a({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("p",{class:c(o(p)("text-muted-foreground text-xs sm:text-sm",e.class))},[l(t.$slots,"default")],2))}}),f=a({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5",e.class))},[l(t.$slots,"default")],2))}}),i=a({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("h3",{class:c(o(p)("text-sm leading-none font-semibold tracking-tight sm:text-base",e.class))},[l(t.$slots,"default")],2))}});export{f as _,i as a,d as b,u as c};
+1
View File
@@ -0,0 +1 @@
import{ax as I,ay as $,m as v,g as p,az as j,af as S,d as C,ad as b,ak as g,aA as w,x as k,aB as P}from"./index-D3ZCYstt.js";function T(e,i){const t=typeof e=="string"&&!i?`${e}Context`:i,o=Symbol(t);return[a=>{const c=I(o,a);if(c||c===null)return c;throw new Error(`Injection \`${o.toString()}\` not found. Component must be used within ${Array.isArray(e)?`one of the following components: ${e.join(", ")}`:`\`${e}\``}`)},a=>($(o,a),a)]}function D(){let e=document.activeElement;if(e==null)return null;for(;e!=null&&e.shadowRoot!=null&&e.shadowRoot.activeElement!=null;)e=e.shadowRoot.activeElement;return e}const[h]=T("ConfigProvider");function _(e){const i=h({dir:v("ltr")});return p(()=>{var t;return(e==null?void 0:e.value)||((t=i.dir)==null?void 0:t.value)||"ltr"})}function K(e,i="reka"){var n;let t;const o=h({useId:void 0});return o.useId?t=o.useId():t=(n=j)==null?void 0:n(),i?`${i}-${t}`:t}function x(){const e=v(),i=p(()=>{var t,o;return["#text","#comment"].includes((t=e.value)==null?void 0:t.$el.nodeName)?(o=e.value)==null?void 0:o.$el.nextElementSibling:S(e)});return{primitiveElement:e,currentElement:i}}const A="data-reka-collection-item";function q(e={}){const{key:i="",isProvider:t=!1}=e,o=`${i}CollectionProvider`;let n;if(t){const u=v(new Map);n={collectionRef:v(),itemMap:u},$(o,n)}else n=I(o);const y=(u=!1)=>{const r=n.collectionRef.value;if(!r)return[];const f=Array.from(r.querySelectorAll(`[${A}]`)),m=new Map(f.map((l,E)=>[l,E])),d=Array.from(n.itemMap.value.values()).sort((l,E)=>(m.get(l.ref)??-1)-(m.get(E.ref)??-1));return u?d:d.filter(l=>l.ref.dataset.disabled!=="")},a=C({name:"CollectionSlot",inheritAttrs:!1,setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=x();return k(s,()=>{n.collectionRef.value=s.value}),()=>g(w,{ref:m,...f},r)}}),c=C({name:"CollectionItem",inheritAttrs:!1,props:{value:{validator:()=>!0}},setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=x();return b(d=>{if(s.value){const l=P(s.value);n.itemMap.value.set(l,{ref:s.value,value:u.value}),d(()=>n.itemMap.value.delete(l))}}),()=>g(w,{...f,[A]:"",ref:m},r)}}),M=p(()=>Array.from(n.itemMap.value.values())),R=p(()=>n.itemMap.value.size);return{getItems:y,reactiveItems:M,itemMapSize:R,CollectionSlot:a,CollectionItem:c}}export{_ as a,q as b,T as c,K as d,D as g,h as i,x as u};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
import{_ as a}from"./Badge.vue_vue_type_script_setup_true_lang--8bF8KCa.js";import{c as h,d as x,o,h as m,u as t,w as c,k as i,t as n,g as v,l as d,a as k,b as r,p,j as f}from"./index-D3ZCYstt.js";import{t as b,e as _}from"./format-B6sc_rZ4.js";/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const w=h("ArrowRightIcon",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]),y=x({__name:"ActionBadge",props:{event:{}},setup(l){const e=l,u=v(()=>{if(e.event.kind==="error")return"block";if(e.event.kind==="log")return"log";switch(e.event.action){case"allow":return"allow";case"block":return"block";case"drop":return"drop";case"modify":return"modify";default:return"muted"}}),s=v(()=>e.event.kind==="error"?d("action.error"):e.event.kind==="log"?d("action.log"):d(`action.${e.event.action??"maybe"}`));return(g,N)=>(o(),m(t(a),{variant:u.value,class:"uppercase"},{default:c(()=>[i(n(s.value),1)]),_:1},8,["variant"]))}}),B={class:"flex items-center gap-2"},I={class:"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums"},$={class:"flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs"},A={class:"text-muted-foreground truncate"},C={class:"truncate"},E=x({__name:"EventItem",props:{event:{}},emits:["select"],setup(l){const e=l;return(u,s)=>(o(),k("button",{type:"button",class:"hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3",onClick:s[0]||(s[0]=g=>u.$emit("select",e.event))},[r("div",B,[r("span",I,n(t(b)(e.event.time)),1),p(y,{event:e.event},null,8,["event"]),p(t(a),{variant:e.event.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:c(()=>[i(n(e.event.proto),1)]),_:1},8,["variant"]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"max-w-32 truncate sm:hidden"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]),r("div",$,[r("span",A,n(t(_)(e.event.srcIP,e.event.srcPort)),1),p(t(w),{class:"text-muted-foreground/60 size-3 shrink-0"}),r("span",C,n(e.event.host||t(_)(e.event.dstIP,e.event.dstPort)),1)]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"hidden max-w-40 truncate sm:inline-flex"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]))}});export{w as A,E as _,y as a};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{d as c,a as t,b as n,t as i,j as a,H as d,o as s}from"./index-D3ZCYstt.js";const l={class:"mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6"},m={class:"min-w-0"},p={class:"text-lg font-semibold tracking-tight sm:text-2xl"},_={key:0,class:"text-muted-foreground mt-0.5 text-xs sm:text-sm"},x={key:0,class:"flex shrink-0 items-center gap-2"},g=c({__name:"PageHeader",props:{title:{},description:{}},setup(r){const e=r;return(o,f)=>(s(),t("div",l,[n("div",m,[n("h1",p,i(e.title),1),e.description?(s(),t("p",_,i(e.description),1)):a("",!0)]),o.$slots.actions?(s(),t("div",x,[d(o.$slots,"actions")])):a("",!0)]))}});export{g as _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
import{c as y,d as M,o as u,a as r,e as L,u as t,f as T,K as F,M as z,p as a,l as e,w as n,_ as f,m as R,k as c,t as o,b as i,F as _,r as p,h as D,i as N,s as $,a1 as E,a2 as q,g as v,a3 as H,v as K,a4 as U,a5 as A}from"./index-D3ZCYstt.js";import{_ as G}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js";import{_ as x,a as h,c as b,b as J}from"./CardTitle.vue_vue_type_script_setup_true_lang-BcE23HB-.js";import{_ as P}from"./Select.vue_vue_type_script_setup_true_lang-C15l-n0X.js";import"./Collection-D-pf6tmY.js";/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const W=y("BookOpenIcon",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const X=y("LogOutIcon",[["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}],["polyline",{points:"16 17 21 12 16 7",key:"1gabdz"}],["line",{x1:"21",x2:"9",y1:"12",y2:"12",key:"1uyos4"}]]);/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Y=y("MonitorIcon",[["rect",{width:"20",height:"14",x:"2",y:"3",rx:"2",key:"48i651"}],["line",{x1:"8",x2:"16",y1:"21",y2:"21",key:"1svkeh"}],["line",{x1:"12",x2:"12",y1:"17",y2:"21",key:"vw1qmm"}]]),Z=M({__name:"Separator",props:{class:{},orientation:{default:"horizontal"}},setup(k){const g=k;return(w,S)=>(u(),r("div",{role:"separator",class:L(t(T)("bg-border shrink-0",g.orientation==="vertical"?"h-full w-px":"h-px w-full",g.class))},null,2))}}),ee={class:"max-w-3xl"},te={class:"flex flex-col gap-2"},se={class:"text-sm font-medium"},ae={class:"bg-muted grid grid-cols-3 gap-1 rounded-lg p-1"},le=["onClick"],ne={class:"flex items-center justify-between gap-4"},oe={class:"text-sm font-medium"},ie={class:"flex flex-col"},ue={class:"text-muted-foreground shrink-0"},re={class:"truncate font-mono text-xs sm:text-[13px]"},ce={class:"flex flex-col"},de={class:"text-muted-foreground shrink-0"},ge={class:"truncate font-mono text-xs sm:text-[13px]"},he=M({__name:"SettingsView",setup(k){const g=K(),{theme:w,setTheme:S}=E(),{lang:V,setLang:C}=q(),m=R(null);F(async()=>{try{m.value=await z.info()}catch{}});const B=v(()=>[{value:"light",label:e("settings.themeLight"),icon:U},{value:"dark",label:e("settings.themeDark"),icon:A},{value:"system",label:e("settings.themeSystem"),icon:Y}]),Q=[{value:"en",label:"English"},{value:"zh",label:"中文"}],j=v(()=>{const s=m.value;return s?[{label:e("settings.version"),value:s.commit?`${s.version} (${s.commit})`:s.version},{label:e("settings.platform"),value:s.platform},{label:e("settings.goVersion"),value:s.goVersion},{label:e("settings.hostname"),value:s.hostname},{label:e("settings.ruleFile"),value:s.ruleFile}]:[]}),I=v(()=>{var l;const s=(l=m.value)==null?void 0:l.config;if(!s)return[];const d=[{label:e("settings.workers"),value:s.workers>0?String(s.workers):e("settings.auto")},{label:e("settings.workerQueueSize"),value:s.workerQueueSize>0?String(s.workerQueueSize):e("settings.auto")},{label:e("settings.ioQueueSize"),value:s.ioQueueSize>0?String(s.ioQueueSize):e("settings.auto")},{label:e("settings.ioLocal"),value:s.ioLocal?e("common.enabled"):e("common.disabled")},{label:e("settings.ioRST"),value:s.ioRST?e("common.enabled"):e("common.disabled")},{label:e("settings.udpMaxStreams"),value:s.udpMaxStreams>0?String(s.udpMaxStreams):e("settings.auto")}];return s.geoip&&d.push({label:e("settings.geoip"),value:s.geoip}),s.geosite&&d.push({label:e("settings.geosite"),value:s.geosite}),d});async function O(){H(),await z.logout(),await g.replace({name:"login"})}return(s,d)=>(u(),r("div",ee,[a(G,{title:t(e)("settings.title"),description:t(e)("settings.subtitle")},null,8,["title","description"]),a(t(f),null,{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.appearance")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-col gap-4"},{default:n(()=>[i("div",te,[i("span",se,o(t(e)("settings.theme")),1),i("div",ae,[(u(!0),r(_,null,p(B.value,l=>(u(),r("button",{key:l.value,type:"button",class:L(["flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm",t(w)===l.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground"]),onClick:me=>t(S)(l.value)},[(u(),D(N(l.icon),{class:"size-3.5"})),c(" "+o(l.label),1)],10,le))),128))])]),i("div",ne,[i("span",oe,o(t(e)("settings.language")),1),a(t(P),{"model-value":t(V),options:Q,class:"w-36","aria-label":t(e)("settings.language"),"onUpdate:modelValue":d[0]||(d[0]=l=>t(C)(l))},null,8,["model-value","aria-label"])])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.instance")),1)]),_:1}),a(t(J),null,{default:n(()=>[c(o(t(e)("settings.engineConfig")),1)]),_:1})]),_:1}),a(t(b),null,{default:n(()=>[i("dl",ie,[(u(!0),r(_,null,p(j.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",ue,o(l.label),1),i("dd",re,o(l.value),1)]))),128))]),a(t(Z),{class:"my-3"}),i("dl",ce,[(u(!0),r(_,null,p(I.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",de,o(l.label),1),i("dd",ge,o(l.value),1)]))),128))])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.session")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-wrap gap-2"},{default:n(()=>[a(t($),{variant:"destructive",onClick:O},{default:n(()=>[a(t(X),{class:"size-4"}),c(" "+o(t(e)("settings.logout")),1)]),_:1}),a(t($),{variant:"outline",as:"a",href:"https://gfw.dev/",target:"_blank",rel:"noreferrer"},{default:n(()=>[a(t(W),{class:"size-4"}),c(" "+o(t(e)("settings.docs")),1)]),_:1})]),_:1})]),_:1})]))}});export{he as default};
@@ -0,0 +1 @@
import{d as a,o as n,a as o,e as t,u as c,f as r}from"./index-D3ZCYstt.js";const u=a({__name:"Skeleton",props:{class:{}},setup(e){const s=e;return(l,m)=>(n(),o("div",{class:t(c(r)("bg-muted animate-pulse rounded-md",s.class))},null,2))}});export{u as _};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function o(t){if(!Number.isFinite(t))return"0";if(t<1e3)return String(t);const e=["k","M","G","T"];let n=t,r=-1;for(;n>=1e3&&r<e.length-1;)n/=1e3,r++;return`${n>=100?Math.round(n):n.toFixed(1).replace(/\.0$/,"")}${e[r]}`}function i(t,e){return e?Math.round(t/e*100):0}function u(t){if(t<60)return`${Math.max(0,Math.floor(t))}s`;const e=Math.floor(t/86400),n=Math.floor(t%86400/3600),r=Math.floor(t%3600/60);return e>0?`${e}d ${n}h`:n>0?`${n}h ${r}m`:`${r}m`}function a(t){return new Date(t).toLocaleTimeString(void 0,{hour12:!1})}function f(t){const e=new Date(t);return`${e.toLocaleDateString()} ${e.toLocaleTimeString(void 0,{hour12:!1})}`}function c(t,e){return t.includes(":")?`[${t}]:${e}`:`${t}:${e}`}export{f as a,o as c,u as d,c as e,i as p,a as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#18181b" />
<path
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
fill="none"
stroke="#a1a1aa"
stroke-width="2"
stroke-linejoin="round"
/>
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
/>
<meta name="theme-color" content="#09090b" />
<meta name="color-scheme" content="light dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>OpenGFW</title>
<script>
// Apply the stored theme before first paint to avoid a flash.
;(function () {
try {
var t = localStorage.getItem('opengfw.theme') || 'system'
var dark =
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
} catch (e) {}
})()
</script>
<script type="module" crossorigin src="/assets/index-D3ZCYstt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CNsDVyYx.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
package web
import "embed"
// distFS holds the compiled web UI. Run `make web` (or `npm run build` inside
// web/frontend) to refresh it; the checked-in placeholder simply tells the user
// that the UI has not been built yet.
//
//go:embed all:dist
var distFS embed.FS
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
/>
<meta name="theme-color" content="#09090b" />
<meta name="color-scheme" content="light dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>OpenGFW</title>
<script>
// Apply the stored theme before first paint to avoid a flash.
;(function () {
try {
var t = localStorage.getItem('opengfw.theme') || 'system'
var dark =
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
} catch (e) {}
})()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2501
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "opengfw-web",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Web UI for OpenGFW",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc -b --noEmit"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.469.0",
"reka-ui": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.10.5",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.1.11",
"typescript": "~5.7.3",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#18181b" />
<path
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
fill="none"
stroke="#a1a1aa"
stroke-width="2"
stroke-linejoin="round"
/>
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import Toaster from '@/components/Toaster.vue'
</script>
<template>
<RouterView />
<Toaster />
</template>
+208
View File
@@ -0,0 +1,208 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
/* Verdict colors, shared by badges, charts and the live feed. */
--allow: oklch(0.63 0.14 155);
--block: oklch(0.58 0.22 27);
--drop: oklch(0.72 0.16 70);
--modify: oklch(0.6 0.16 255);
--log: oklch(0.62 0.11 215);
--tcp: oklch(0.62 0.15 265);
--udp: oklch(0.68 0.13 190);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.19 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--allow: oklch(0.72 0.16 155);
--block: oklch(0.68 0.2 22);
--drop: oklch(0.79 0.15 75);
--modify: oklch(0.7 0.15 255);
--log: oklch(0.72 0.11 215);
--tcp: oklch(0.7 0.15 265);
--udp: oklch(0.76 0.12 190);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-allow: var(--allow);
--color-block: var(--block);
--color-drop: var(--drop);
--color-modify: var(--modify);
--color-log: var(--log);
--color-tcp: var(--tcp);
--color-udp: var(--udp);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
--animate-fade-in: fade-in 0.18s ease-out;
--animate-slide-up: slide-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
--animate-slide-in-right: slide-in-right 0.24s cubic-bezier(0.32, 0.72, 0, 1);
--animate-slide-in-left: slide-in-left 0.24s cubic-bezier(0.32, 0.72, 0, 1);
--animate-pulse-dot: pulse-dot 1.8s ease-in-out infinite;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@layer base {
* {
border-color: var(--color-border);
}
html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family:
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
overscroll-behavior-y: none;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-muted-foreground) 35%, transparent);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-muted-foreground) 55%, transparent);
background-clip: content-box;
}
}
@utility safe-bottom {
padding-bottom: max(env(safe-area-inset-bottom), 0px);
}
@utility safe-top {
padding-top: max(env(safe-area-inset-top), 0px);
}
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Badge } from '@/components/ui'
import { t } from '@/i18n'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
const variant = computed(() => {
if (props.event.kind === 'error') return 'block'
if (props.event.kind === 'log') return 'log'
switch (props.event.action) {
case 'allow':
return 'allow'
case 'block':
return 'block'
case 'drop':
return 'drop'
case 'modify':
return 'modify'
default:
return 'muted'
}
})
const label = computed(() => {
if (props.event.kind === 'error') return t('action.error')
if (props.event.kind === 'log') return t('action.log')
return t(`action.${props.event.action ?? 'maybe'}`)
})
</script>
<template>
<Badge :variant="variant" class="uppercase">{{ label }}</Badge>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { Activity, LayoutDashboard, ListFilter, Radio, Settings2 } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import StatusPill from '@/components/StatusPill.vue'
import { startLive, stopLive } from '@/composables/useLive'
import { useTheme } from '@/composables/useTheme'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const route = useRoute()
useTheme()
const items = computed(() => [
{ to: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
{ to: '/events', label: t('nav.events'), icon: Radio },
{ to: '/rules', label: t('nav.rules'), icon: ListFilter },
{ to: '/analyzers', label: t('nav.analyzers'), icon: Activity },
{ to: '/settings', label: t('nav.settings'), icon: Settings2 },
])
const title = computed(() => {
const item = items.value.find((i) => i.to === route.path)
return item?.label ?? t('app.name')
})
onMounted(startLive)
onUnmounted(stopLive)
</script>
<template>
<div class="bg-background min-h-dvh">
<!-- Desktop sidebar -->
<aside
class="bg-card/40 fixed inset-y-0 left-0 z-40 hidden w-56 flex-col border-r px-3 py-4 md:flex lg:w-60"
>
<div class="flex items-center gap-2 px-2 pb-4">
<div
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg font-bold"
>
G
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ t('app.name') }}</p>
<p class="text-muted-foreground truncate text-[11px]">{{ t('app.tagline') }}</p>
</div>
</div>
<nav class="flex flex-1 flex-col gap-0.5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
route.path === item.to
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)
"
>
<component :is="item.icon" class="size-4 shrink-0" />
{{ item.label }}
</RouterLink>
</nav>
<div class="px-2 pt-3">
<StatusPill />
</div>
</aside>
<div class="flex min-h-dvh flex-col md:pl-56 lg:pl-60">
<!-- Mobile top bar -->
<header
class="bg-background/85 safe-top sticky top-0 z-30 border-b backdrop-blur-md md:hidden"
>
<div class="flex h-12 items-center justify-between gap-3 px-4">
<span class="truncate text-sm font-semibold">{{ title }}</span>
<StatusPill />
</div>
</header>
<main class="flex-1 px-4 pt-4 pb-24 sm:px-6 sm:pt-6 md:pb-8">
<RouterView v-slot="{ Component }">
<component :is="Component" class="animate-fade-in" />
</RouterView>
</main>
</div>
<!-- Mobile bottom navigation -->
<nav
class="bg-background/90 safe-bottom fixed inset-x-0 bottom-0 z-40 border-t backdrop-blur-md md:hidden"
>
<div class="grid grid-cols-5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex flex-col items-center gap-0.5 py-2 text-[10px] font-medium transition-colors',
route.path === item.to ? 'text-foreground' : 'text-muted-foreground',
)
"
>
<component
:is="item.icon"
:class="cn('size-5', route.path === item.to && 'text-primary')"
/>
<span class="truncate">{{ item.label }}</span>
</RouterLink>
</div>
</nav>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { compact } from '@/lib/format'
import type { NameCount } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ items: NameCount[]; tone?: 'primary' | 'block' | 'allow'; empty?: string }>(),
{ tone: 'primary' },
)
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)))
const bars: Record<string, string> = {
primary: 'bg-primary/15',
block: 'bg-block/15',
allow: 'bg-allow/15',
}
</script>
<template>
<div v-if="props.items.length" class="flex flex-col gap-1">
<div
v-for="item in props.items"
:key="item.name"
class="relative flex items-center justify-between gap-3 overflow-hidden rounded-md px-2 py-1.5"
>
<div
:class="cn('absolute inset-y-0 left-0 rounded-md', bars[props.tone])"
:style="{ width: `${Math.max(4, (item.count / max) * 100)}%` }"
/>
<span class="relative truncate font-mono text-xs sm:text-[13px]">{{ item.name }}</span>
<span class="text-muted-foreground relative shrink-0 text-xs tabular-nums">{{
compact(item.count)
}}</span>
</div>
</div>
<p v-else class="text-muted-foreground py-6 text-center text-xs">
{{ props.empty ?? '—' }}
</p>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ArrowRight } from 'lucide-vue-next'
import ActionBadge from '@/components/ActionBadge.vue'
import { Badge } from '@/components/ui'
import { endpoint, time } from '@/lib/format'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
defineEmits<{ select: [event: LiveEvent] }>()
</script>
<template>
<button
type="button"
class="hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3"
@click="$emit('select', props.event)"
>
<div class="flex items-center gap-2">
<span class="text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums">
{{ time(props.event.time) }}
</span>
<ActionBadge :event="props.event" />
<Badge :variant="props.event.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ props.event.proto }}
</Badge>
<Badge v-if="props.event.rule" variant="outline" class="max-w-32 truncate sm:hidden">
{{ props.event.rule }}
</Badge>
</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs">
<span class="text-muted-foreground truncate">
{{ endpoint(props.event.srcIP, props.event.srcPort) }}
</span>
<ArrowRight class="text-muted-foreground/60 size-3 shrink-0" />
<span class="truncate">
{{ props.event.host || endpoint(props.event.dstIP, props.event.dstPort) }}
</span>
</div>
<Badge v-if="props.event.rule" variant="outline" class="hidden max-w-40 truncate sm:inline-flex">
{{ props.event.rule }}
</Badge>
</button>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineProps<{ title: string; description?: string }>()
</script>
<template>
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6">
<div class="min-w-0">
<h1 class="text-lg font-semibold tracking-tight sm:text-2xl">{{ props.title }}</h1>
<p v-if="props.description" class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
{{ props.description }}
</p>
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Card } from '@/components/ui'
import { cn } from '@/lib/utils'
const props = defineProps<{
label: string
value: string
hint?: string
icon?: Component
tone?: 'default' | 'allow' | 'block' | 'drop' | 'modify'
}>()
const tones: Record<string, string> = {
default: 'text-muted-foreground',
allow: 'text-allow',
block: 'text-block',
drop: 'text-drop',
modify: 'text-modify',
}
</script>
<template>
<Card class="p-3.5 sm:p-4">
<div class="flex items-start justify-between gap-2">
<span class="text-muted-foreground text-xs font-medium sm:text-sm">{{ props.label }}</span>
<component
:is="props.icon"
v-if="props.icon"
:class="cn('size-4 shrink-0', tones[props.tone ?? 'default'])"
/>
</div>
<div class="mt-1.5 flex items-baseline gap-1.5">
<span
:class="
cn(
'text-xl font-semibold tracking-tight tabular-nums sm:text-2xl',
props.tone && props.tone !== 'default' ? tones[props.tone] : '',
)
"
>{{ props.value }}</span
>
<span v-if="props.hint" class="text-muted-foreground truncate text-xs">{{
props.hint
}}</span>
</div>
</Card>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const { status } = useLive()
const label = computed(() => {
switch (status.value) {
case 'open':
return t('common.live')
case 'connecting':
return t('common.connecting')
default:
return t('common.offline')
}
})
</script>
<template>
<span
:class="
cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
status === 'open'
? 'border-allow/30 bg-allow/10 text-allow'
: status === 'connecting'
? 'border-drop/30 bg-drop/10 text-drop'
: 'border-block/30 bg-block/10 text-block',
)
"
>
<span
:class="
cn(
'size-1.5 rounded-full',
status === 'open'
? 'bg-allow animate-pulse-dot'
: status === 'connecting'
? 'bg-drop animate-pulse-dot'
: 'bg-block',
)
"
/>
{{ label }}
</span>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { CheckCircle2, TriangleAlert, X } from 'lucide-vue-next'
import { dismiss, useToast } from '@/composables/useToast'
import { cn } from '@/lib/utils'
const { toasts } = useToast()
</script>
<template>
<div
class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-center gap-2 p-3 sm:top-auto sm:right-0 sm:bottom-0 sm:left-auto sm:items-end sm:p-4"
>
<TransitionGroup
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2 sm:translate-y-2"
leave-active-class="transition duration-150 ease-in absolute"
leave-to-class="opacity-0 scale-95"
>
<div
v-for="item in toasts"
:key="item.id"
:class="
cn(
'bg-popover text-popover-foreground pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border p-3 shadow-lg',
item.variant === 'error' && 'border-block/40',
item.variant === 'success' && 'border-allow/40',
)
"
>
<CheckCircle2 v-if="item.variant === 'success'" class="text-allow mt-0.5 size-4 shrink-0" />
<TriangleAlert
v-else-if="item.variant === 'error'"
class="text-block mt-0.5 size-4 shrink-0"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{{ item.title }}</p>
<p v-if="item.description" class="text-muted-foreground mt-0.5 text-xs break-words">
{{ item.description }}
</p>
</div>
<button
type="button"
class="text-muted-foreground hover:text-foreground -m-1 p-1"
@click="dismiss(item.id)"
>
<X class="size-3.5" />
</button>
</div>
</TransitionGroup>
</div>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { compact, time } from '@/lib/format'
import type { Bucket } from '@/lib/types'
const props = defineProps<{ buckets: Bucket[] }>()
const W = 600
const H = 160
const PAD_TOP = 8
const PAD_BOTTOM = 16
const hover = ref<number | null>(null)
const points = computed(() => props.buckets ?? [])
const max = computed(() => Math.max(1, ...points.value.map((b) => b.tcp + b.udp)))
const hasData = computed(() => points.value.some((b) => b.tcp + b.udp + b.blocked > 0))
function x(i: number): number {
const n = points.value.length
return n <= 1 ? 0 : (i / (n - 1)) * W
}
function y(value: number): number {
const usable = H - PAD_TOP - PAD_BOTTOM
return PAD_TOP + usable - (value / max.value) * usable
}
/** Builds a closed area path for a series of stacked values. */
function area(values: number[]): string {
if (values.length === 0) return ''
const top = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
const base = `L${W},${y(0)} L0,${y(0)} Z`
return top.join(' ') + ' ' + base
}
function line(values: number[]): string {
return values
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
.join(' ')
}
const totalPath = computed(() => area(points.value.map((b) => b.tcp + b.udp)))
const tcpPath = computed(() => area(points.value.map((b) => b.tcp)))
const blockedPath = computed(() => line(points.value.map((b) => b.blocked)))
const active = computed(() => (hover.value === null ? null : points.value[hover.value]))
function onMove(event: PointerEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = (event.clientX - rect.left) / rect.width
const index = Math.round(ratio * (points.value.length - 1))
hover.value = Math.min(points.value.length - 1, Math.max(0, index))
}
const legend = computed(() => {
const sum = (key: 'tcp' | 'udp' | 'blocked') =>
points.value.reduce((acc, b) => acc + b[key], 0)
return [
{ label: t('dashboard.tcp'), color: 'bg-tcp', value: sum('tcp') },
{ label: t('dashboard.udp'), color: 'bg-udp', value: sum('udp') },
{ label: t('dashboard.blocked'), color: 'bg-block', value: sum('blocked') },
]
})
</script>
<template>
<div>
<div
class="relative touch-pan-y"
@pointermove="onMove"
@pointerdown="onMove"
@pointerleave="hover = null"
>
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-36 w-full sm:h-44"
role="img"
>
<line
v-for="frac in [0, 0.5, 1]"
:key="frac"
:x1="0"
:x2="W"
:y1="y(max * frac)"
:y2="y(max * frac)"
class="stroke-border"
stroke-width="1"
vector-effect="non-scaling-stroke"
stroke-dasharray="3 4"
/>
<path :d="totalPath" class="fill-udp/25" />
<path :d="tcpPath" class="fill-tcp/45" />
<path
:d="blockedPath"
class="stroke-block"
fill="none"
stroke-width="1.75"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<template v-if="hover !== null">
<line
:x1="x(hover)"
:x2="x(hover)"
:y1="PAD_TOP"
:y2="y(0)"
class="stroke-foreground/40"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</template>
</svg>
<div
class="text-muted-foreground pointer-events-none absolute top-0 left-0 text-[10px] tabular-nums"
>
{{ compact(max) }}
</div>
<div
v-if="active"
class="bg-popover pointer-events-none absolute top-1 z-10 rounded-md border px-2 py-1.5 text-[11px] shadow-md"
:style="{
left: `${(hover! / Math.max(1, points.length - 1)) * 100}%`,
transform:
hover! / Math.max(1, points.length - 1) > 0.6
? 'translateX(-105%)'
: 'translateX(8px)',
}"
>
<div class="text-muted-foreground mb-0.5">{{ time(active.time) }}</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-tcp size-2 rounded-full" />{{ t('dashboard.tcp') }}
<span class="ml-auto font-medium">{{ active.tcp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-udp size-2 rounded-full" />{{ t('dashboard.udp') }}
<span class="ml-auto font-medium">{{ active.udp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-block size-2 rounded-full" />{{ t('dashboard.blocked') }}
<span class="ml-auto font-medium">{{ active.blocked }}</span>
</div>
</div>
<div
v-if="!hasData"
class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
>
{{ t('dashboard.noTraffic') }}
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
<div v-for="item in legend" :key="item.label" class="flex items-center gap-1.5 text-xs">
<span :class="['size-2 rounded-full', item.color]" />
<span class="text-muted-foreground">{{ item.label }}</span>
<span class="font-medium tabular-nums">{{ compact(item.value) }}</span>
</div>
</div>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const badgeVariants = cva(
'inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
outline: 'text-foreground',
muted: 'border-transparent bg-muted text-muted-foreground',
allow: 'border-allow/25 bg-allow/12 text-allow',
block: 'border-block/25 bg-block/12 text-block',
drop: 'border-drop/25 bg-drop/12 text-drop',
modify: 'border-modify/25 bg-modify/12 text-modify',
log: 'border-log/25 bg-log/12 text-log',
tcp: 'border-tcp/25 bg-tcp/12 text-tcp',
udp: 'border-udp/25 bg-udp/12 text-udp',
},
},
defaultVariants: { variant: 'default' },
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
</script>
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ variant?: BadgeVariants['variant']; class?: string }>()
</script>
<template>
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
<slot />
</span>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,background-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring active:scale-[0.98] select-none",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-[13px]',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
</script>
<script setup lang="ts">
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<
PrimitiveProps & {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: string
}
>(),
{ as: 'button' },
)
</script>
<template>
<Primitive
:as="props.as"
:as-child="props.asChild"
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
>
<slot />
</Primitive>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div
:class="
cn(
'bg-card text-card-foreground rounded-xl border shadow-sm transition-colors',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('px-4 pb-4 sm:px-5 sm:pb-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<p :class="cn('text-muted-foreground text-xs sm:text-sm', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<h3 :class="cn('text-sm leading-none font-semibold tracking-tight sm:text-base', props.class)">
<slot />
</h3>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string | number }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; for?: string }>()
</script>
<template>
<Label
:for="props.for"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none',
props.class,
)
"
>
<slot />
</Label>
</template>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { cn } from '@/lib/utils'
/**
* A dialog that turns into a bottom drawer on small screens, which is much
* easier to reach with a thumb than a centered modal.
*/
const props = defineProps<{
open: boolean
title: string
description?: string
class?: string
}>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
</script>
<template>
<DialogRoot :open="props.open" @update:open="emit('update:open', $event)">
<DialogPortal>
<DialogOverlay
class="animate-fade-in fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]"
/>
<DialogContent
:class="
cn(
'bg-background fixed z-50 flex flex-col gap-4 border shadow-lg outline-none',
// Mobile: bottom drawer
'animate-slide-up inset-x-0 bottom-0 max-h-[92vh] rounded-t-2xl border-b-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]',
// Desktop: centered dialog
'sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:max-h-[85vh] sm:w-full sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border-b sm:p-6',
props.class,
)
"
>
<div
class="bg-muted-foreground/25 mx-auto -mt-1 mb-1 h-1 w-10 shrink-0 rounded-full sm:hidden"
/>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<DialogTitle class="text-base font-semibold">{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description" class="text-muted-foreground text-sm">
{{ props.description }}
</DialogDescription>
</div>
<DialogClose
class="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 -mt-1 -mr-1 rounded-md p-1.5 transition-colors outline-none focus-visible:ring-[3px]"
>
<X class="size-4" />
</DialogClose>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<slot />
</div>
<div v-if="$slots.footer" class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<slot name="footer" />
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
import type { SelectOption } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue: string
options: SelectOption[]
placeholder?: string
ariaLabel?: string
class?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<SelectRoot
:model-value="props.modelValue"
@update:model-value="emit('update:modelValue', String($event))"
>
<SelectTrigger
:aria-label="props.ariaLabel"
:class="
cn(
'border-input bg-background flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<SelectValue :placeholder="props.placeholder ?? ''" class="truncate" />
<ChevronDown class="size-4 shrink-0 opacity-50" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="4"
class="bg-popover text-popover-foreground animate-fade-in relative z-50 max-h-72 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border shadow-md"
>
<SelectViewport class="p-1">
<SelectItem
v-for="option in props.options"
:key="option.value"
:value="option.value"
class="focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<SelectItemText>{{ option.label }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 flex items-center">
<Check class="size-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ class?: string; orientation?: 'horizontal' | 'vertical' }>(),
{ orientation: 'horizontal' },
)
</script>
<template>
<div
role="separator"
:class="
cn(
'bg-border shrink-0',
props.orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
props.class,
)
"
/>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('bg-muted animate-pulse rounded-md', props.class)" />
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: boolean; id?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
<template>
<SwitchRoot
:id="props.id"
:model-value="props.modelValue"
:class="
cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@update:model-value="emit('update:modelValue', $event)"
>
<SwitchThumb
class="bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
/>
</SwitchRoot>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { TabsRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<TabsRoot
:model-value="props.modelValue"
:class="cn('flex flex-col gap-3', props.class)"
@update:model-value="emit('update:modelValue', String($event))"
>
<slot />
</TabsRoot>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsContent :value="props.value" :class="cn('outline-none', props.class)">
<slot />
</TabsContent>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { TabsList } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<TabsList
:class="
cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1',
props.class,
)
"
>
<slot />
</TabsList>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { TabsTrigger } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsTrigger
:value="props.value"
:class="
cn(
'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'focus-visible:ring-ring/40 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:size-3.5',
props.class,
)
"
>
<slot />
</TabsTrigger>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<textarea
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex min-h-20 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground field-sizing-content',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>
+19
View File
@@ -0,0 +1,19 @@
export { default as Badge, badgeVariants } from './Badge.vue'
export { default as Button, buttonVariants } from './Button.vue'
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as Input } from './Input.vue'
export { default as Label } from './Label.vue'
export { default as Modal } from './Modal.vue'
export { default as Select } from './Select.vue'
export { default as Separator } from './Separator.vue'
export { default as Skeleton } from './Skeleton.vue'
export { default as Switch } from './Switch.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TabsContent } from './TabsContent.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export { default as Textarea } from './Textarea.vue'
+105
View File
@@ -0,0 +1,105 @@
import { computed, ref, shallowRef } from 'vue'
import { api, auth, liveUrl } from '@/lib/api'
import type { LiveEvent, Metrics } from '@/lib/types'
const MAX_EVENTS = 500
export type LiveStatus = 'closed' | 'connecting' | 'open'
const metrics = ref<Metrics | null>(null)
const events = shallowRef<LiveEvent[]>([])
const status = ref<LiveStatus>('closed')
let source: EventSource | null = null
let retryTimer: number | undefined
let retryDelay = 1000
function pushEvent(event: LiveEvent) {
const next = [event, ...events.value]
if (next.length > MAX_EVENTS) next.length = MAX_EVENTS
events.value = next
}
async function seed() {
try {
const [m, e] = await Promise.all([api.metrics(), api.events(MAX_EVENTS)])
metrics.value = m
if (events.value.length === 0) events.value = e.events ?? []
} catch {
// The SSE connection will report the problem, no need to duplicate it.
}
}
function connect() {
if (source || !auth.authenticated) return
status.value = 'connecting'
const es = new EventSource(liveUrl)
source = es
es.addEventListener('open', () => {
status.value = 'open'
retryDelay = 1000
})
es.addEventListener('metrics', (ev) => {
try {
metrics.value = JSON.parse((ev as MessageEvent<string>).data) as Metrics
status.value = 'open'
} catch {
/* ignore malformed frame */
}
})
es.addEventListener('event', (ev) => {
try {
pushEvent(JSON.parse((ev as MessageEvent<string>).data) as LiveEvent)
} catch {
/* ignore malformed frame */
}
})
es.addEventListener('error', () => {
es.close()
if (source === es) source = null
status.value = 'closed'
// The session may have expired; a plain fetch tells us for sure.
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(() => {
if (auth.authenticated) connect()
}, retryDelay)
retryDelay = Math.min(retryDelay * 2, 15000)
})
}
export function startLive() {
if (!auth.authenticated) return
void seed()
connect()
}
export function stopLive() {
window.clearTimeout(retryTimer)
source?.close()
source = null
status.value = 'closed'
metrics.value = null
events.value = []
}
export function clearEvents() {
events.value = []
}
export function useLive() {
return {
metrics,
events,
status,
connected: computed(() => status.value === 'open'),
startLive,
stopLive,
clearEvents,
refresh: seed,
}
}
+46
View File
@@ -0,0 +1,46 @@
import { computed, ref, watch } from 'vue'
export type Theme = 'light' | 'dark' | 'system'
const THEME_KEY = 'opengfw.theme'
const stored = localStorage.getItem(THEME_KEY)
const theme = ref<Theme>(stored === 'light' || stored === 'dark' ? stored : 'system')
const media = window.matchMedia('(prefers-color-scheme: dark)')
function resolve(value: Theme): boolean {
return value === 'dark' || (value === 'system' && media.matches)
}
const isDark = ref(resolve(theme.value))
function apply() {
isDark.value = resolve(theme.value)
document.documentElement.classList.toggle('dark', isDark.value)
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDark.value ? '#09090b' : '#ffffff')
}
watch(theme, (value) => {
localStorage.setItem(THEME_KEY, value)
apply()
})
media.addEventListener('change', () => {
if (theme.value === 'system') apply()
})
apply()
export function useTheme() {
return {
theme,
isDark: computed(() => isDark.value),
setTheme: (value: Theme) => {
theme.value = value
},
toggle: () => {
theme.value = isDark.value ? 'light' : 'dark'
},
}
}
+33
View File
@@ -0,0 +1,33 @@
import { ref } from 'vue'
export type ToastVariant = 'default' | 'success' | 'error'
export interface Toast {
id: number
title: string
description?: string
variant: ToastVariant
}
const toasts = ref<Toast[]>([])
let nextId = 1
function push(title: string, variant: ToastVariant, description?: string) {
const id = nextId++
toasts.value = [...toasts.value, { id, title, description, variant }]
window.setTimeout(() => dismiss(id), variant === 'error' ? 6000 : 3500)
}
export function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
export const toast = {
info: (title: string, description?: string) => push(title, 'default', description),
success: (title: string, description?: string) => push(title, 'success', description),
error: (title: string, description?: string) => push(title, 'error', description),
}
export function useToast() {
return { toasts, toast, dismiss }
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+182
View File
@@ -0,0 +1,182 @@
export default {
app: {
name: 'OpenGFW',
tagline: 'Network filtering and analysis',
},
nav: {
dashboard: 'Dashboard',
events: 'Events',
rules: 'Rules',
analyzers: 'Analyzers',
settings: 'Settings',
},
common: {
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
close: 'Close',
refresh: 'Refresh',
search: 'Search',
loading: 'Loading…',
empty: 'Nothing here yet',
error: 'Error',
retry: 'Retry',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
apply: 'Apply',
copy: 'Copy',
copied: 'Copied',
enabled: 'Enabled',
disabled: 'Disabled',
all: 'All',
unknown: 'Unknown',
total: 'Total',
live: 'Live',
offline: 'Disconnected',
connecting: 'Connecting…',
back: 'Back',
more: 'More',
},
login: {
title: 'Sign in',
subtitle: 'Enter the web UI password from your config file',
password: 'Password',
submit: 'Sign in',
failed: 'Sign in failed',
hint: 'Set it with web.secret in config.yaml',
},
dashboard: {
title: 'Dashboard',
subtitle: 'Live overview of the engine',
uptime: 'Uptime',
workers: 'Workers',
streams: 'Streams',
blocked: 'Blocked',
allowed: 'Allowed',
modified: 'Modified',
errors: 'Errors',
traffic: 'Traffic',
trafficDesc: 'New streams and verdicts over time',
range10m: '10 min',
range1h: '1 hour',
protocols: 'Protocols',
verdicts: 'Verdicts',
topHosts: 'Top hosts',
topHostsDesc: 'Seen in TLS SNI, HTTP Host and DNS queries',
topBlocked: 'Blocked destinations',
topRules: 'Triggered rules',
recent: 'Recent events',
viewAll: 'View all',
noTraffic: 'No traffic seen yet',
tcp: 'TCP',
udp: 'UDP',
},
events: {
title: 'Events',
subtitle: 'Live feed of verdicts, rule logs and errors',
pause: 'Pause',
resume: 'Resume',
paused: 'Paused',
clear: 'Clear',
filterAction: 'Verdict',
filterProto: 'Protocol',
filterKind: 'Type',
searchPlaceholder: 'Search IP, host or rule…',
empty: 'Waiting for events…',
emptyFiltered: 'No event matches the filter',
detail: 'Event detail',
stream: 'Stream',
source: 'Source',
destination: 'Destination',
host: 'Host',
rule: 'Rule',
props: 'Analyzer properties',
noProps: 'No analyzer properties',
copyJson: 'Copy JSON',
buffered: '{n} new',
},
rules: {
title: 'Rules',
subtitle: 'Rules are matched top to bottom; the first match wins',
count: '{n} rules',
file: 'Rule file',
add: 'New rule',
editTitle: 'Edit rule',
addTitle: 'New rule',
name: 'Name',
namePlaceholder: 'block-google-dns',
action: 'Action',
actionNone: 'None (log only)',
log: 'Log matches',
expr: 'Expression',
exprPlaceholder: 'dns != nil && dns.qr && any(dns.questions, {.name endsWith "example.com"})',
modifier: 'Modifier',
modifierArgs: 'Modifier arguments (JSON)',
visual: 'Visual',
yaml: 'YAML',
validate: 'Validate',
valid: 'Rules are valid',
invalid: 'Invalid rules',
save: 'Save & apply',
saved: 'Rules applied to the running engine',
unsaved: 'Unsaved changes',
discard: 'Discard',
deleteConfirm: 'Delete rule "{name}"?',
moveUp: 'Move up',
moveDown: 'Move down',
empty: 'No rules yet. Everything is accepted.',
nameRequired: 'A name is required',
exprRequired: 'An expression is required',
hint: 'Saving writes the rule file and hot reloads the engine.',
},
analyzers: {
title: 'Analyzers',
subtitle: 'Protocol analyzers compiled into this build',
hits: 'Streams analyzed',
modifiers: 'Modifiers',
modifiersDesc: 'Available for rules with the modify action',
functions: 'Rule functions',
functionsDesc: 'Built-in functions usable in expressions',
used: 'In use',
idle: 'Idle',
},
settings: {
title: 'Settings',
subtitle: 'Appearance and instance information',
appearance: 'Appearance',
theme: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
language: 'Language',
instance: 'Instance',
version: 'Version',
platform: 'Platform',
goVersion: 'Go version',
hostname: 'Hostname',
ruleFile: 'Rule file',
engineConfig: 'Engine configuration',
ioQueueSize: 'NFQueue size',
ioLocal: 'Local mode',
ioRST: 'Send RST',
workers: 'Workers',
workerQueueSize: 'Worker queue size',
udpMaxStreams: 'Max UDP streams',
geoip: 'GeoIP database',
geosite: 'GeoSite database',
auto: 'Auto',
session: 'Session',
logout: 'Sign out',
docs: 'Documentation',
},
action: {
allow: 'Allow',
block: 'Block',
drop: 'Drop',
modify: 'Modify',
maybe: 'Maybe',
log: 'Log',
error: 'Error',
},
}
+46
View File
@@ -0,0 +1,46 @@
import { computed, ref } from 'vue'
import en from './en'
import zh from './zh'
export type Lang = 'en' | 'zh'
const LANG_KEY = 'opengfw.lang'
const dictionaries: Record<Lang, Record<string, unknown>> = { en, zh }
function detect(): Lang {
const stored = localStorage.getItem(LANG_KEY)
if (stored === 'en' || stored === 'zh') return stored
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
}
const lang = ref<Lang>(detect())
function lookup(dict: Record<string, unknown>, key: string): string | undefined {
const value = key.split('.').reduce<unknown>((acc, part) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part]
return undefined
}, dict)
return typeof value === 'string' ? value : undefined
}
/** Translates a dotted key, interpolating {placeholders}. */
export function t(key: string, params?: Record<string, string | number>): string {
const raw = lookup(dictionaries[lang.value], key) ?? lookup(dictionaries.en, key) ?? key
if (!params) return raw
return raw.replace(/\{(\w+)\}/g, (_, name: string) =>
params[name] === undefined ? `{${name}}` : String(params[name]),
)
}
export function setLang(value: Lang) {
lang.value = value
localStorage.setItem(LANG_KEY, value)
document.documentElement.lang = value === 'zh' ? 'zh-CN' : 'en'
}
export function useI18n() {
return { t, lang: computed(() => lang.value), setLang }
}
document.documentElement.lang = lang.value === 'zh' ? 'zh-CN' : 'en'
+182
View File
@@ -0,0 +1,182 @@
export default {
app: {
name: 'OpenGFW',
tagline: '网络过滤与分析',
},
nav: {
dashboard: '概览',
events: '事件',
rules: '规则',
analyzers: '解析器',
settings: '设置',
},
common: {
save: '保存',
saving: '保存中…',
cancel: '取消',
close: '关闭',
refresh: '刷新',
search: '搜索',
loading: '加载中…',
empty: '暂无数据',
error: '错误',
retry: '重试',
delete: '删除',
edit: '编辑',
add: '添加',
apply: '应用',
copy: '复制',
copied: '已复制',
enabled: '已启用',
disabled: '已禁用',
all: '全部',
unknown: '未知',
total: '总计',
live: '实时',
offline: '连接断开',
connecting: '连接中…',
back: '返回',
more: '更多',
},
login: {
title: '登录',
subtitle: '请输入配置文件中设置的 Web UI 密码',
password: '密码',
submit: '登录',
failed: '登录失败',
hint: '在 config.yaml 的 web.secret 中设置',
},
dashboard: {
title: '概览',
subtitle: '引擎实时运行状态',
uptime: '运行时长',
workers: '工作线程',
streams: '连接数',
blocked: '已拦截',
allowed: '已放行',
modified: '已修改',
errors: '错误',
traffic: '流量趋势',
trafficDesc: '新建连接与判定结果随时间变化',
range10m: '10 分钟',
range1h: '1 小时',
protocols: '协议分布',
verdicts: '判定分布',
topHosts: '热门域名',
topHostsDesc: '来自 TLS SNI、HTTP Host 与 DNS 查询',
topBlocked: '被拦截的目标',
topRules: '命中的规则',
recent: '最近事件',
viewAll: '查看全部',
noTraffic: '暂未捕获到流量',
tcp: 'TCP',
udp: 'UDP',
},
events: {
title: '事件',
subtitle: '判定结果、规则日志与错误的实时推送',
pause: '暂停',
resume: '继续',
paused: '已暂停',
clear: '清空',
filterAction: '判定',
filterProto: '协议',
filterKind: '类型',
searchPlaceholder: '搜索 IP、域名或规则…',
empty: '等待事件中…',
emptyFiltered: '没有符合筛选条件的事件',
detail: '事件详情',
stream: '连接',
source: '来源',
destination: '目标',
host: '域名',
rule: '规则',
props: '解析器属性',
noProps: '无解析器属性',
copyJson: '复制 JSON',
buffered: '{n} 条新事件',
},
rules: {
title: '规则',
subtitle: '规则自上而下匹配,命中第一条即生效',
count: '{n} 条规则',
file: '规则文件',
add: '新建规则',
editTitle: '编辑规则',
addTitle: '新建规则',
name: '名称',
namePlaceholder: 'block-google-dns',
action: '动作',
actionNone: '无(仅记录日志)',
log: '记录日志',
expr: '表达式',
exprPlaceholder: 'dns != nil && dns.qr && any(dns.questions, {.name endsWith "example.com"})',
modifier: '修改器',
modifierArgs: '修改器参数(JSON)',
visual: '可视化',
yaml: 'YAML',
validate: '校验',
valid: '规则校验通过',
invalid: '规则不合法',
save: '保存并应用',
saved: '规则已热加载到运行中的引擎',
unsaved: '有未保存的修改',
discard: '放弃修改',
deleteConfirm: '确定删除规则「{name}」?',
moveUp: '上移',
moveDown: '下移',
empty: '暂无规则,所有流量都会放行。',
nameRequired: '请填写名称',
exprRequired: '请填写表达式',
hint: '保存后将写入规则文件并热加载引擎。',
},
analyzers: {
title: '解析器',
subtitle: '当前版本内置的协议解析器',
hits: '已解析连接',
modifiers: '修改器',
modifiersDesc: '可用于 modify 动作的规则',
functions: '规则函数',
functionsDesc: '表达式中可用的内置函数',
used: '使用中',
idle: '空闲',
},
settings: {
title: '设置',
subtitle: '外观与实例信息',
appearance: '外观',
theme: '主题',
themeLight: '浅色',
themeDark: '深色',
themeSystem: '跟随系统',
language: '语言',
instance: '实例信息',
version: '版本',
platform: '平台',
goVersion: 'Go 版本',
hostname: '主机名',
ruleFile: '规则文件',
engineConfig: '引擎配置',
ioQueueSize: 'NFQueue 队列大小',
ioLocal: '本机模式',
ioRST: '发送 RST',
workers: '工作线程数',
workerQueueSize: '工作线程队列大小',
udpMaxStreams: 'UDP 最大连接数',
geoip: 'GeoIP 数据库',
geosite: 'GeoSite 数据库',
auto: '自动',
session: '会话',
logout: '退出登录',
docs: '文档',
},
action: {
allow: '放行',
block: '拦截',
drop: '丢弃',
modify: '修改',
maybe: '待定',
log: '日志',
error: '错误',
},
}
+110
View File
@@ -0,0 +1,110 @@
import type {
Info,
LiveEvent,
MetaInfo,
Metrics,
Rule,
RulesResponse,
ValidateResponse,
} from './types'
const BASE = '/api/v1'
const TOKEN_KEY = 'opengfw.token'
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
let token = localStorage.getItem(TOKEN_KEY) ?? ''
const unauthorizedHandlers = new Set<() => void>()
export const auth = {
get token() {
return token
},
get authenticated() {
return token !== ''
},
set(value: string) {
token = value
localStorage.setItem(TOKEN_KEY, value)
},
clear() {
token = ''
localStorage.removeItem(TOKEN_KEY)
},
onUnauthorized(handler: () => void) {
unauthorizedHandlers.add(handler)
return () => unauthorizedHandlers.delete(handler)
},
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
if (init.body) headers.set('Content-Type', 'application/json')
let res: Response
try {
res = await fetch(BASE + path, { ...init, headers, credentials: 'same-origin' })
} catch {
throw new ApiError(0, 'network error')
}
if (res.status === 401) {
auth.clear()
unauthorizedHandlers.forEach((h) => h())
throw new ApiError(401, 'unauthorized')
}
const text = await res.text()
const data = text ? (JSON.parse(text) as unknown) : null
if (!res.ok) {
const message =
data && typeof data === 'object' && 'error' in data
? String((data as { error: unknown }).error)
: `request failed (${res.status})`
throw new ApiError(res.status, message)
}
return data as T
}
export const api = {
async login(password: string): Promise<void> {
const res = await request<{ token: string }>('/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
auth.set(res.token)
},
async logout(): Promise<void> {
try {
await request('/logout', { method: 'POST' })
} finally {
auth.clear()
}
},
info: () => request<Info>('/info'),
meta: () => request<MetaInfo>('/meta'),
metrics: () => request<Metrics>('/metrics'),
events: (limit = 200) => request<{ events: LiveEvent[] }>(`/events?limit=${limit}`),
rules: () => request<RulesResponse>('/rules'),
saveRules: (payload: { raw?: string; rules?: Rule[] }) =>
request<RulesResponse>('/rules', { method: 'PUT', body: JSON.stringify(payload) }),
validateRules: (payload: { raw?: string; rules?: Rule[] }) =>
request<ValidateResponse>('/rules/validate', {
method: 'POST',
body: JSON.stringify(payload),
}),
}
export const liveUrl = `${BASE}/live`
+48
View File
@@ -0,0 +1,48 @@
/** Compact number formatting: 1234 -> 1.2k */
export function compact(n: number): string {
if (!Number.isFinite(n)) return '0'
if (n < 1000) return String(n)
const units = ['k', 'M', 'G', 'T']
let value = n
let unit = -1
while (value >= 1000 && unit < units.length - 1) {
value /= 1000
unit++
}
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}${units[unit]}`
}
export function percent(part: number, total: number): number {
if (!total) return 0
return Math.round((part / total) * 100)
}
/** 3725 -> "1h 2m" */
export function duration(seconds: number): string {
if (seconds < 60) return `${Math.max(0, Math.floor(seconds))}s`
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
export function time(ts: number): string {
const d = new Date(ts)
return d.toLocaleTimeString(undefined, { hour12: false })
}
export function dateTime(ts: number): string {
const d = new Date(ts)
return `${d.toLocaleDateString()} ${d.toLocaleTimeString(undefined, { hour12: false })}`
}
/** Formats an address, wrapping IPv6 in brackets. */
export function endpoint(ip: string, port: number): string {
return ip.includes(':') ? `[${ip}]:${port}` : `${ip}:${port}`
}
export function truncate(s: string, max = 42): string {
return s.length > max ? `${s.slice(0, max - 1)}…` : s
}
+110
View File
@@ -0,0 +1,110 @@
export interface SelectOption {
value: string
label: string
}
export type ActionName = 'allow' | 'block' | 'drop' | 'modify' | 'maybe'
export type EventKind = 'action' | 'log' | 'error'
export interface LiveEvent {
seq: number
time: number
kind: EventKind
id: number
proto: string
srcIP: string
srcPort: number
dstIP: string
dstPort: number
action?: ActionName
rule?: string
host?: string
message?: string
props?: Record<string, Record<string, unknown>>
}
export interface Bucket {
time: number
tcp: number
udp: number
blocked: number
allowed: number
}
export interface NameCount {
name: string
count: number
}
export interface Metrics {
uptimeSeconds: number
workers: number
streams: { tcp: number; udp: number; total: number }
actions: { allow: number; block: number; drop: number; modify: number; maybe: number }
errors: number
short: Bucket[]
long: Bucket[]
topHosts: NameCount[]
topBlockedIPs: NameCount[]
topRules: NameCount[]
analyzers: NameCount[]
}
export interface ConfigDigest {
ioQueueSize: number
ioLocal: boolean
ioRST: boolean
workers: number
workerQueueSize: number
udpMaxStreams: number
geoip?: string
geosite?: string
}
export interface Info {
version: string
commit?: string
platform: string
goVersion: string
hostname: string
ruleFile: string
config: ConfigDigest
}
export interface AnalyzerInfo {
name: string
proto: string
}
export interface MetaInfo {
analyzers: AnalyzerInfo[]
modifiers: string[]
actions: string[]
functions: string[]
}
export interface RuleModifier {
name: string
args?: Record<string, unknown>
}
export interface Rule {
name: string
action?: string
log?: boolean
expr: string
modifier?: RuleModifier | null
}
export interface RulesResponse {
path: string
raw: string
rules: Rule[]
}
export interface ValidateResponse {
valid: boolean
error?: string
rules?: Rule[]
raw: string
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/style.css'
import { stopLive } from './composables/useLive'
import { auth } from './lib/api'
import router from './router'
auth.onUnauthorized(() => {
stopLive()
void router.replace({ name: 'login' })
})
createApp(App).use(router).mount('#app')
+38
View File
@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
import AppShell from '@/components/AppShell.vue'
import { auth } from '@/lib/api'
import LoginView from '@/views/LoginView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
component: AppShell,
children: [
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: 'events', name: 'events', component: () => import('@/views/EventsView.vue') },
{ path: 'rules', name: 'rules', component: () => import('@/views/RulesView.vue') },
{
path: 'analyzers',
name: 'analyzers',
component: () => import('@/views/AnalyzersView.vue'),
},
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach((to) => {
if (!to.meta.public && !auth.authenticated) {
return { name: 'login', query: to.fullPath === '/' ? undefined : { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.authenticated) return { name: 'dashboard' }
return true
})
export default router
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Boxes, FunctionSquare } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from '@/components/ui'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { api } from '@/lib/api'
import { compact } from '@/lib/format'
import type { MetaInfo } from '@/lib/types'
const { metrics } = useLive()
const meta = ref<MetaInfo | null>(null)
onMounted(async () => {
try {
meta.value = await api.meta()
} catch {
meta.value = { analyzers: [], modifiers: [], actions: [], functions: [] }
}
})
const hits = computed(() => {
const map = new Map<string, number>()
for (const item of metrics.value?.analyzers ?? []) map.set(item.name, item.count)
return map
})
const analyzers = computed(() =>
[...(meta.value?.analyzers ?? [])].sort(
(a, b) => (hits.value.get(b.name) ?? 0) - (hits.value.get(a.name) ?? 0),
),
)
</script>
<template>
<div>
<PageHeader :title="t('analyzers.title')" :description="t('analyzers.subtitle')" />
<div v-if="!meta" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="h-24" />
</div>
<template v-else>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<Card v-for="analyzer in analyzers" :key="analyzer.name" class="p-3.5 sm:p-4">
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-sm font-medium">{{ analyzer.name }}</span>
<Badge :variant="analyzer.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ analyzer.proto }}
</Badge>
</div>
<p class="mt-2 text-xl font-semibold tabular-nums">
{{ compact(hits.get(analyzer.name) ?? 0) }}
</p>
<p class="text-muted-foreground text-xs">{{ t('analyzers.hits') }}</p>
</Card>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Boxes class="size-4" />
{{ t('analyzers.modifiers') }}
</CardTitle>
<CardDescription>{{ t('analyzers.modifiersDesc') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-1.5">
<Badge v-for="name in meta.modifiers" :key="name" variant="secondary" class="font-mono">
{{ name }}
</Badge>
<span v-if="!meta.modifiers.length" class="text-muted-foreground text-xs">—</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<FunctionSquare class="size-4" />
{{ t('analyzers.functions') }}
</CardTitle>
<CardDescription>{{ t('analyzers.functionsDesc') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-1.5">
<Badge v-for="name in meta.functions" :key="name" variant="secondary" class="font-mono">
{{ name }}()
</Badge>
</CardContent>
</Card>
</div>
</template>
</div>
</template>
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ArrowRight, Ban, Clock, Cpu, ShieldCheck, TriangleAlert, Waves } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import BarList from '@/components/BarList.vue'
import EventItem from '@/components/EventItem.vue'
import PageHeader from '@/components/PageHeader.vue'
import StatCard from '@/components/StatCard.vue'
import TrafficChart from '@/components/TrafficChart.vue'
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
Tabs,
TabsList,
TabsTrigger,
} from '@/components/ui'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { compact, duration, percent } from '@/lib/format'
const router = useRouter()
const { metrics, events } = useLive()
const range = ref('short')
const buckets = computed(
() => (range.value === 'short' ? metrics.value?.short : metrics.value?.long) ?? [],
)
const recent = computed(() => events.value.slice(0, 8))
const verdicts = computed(() => {
const a = metrics.value?.actions
if (!a) return []
const total = a.allow + a.block + a.drop + a.modify || 1
return [
{ key: 'allow', label: t('action.allow'), value: a.allow, color: 'bg-allow', total },
{ key: 'block', label: t('action.block'), value: a.block, color: 'bg-block', total },
{ key: 'drop', label: t('action.drop'), value: a.drop, color: 'bg-drop', total },
{ key: 'modify', label: t('action.modify'), value: a.modify, color: 'bg-modify', total },
].filter((v) => v.value > 0 || v.key === 'allow' || v.key === 'block')
})
const protocols = computed(() => {
const s = metrics.value?.streams
if (!s) return []
const total = s.total || 1
return [
{ key: 'tcp', label: t('dashboard.tcp'), value: s.tcp, color: 'bg-tcp', total },
{ key: 'udp', label: t('dashboard.udp'), value: s.udp, color: 'bg-udp', total },
]
})
const blockedTotal = computed(() => {
const a = metrics.value?.actions
return a ? a.block + a.drop : 0
})
</script>
<template>
<div>
<PageHeader :title="t('dashboard.title')" :description="t('dashboard.subtitle')" />
<div v-if="!metrics" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Skeleton v-for="i in 6" :key="i" class="h-20" />
</div>
<template v-else>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard
:label="t('dashboard.streams')"
:value="compact(metrics.streams.total)"
:hint="`${compact(metrics.streams.tcp)} / ${compact(metrics.streams.udp)}`"
:icon="Waves"
/>
<StatCard
:label="t('dashboard.blocked')"
:value="compact(blockedTotal)"
:hint="`${percent(blockedTotal, metrics.streams.total)}%`"
:icon="Ban"
tone="block"
/>
<StatCard
:label="t('dashboard.allowed')"
:value="compact(metrics.actions.allow)"
:icon="ShieldCheck"
tone="allow"
/>
<StatCard
:label="t('dashboard.modified')"
:value="compact(metrics.actions.modify)"
:icon="Waves"
tone="modify"
/>
<StatCard
:label="t('dashboard.uptime')"
:value="duration(metrics.uptimeSeconds)"
:icon="Clock"
/>
<StatCard
:label="metrics.errors > 0 ? t('dashboard.errors') : t('dashboard.workers')"
:value="metrics.errors > 0 ? compact(metrics.errors) : String(metrics.workers)"
:icon="metrics.errors > 0 ? TriangleAlert : Cpu"
:tone="metrics.errors > 0 ? 'drop' : 'default'"
/>
</div>
<div class="mt-3 grid gap-3 lg:grid-cols-3">
<Card class="lg:col-span-2">
<CardHeader class="flex-row items-start justify-between gap-3">
<div class="min-w-0">
<CardTitle>{{ t('dashboard.traffic') }}</CardTitle>
<CardDescription>{{ t('dashboard.trafficDesc') }}</CardDescription>
</div>
<Tabs v-model="range" class="shrink-0">
<TabsList class="h-8">
<TabsTrigger value="short" class="text-xs">{{ t('dashboard.range10m') }}</TabsTrigger>
<TabsTrigger value="long" class="text-xs">{{ t('dashboard.range1h') }}</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent>
<TrafficChart :buckets="buckets" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ t('dashboard.verdicts') }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<div>
<div class="bg-muted flex h-2 overflow-hidden rounded-full">
<div
v-for="v in verdicts"
:key="v.key"
:class="v.color"
:style="{ width: `${(v.value / v.total) * 100}%` }"
/>
</div>
<div class="mt-2.5 flex flex-col gap-1.5">
<div
v-for="v in verdicts"
:key="v.key"
class="flex items-center gap-2 text-xs sm:text-[13px]"
>
<span :class="['size-2 rounded-full', v.color]" />
<span class="text-muted-foreground">{{ v.label }}</span>
<span class="ml-auto font-medium tabular-nums">{{ compact(v.value) }}</span>
</div>
</div>
</div>
<div>
<p class="text-muted-foreground mb-2 text-xs font-medium">
{{ t('dashboard.protocols') }}
</p>
<div class="bg-muted flex h-2 overflow-hidden rounded-full">
<div
v-for="p in protocols"
:key="p.key"
:class="p.color"
:style="{ width: `${(p.value / p.total) * 100}%` }"
/>
</div>
<div class="mt-2.5 flex flex-col gap-1.5">
<div
v-for="p in protocols"
:key="p.key"
class="flex items-center gap-2 text-xs sm:text-[13px]"
>
<span :class="['size-2 rounded-full', p.color]" />
<span class="text-muted-foreground">{{ p.label }}</span>
<span class="ml-auto font-medium tabular-nums">{{ compact(p.value) }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{{ t('dashboard.topHosts') }}</CardTitle>
<CardDescription>{{ t('dashboard.topHostsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<BarList :items="metrics.topHosts" :empty="t('common.empty')" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ t('dashboard.topBlocked') }}</CardTitle>
</CardHeader>
<CardContent>
<BarList :items="metrics.topBlockedIPs" tone="block" :empty="t('common.empty')" />
</CardContent>
</Card>
<Card class="md:col-span-2 xl:col-span-1">
<CardHeader>
<CardTitle>{{ t('dashboard.topRules') }}</CardTitle>
</CardHeader>
<CardContent>
<BarList :items="metrics.topRules" tone="allow" :empty="t('common.empty')" />
</CardContent>
</Card>
</div>
<Card class="mt-3">
<CardHeader class="flex-row items-center justify-between">
<CardTitle>{{ t('dashboard.recent') }}</CardTitle>
<Button variant="ghost" size="sm" @click="router.push('/events')">
{{ t('dashboard.viewAll') }}
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="px-1.5 sm:px-2.5">
<div v-if="recent.length" class="flex flex-col">
<EventItem
v-for="event in recent"
:key="event.seq"
:event="event"
@select="router.push('/events')"
/>
</div>
<p v-else class="text-muted-foreground py-6 text-center text-xs">
{{ t('events.empty') }}
</p>
</CardContent>
</Card>
</template>
</div>
</template>
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { Copy, Pause, Play, Search, Trash2 } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import ActionBadge from '@/components/ActionBadge.vue'
import EventItem from '@/components/EventItem.vue'
import PageHeader from '@/components/PageHeader.vue'
import { Badge, Button, Card, Input, Modal, Select } from '@/components/ui'
import { clearEvents, useLive } from '@/composables/useLive'
import { toast } from '@/composables/useToast'
import { t } from '@/i18n'
import { dateTime, endpoint } from '@/lib/format'
import type { LiveEvent } from '@/lib/types'
const { events } = useLive()
const paused = ref(false)
const frozen = ref<LiveEvent[]>([])
const query = ref('')
const action = ref('all')
const proto = ref('all')
const selected = ref<LiveEvent | null>(null)
watch(paused, (value) => {
frozen.value = value ? events.value : []
})
const source = computed(() => (paused.value ? frozen.value : events.value))
const pending = computed(() =>
paused.value ? Math.max(0, events.value.length - frozen.value.length) : 0,
)
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
return source.value.filter((e) => {
if (action.value !== 'all') {
if (action.value === 'log' || action.value === 'error') {
if (e.kind !== action.value) return false
} else if (e.kind !== 'action' || e.action !== action.value) {
return false
}
}
if (proto.value !== 'all' && e.proto !== proto.value) return false
if (!q) return true
return (
e.srcIP.toLowerCase().includes(q) ||
e.dstIP.toLowerCase().includes(q) ||
(e.host ?? '').toLowerCase().includes(q) ||
(e.rule ?? '').toLowerCase().includes(q)
)
})
})
const actionOptions = computed(() => [
{ value: 'all', label: t('common.all') },
{ value: 'block', label: t('action.block') },
{ value: 'drop', label: t('action.drop') },
{ value: 'allow', label: t('action.allow') },
{ value: 'modify', label: t('action.modify') },
{ value: 'log', label: t('action.log') },
{ value: 'error', label: t('action.error') },
])
const protoOptions = computed(() => [
{ value: 'all', label: t('common.all') },
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
])
const propEntries = computed(() => Object.entries(selected.value?.props ?? {}))
async function copyDetail() {
if (!selected.value) return
try {
await navigator.clipboard.writeText(JSON.stringify(selected.value, null, 2))
toast.success(t('common.copied'))
} catch {
toast.error(t('common.error'))
}
}
function clear() {
clearEvents()
frozen.value = []
}
</script>
<template>
<div>
<PageHeader :title="t('events.title')" :description="t('events.subtitle')">
<template #actions>
<Button variant="outline" size="sm" @click="paused = !paused">
<Play v-if="paused" class="size-3.5" />
<Pause v-else class="size-3.5" />
<span class="hidden sm:inline">{{ paused ? t('events.resume') : t('events.pause') }}</span>
</Button>
<Button variant="outline" size="sm" @click="clear">
<Trash2 class="size-3.5" />
<span class="hidden sm:inline">{{ t('events.clear') }}</span>
</Button>
</template>
</PageHeader>
<div class="mb-3 flex flex-col gap-2 sm:flex-row">
<div class="relative flex-1">
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
v-model="query"
:placeholder="t('events.searchPlaceholder')"
class="pl-9"
type="search"
/>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex">
<Select
v-model="action"
:options="actionOptions"
:aria-label="t('events.filterAction')"
class="sm:w-32"
/>
<Select
v-model="proto"
:options="protoOptions"
:aria-label="t('events.filterProto')"
class="sm:w-28"
/>
</div>
</div>
<div
v-if="paused"
class="text-muted-foreground mb-2 flex items-center justify-between gap-2 text-xs"
>
<Badge variant="drop">{{ t('events.paused') }}</Badge>
<span v-if="pending > 0">{{ t('events.buffered', { n: pending }) }}</span>
</div>
<Card class="px-1.5 py-1.5 sm:px-2.5">
<div v-if="filtered.length" class="flex flex-col">
<EventItem
v-for="event in filtered"
:key="event.seq"
:event="event"
@select="selected = $event"
/>
</div>
<p v-else class="text-muted-foreground py-12 text-center text-sm">
{{ source.length ? t('events.emptyFiltered') : t('events.empty') }}
</p>
</Card>
<Modal
:open="selected !== null"
:title="t('events.detail')"
@update:open="!$event && (selected = null)"
>
<div v-if="selected" class="flex flex-col gap-3 text-sm">
<div class="flex flex-wrap items-center gap-2">
<ActionBadge :event="selected" />
<Badge :variant="selected.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ selected.proto }}
</Badge>
<span class="text-muted-foreground text-xs">{{ dateTime(selected.time) }}</span>
</div>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs sm:text-sm">
<dt class="text-muted-foreground">{{ t('events.stream') }}</dt>
<dd class="font-mono break-all">{{ selected.id }}</dd>
<dt class="text-muted-foreground">{{ t('events.source') }}</dt>
<dd class="font-mono break-all">{{ endpoint(selected.srcIP, selected.srcPort) }}</dd>
<dt class="text-muted-foreground">{{ t('events.destination') }}</dt>
<dd class="font-mono break-all">{{ endpoint(selected.dstIP, selected.dstPort) }}</dd>
<template v-if="selected.host">
<dt class="text-muted-foreground">{{ t('events.host') }}</dt>
<dd class="font-mono break-all">{{ selected.host }}</dd>
</template>
<template v-if="selected.rule">
<dt class="text-muted-foreground">{{ t('events.rule') }}</dt>
<dd class="break-all">{{ selected.rule }}</dd>
</template>
<template v-if="selected.message">
<dt class="text-muted-foreground">{{ t('common.error') }}</dt>
<dd class="text-destructive break-all">{{ selected.message }}</dd>
</template>
</dl>
<div>
<p class="text-muted-foreground mb-1.5 text-xs font-medium">{{ t('events.props') }}</p>
<div v-if="propEntries.length" class="flex flex-col gap-2">
<div v-for="[name, value] in propEntries" :key="name" class="rounded-lg border">
<div class="border-b px-2.5 py-1.5 text-xs font-medium">{{ name }}</div>
<pre
class="overflow-x-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed"
>{{ JSON.stringify(value, null, 2) }}</pre>
</div>
</div>
<p v-else class="text-muted-foreground text-xs">{{ t('events.noProps') }}</p>
</div>
</div>
<template #footer>
<Button variant="outline" @click="copyDetail">
<Copy class="size-4" />
{{ t('events.copyJson') }}
</Button>
<Button @click="selected = null">{{ t('common.close') }}</Button>
</template>
</Modal>
</div>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { Loader2, LockKeyhole, Moon, Sun } from 'lucide-vue-next'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Button, Card, Input, Label } from '@/components/ui'
import { useTheme } from '@/composables/useTheme'
import { t, useI18n } from '@/i18n'
import { api, ApiError } from '@/lib/api'
const router = useRouter()
const route = useRoute()
const { lang, setLang } = useI18n()
const { isDark, toggle } = useTheme()
const password = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
if (loading.value) return
loading.value = true
error.value = ''
try {
await api.login(password.value)
const redirect = route.query.redirect
await router.replace(typeof redirect === 'string' ? redirect : '/')
} catch (e) {
error.value = e instanceof ApiError ? e.message : t('login.failed')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="bg-background flex min-h-dvh flex-col">
<div class="flex items-center justify-end gap-1 p-3">
<Button variant="ghost" size="sm" @click="setLang(lang === 'zh' ? 'en' : 'zh')">
{{ lang === 'zh' ? 'EN' : '中文' }}
</Button>
<Button variant="ghost" size="icon-sm" :aria-label="t('settings.theme')" @click="toggle">
<Moon v-if="isDark" class="size-4" />
<Sun v-else class="size-4" />
</Button>
</div>
<div class="flex flex-1 items-center justify-center px-4 pb-16">
<Card class="animate-slide-up w-full max-w-sm p-6">
<div class="mb-6 flex flex-col items-center text-center">
<div
class="bg-primary text-primary-foreground mb-3 flex size-11 items-center justify-center rounded-xl"
>
<LockKeyhole class="size-5" />
</div>
<h1 class="text-lg font-semibold tracking-tight">{{ t('app.name') }}</h1>
<p class="text-muted-foreground mt-1 text-sm">{{ t('login.subtitle') }}</p>
</div>
<form class="flex flex-col gap-3" @submit.prevent="submit">
<div class="flex flex-col gap-2">
<Label for="password">{{ t('login.password') }}</Label>
<Input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
autofocus
:disabled="loading"
/>
</div>
<p v-if="error" class="text-destructive text-sm">{{ error }}</p>
<Button type="submit" class="mt-1 w-full" :disabled="loading || !password">
<Loader2 v-if="loading" class="size-4 animate-spin" />
{{ t('login.submit') }}
</Button>
</form>
<p class="text-muted-foreground mt-4 text-center text-xs">{{ t('login.hint') }}</p>
</Card>
</div>
</div>
</template>
+451
View File
@@ -0,0 +1,451 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowUp,
CheckCircle2,
FileCode2,
Loader2,
Pencil,
Plus,
RotateCcw,
Save,
Trash2,
} from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import {
Badge,
Button,
Card,
Input,
Label,
Modal,
Select,
Switch,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
import { toast } from '@/composables/useToast'
import { t } from '@/i18n'
import { api, ApiError } from '@/lib/api'
import type { MetaInfo, Rule } from '@/lib/types'
const mode = ref('visual')
const rules = ref<Rule[]>([])
const raw = ref('')
const path = ref('')
const meta = ref<MetaInfo | null>(null)
const loading = ref(true)
const busy = ref(false)
const dirty = ref(false)
const problem = ref('')
const editing = ref<Rule | null>(null)
const editingIndex = ref(-1)
const removing = ref<number>(-1)
const actionOptions = computed(() => [
{ value: '', label: t('rules.actionNone') },
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
])
const modifierOptions = computed(() =>
(meta.value?.modifiers ?? []).map((m) => ({ value: m, label: m })),
)
const badgeVariant = (action?: string) => {
switch (action) {
case 'allow':
return 'allow'
case 'block':
return 'block'
case 'drop':
return 'drop'
case 'modify':
return 'modify'
default:
return 'muted'
}
}
onMounted(async () => {
try {
const [res, m] = await Promise.all([api.rules(), api.meta()])
rules.value = res.rules ?? []
raw.value = res.raw
path.value = res.path
meta.value = m
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
loading.value = false
}
})
/** The backend is the single source of truth for YAML <-> rules conversion. */
async function switchMode(next: string) {
if (next === mode.value || busy.value) return
busy.value = true
problem.value = ''
try {
const res = await api.validateRules(next === 'yaml' ? { rules: rules.value } : { raw: raw.value })
if (next === 'yaml') {
raw.value = res.raw
} else {
if (!res.valid) {
problem.value = res.error ?? t('rules.invalid')
return
}
rules.value = res.rules ?? []
}
mode.value = next
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
function payload() {
return mode.value === 'yaml' ? { raw: raw.value } : { rules: rules.value }
}
async function validate() {
busy.value = true
problem.value = ''
try {
const res = await api.validateRules(payload())
if (res.valid) toast.success(t('rules.valid'))
else problem.value = res.error ?? t('rules.invalid')
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
async function save() {
busy.value = true
problem.value = ''
try {
const res = await api.saveRules(payload())
rules.value = res.rules ?? []
raw.value = res.raw
dirty.value = false
toast.success(t('rules.saved'), t('rules.count', { n: rules.value.length }))
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
toast.error(t('rules.invalid'), problem.value)
} finally {
busy.value = false
}
}
async function reload() {
busy.value = true
problem.value = ''
try {
const res = await api.rules()
rules.value = res.rules ?? []
raw.value = res.raw
dirty.value = false
} catch (e) {
problem.value = e instanceof ApiError ? e.message : String(e)
} finally {
busy.value = false
}
}
function move(index: number, delta: number) {
const target = index + delta
if (target < 0 || target >= rules.value.length) return
const next = [...rules.value]
const [item] = next.splice(index, 1)
next.splice(target, 0, item)
rules.value = next
dirty.value = true
}
function startAdd() {
editingIndex.value = -1
editing.value = { name: '', action: 'block', log: false, expr: '' }
modifierArgs.value = '{}'
}
function startEdit(index: number) {
editingIndex.value = index
editing.value = { ...rules.value[index] }
modifierArgs.value = JSON.stringify(rules.value[index].modifier?.args ?? {}, null, 2)
}
const modifierArgs = ref('{}')
const editError = ref('')
function commitEdit() {
const draft = editing.value
if (!draft) return
if (!draft.name.trim()) {
editError.value = t('rules.nameRequired')
return
}
if (!draft.expr.trim()) {
editError.value = t('rules.exprRequired')
return
}
if (draft.action === 'modify') {
try {
const args = JSON.parse(modifierArgs.value || '{}') as Record<string, unknown>
draft.modifier = { name: draft.modifier?.name ?? modifierOptions.value[0]?.value ?? '', args }
} catch {
editError.value = 'JSON'
return
}
} else {
draft.modifier = null
}
const next = [...rules.value]
if (editingIndex.value >= 0) next[editingIndex.value] = draft
else next.push(draft)
rules.value = next
dirty.value = true
editing.value = null
editError.value = ''
}
function remove(index: number) {
rules.value = rules.value.filter((_, i) => i !== index)
dirty.value = true
removing.value = -1
}
</script>
<template>
<div>
<PageHeader :title="t('rules.title')" :description="t('rules.subtitle')">
<template #actions>
<Button variant="ghost" size="sm" :disabled="busy" @click="reload">
<RotateCcw class="size-3.5" />
<span class="hidden sm:inline">{{ t('common.refresh') }}</span>
</Button>
<Button variant="outline" size="sm" :disabled="busy" @click="validate">
<CheckCircle2 class="size-3.5" />
<span class="hidden sm:inline">{{ t('rules.validate') }}</span>
</Button>
<Button size="sm" :disabled="busy" @click="save">
<Loader2 v-if="busy" class="size-3.5 animate-spin" />
<Save v-else class="size-3.5" />
{{ t('rules.save') }}
</Button>
</template>
</PageHeader>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Tabs :model-value="mode" @update:model-value="switchMode">
<TabsList>
<TabsTrigger value="visual">{{ t('rules.visual') }}</TabsTrigger>
<TabsTrigger value="yaml">
<FileCode2 />
{{ t('rules.yaml') }}
</TabsTrigger>
</TabsList>
</Tabs>
<Badge variant="secondary">{{ t('rules.count', { n: rules.length }) }}</Badge>
<Badge v-if="dirty" variant="drop">{{ t('rules.unsaved') }}</Badge>
<span
v-if="path"
class="text-muted-foreground ml-auto hidden truncate font-mono text-[11px] sm:inline"
>{{ path }}</span
>
</div>
<p
v-if="problem"
class="border-destructive/40 bg-destructive/10 text-destructive mb-3 rounded-lg border px-3 py-2 font-mono text-xs break-words whitespace-pre-wrap"
>
{{ problem }}
</p>
<Tabs :model-value="mode" @update:model-value="switchMode">
<TabsContent value="visual">
<div v-if="loading" class="text-muted-foreground py-12 text-center text-sm">
{{ t('common.loading') }}
</div>
<div v-else class="flex flex-col gap-2">
<Card
v-for="(rule, index) in rules"
:key="`${rule.name}-${index}`"
class="p-3 sm:p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-muted-foreground font-mono text-[11px] tabular-nums"
>#{{ index + 1 }}</span
>
<span class="truncate text-sm font-medium">{{ rule.name }}</span>
<Badge v-if="rule.action" :variant="badgeVariant(rule.action)" class="uppercase">
{{ t(`action.${rule.action}`) }}
</Badge>
<Badge v-if="rule.log" variant="log">{{ t('action.log') }}</Badge>
<Badge v-if="rule.modifier?.name" variant="outline">
{{ rule.modifier.name }}
</Badge>
</div>
<code
class="text-muted-foreground bg-muted/60 rounded-md px-2 py-1.5 font-mono text-[11px] leading-relaxed break-all sm:text-xs"
>{{ rule.expr }}</code
>
</div>
<div class="flex shrink-0 flex-col gap-1 sm:flex-row">
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('rules.moveUp')"
:disabled="index === 0"
@click="move(index, -1)"
>
<ArrowUp class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('rules.moveDown')"
:disabled="index === rules.length - 1"
@click="move(index, 1)"
>
<ArrowDown class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
:aria-label="t('common.edit')"
@click="startEdit(index)"
>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
:aria-label="t('common.delete')"
@click="removing = index"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</Card>
<p v-if="!rules.length" class="text-muted-foreground py-10 text-center text-sm">
{{ t('rules.empty') }}
</p>
<Button variant="outline" class="mt-1 w-full" @click="startAdd">
<Plus class="size-4" />
{{ t('rules.add') }}
</Button>
</div>
</TabsContent>
<TabsContent value="yaml">
<Textarea
v-model="raw"
spellcheck="false"
class="h-[60vh] resize-y font-mono text-xs leading-relaxed"
@update:model-value="dirty = true"
/>
<p class="text-muted-foreground mt-2 text-xs">{{ t('rules.hint') }}</p>
</TabsContent>
</Tabs>
<!-- Rule editor -->
<Modal
:open="editing !== null"
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
@update:open="!$event && ((editing = null), (editError = ''))"
>
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent="commitEdit">
<div class="flex flex-col gap-2">
<Label for="rule-name">{{ t('rules.name') }}</Label>
<Input id="rule-name" v-model="editing.name" :placeholder="t('rules.namePlaceholder')" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<Label>{{ t('rules.action') }}</Label>
<Select
:model-value="editing.action ?? ''"
:options="actionOptions"
@update:model-value="editing.action = $event"
/>
</div>
<div class="flex flex-col justify-end gap-2">
<Label for="rule-log">{{ t('rules.log') }}</Label>
<div class="flex h-9 items-center">
<Switch id="rule-log" v-model="editing.log" />
</div>
</div>
</div>
<div v-if="editing.action === 'modify'" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<Label>{{ t('rules.modifier') }}</Label>
<Select
:model-value="editing.modifier?.name ?? ''"
:options="modifierOptions"
@update:model-value="
editing.modifier = { name: $event, args: editing.modifier?.args }
"
/>
</div>
<div class="flex flex-col gap-2">
<Label for="rule-args">{{ t('rules.modifierArgs') }}</Label>
<Textarea id="rule-args" v-model="modifierArgs" class="font-mono text-xs" rows="4" />
</div>
</div>
<div class="flex flex-col gap-2">
<Label for="rule-expr">{{ t('rules.expr') }}</Label>
<Textarea
id="rule-expr"
v-model="editing.expr"
spellcheck="false"
:placeholder="t('rules.exprPlaceholder')"
class="min-h-28 font-mono text-xs leading-relaxed"
/>
</div>
<p v-if="editError" class="text-destructive text-sm">{{ editError }}</p>
</form>
<template #footer>
<Button variant="outline" @click="((editing = null), (editError = ''))">
{{ t('common.cancel') }}
</Button>
<Button @click="commitEdit">{{ t('common.save') }}</Button>
</template>
</Modal>
<!-- Delete confirmation -->
<Modal
:open="removing >= 0"
:title="t('common.delete')"
:description="
removing >= 0 ? t('rules.deleteConfirm', { name: rules[removing]?.name ?? '' }) : ''
"
@update:open="!$event && (removing = -1)"
>
<template #footer>
<Button variant="outline" @click="removing = -1">{{ t('common.cancel') }}</Button>
<Button variant="destructive" @click="remove(removing)">{{ t('common.delete') }}</Button>
</template>
</Modal>
</div>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { BookOpen, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from '@/components/PageHeader.vue'
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Select,
Separator,
} from '@/components/ui'
import { stopLive } from '@/composables/useLive'
import { useTheme, type Theme } from '@/composables/useTheme'
import { t, useI18n } from '@/i18n'
import { api } from '@/lib/api'
import type { Info } from '@/lib/types'
const router = useRouter()
const { theme, setTheme } = useTheme()
const { lang, setLang } = useI18n()
const info = ref<Info | null>(null)
onMounted(async () => {
try {
info.value = await api.info()
} catch {
/* the shell already surfaces connection problems */
}
})
const themes = computed(() => [
{ value: 'light', label: t('settings.themeLight'), icon: Sun },
{ value: 'dark', label: t('settings.themeDark'), icon: Moon },
{ value: 'system', label: t('settings.themeSystem'), icon: Monitor },
])
const langOptions = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
]
const rows = computed(() => {
const i = info.value
if (!i) return []
return [
{ label: t('settings.version'), value: i.commit ? `${i.version} (${i.commit})` : i.version },
{ label: t('settings.platform'), value: i.platform },
{ label: t('settings.goVersion'), value: i.goVersion },
{ label: t('settings.hostname'), value: i.hostname },
{ label: t('settings.ruleFile'), value: i.ruleFile },
]
})
const engineRows = computed(() => {
const c = info.value?.config
if (!c) return []
const rows = [
{ label: t('settings.workers'), value: c.workers > 0 ? String(c.workers) : t('settings.auto') },
{
label: t('settings.workerQueueSize'),
value: c.workerQueueSize > 0 ? String(c.workerQueueSize) : t('settings.auto'),
},
{
label: t('settings.ioQueueSize'),
value: c.ioQueueSize > 0 ? String(c.ioQueueSize) : t('settings.auto'),
},
{ label: t('settings.ioLocal'), value: c.ioLocal ? t('common.enabled') : t('common.disabled') },
{ label: t('settings.ioRST'), value: c.ioRST ? t('common.enabled') : t('common.disabled') },
{
label: t('settings.udpMaxStreams'),
value: c.udpMaxStreams > 0 ? String(c.udpMaxStreams) : t('settings.auto'),
},
]
if (c.geoip) rows.push({ label: t('settings.geoip'), value: c.geoip })
if (c.geosite) rows.push({ label: t('settings.geosite'), value: c.geosite })
return rows
})
async function logout() {
stopLive()
await api.logout()
await router.replace({ name: 'login' })
}
</script>
<template>
<div class="max-w-3xl">
<PageHeader :title="t('settings.title')" :description="t('settings.subtitle')" />
<Card>
<CardHeader>
<CardTitle>{{ t('settings.appearance') }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium">{{ t('settings.theme') }}</span>
<div class="bg-muted grid grid-cols-3 gap-1 rounded-lg p-1">
<button
v-for="item in themes"
:key="item.value"
type="button"
class="flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm"
:class="
theme === item.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTheme(item.value as Theme)"
>
<component :is="item.icon" class="size-3.5" />
{{ item.label }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-sm font-medium">{{ t('settings.language') }}</span>
<Select
:model-value="lang"
:options="langOptions"
class="w-36"
:aria-label="t('settings.language')"
@update:model-value="setLang($event as 'en' | 'zh')"
/>
</div>
</CardContent>
</Card>
<Card class="mt-3">
<CardHeader>
<CardTitle>{{ t('settings.instance') }}</CardTitle>
<CardDescription>{{ t('settings.engineConfig') }}</CardDescription>
</CardHeader>
<CardContent>
<dl class="flex flex-col">
<div
v-for="row in rows"
:key="row.label"
class="flex items-start justify-between gap-4 py-1.5 text-sm"
>
<dt class="text-muted-foreground shrink-0">{{ row.label }}</dt>
<dd class="truncate font-mono text-xs sm:text-[13px]">{{ row.value }}</dd>
</div>
</dl>
<Separator class="my-3" />
<dl class="flex flex-col">
<div
v-for="row in engineRows"
:key="row.label"
class="flex items-start justify-between gap-4 py-1.5 text-sm"
>
<dt class="text-muted-foreground shrink-0">{{ row.label }}</dt>
<dd class="truncate font-mono text-xs sm:text-[13px]">{{ row.value }}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card class="mt-3">
<CardHeader>
<CardTitle>{{ t('settings.session') }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Button variant="destructive" @click="logout">
<LogOut class="size-4" />
{{ t('settings.logout') }}
</Button>
<Button variant="outline" as="a" href="https://gfw.dev/" target="_blank" rel="noreferrer">
<BookOpen class="size-4" />
{{ t('settings.docs') }}
</Button>
</CardContent>
</Card>
</div>
</template>
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
// The production build lands in ../dist, which is embedded into the Go binary.
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: '../dist',
emptyOutDir: true,
chunkSizeWarningLimit: 900,
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.OPENGFW_API ?? 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})
+513
View File
@@ -0,0 +1,513 @@
package web
import (
"reflect"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
)
// PropMap is a set of properties produced by a single analyzer.
type PropMap = map[string]interface{}
// Props is the combined property map of all analyzers of a stream.
type Props = map[string]PropMap
// Event kinds.
const (
EventKindAction = "action"
EventKindLog = "log"
EventKindError = "error"
)
// Event is a single entry of the live event feed shown by the web UI.
type Event struct {
Seq uint64 `json:"seq"`
Time int64 `json:"time"` // Unix milliseconds
Kind string `json:"kind"`
ID int64 `json:"id"`
Proto string `json:"proto"`
SrcIP string `json:"srcIP"`
SrcPort uint16 `json:"srcPort"`
DstIP string `json:"dstIP"`
DstPort uint16 `json:"dstPort"`
Action string `json:"action,omitempty"`
Rule string `json:"rule,omitempty"`
Host string `json:"host,omitempty"`
Message string `json:"message,omitempty"`
Props Props `json:"props,omitempty"`
}
// Bucket is a single time slot of the traffic time series.
type Bucket struct {
Time int64 `json:"time"` // Unix milliseconds, start of the bucket
TCP uint64 `json:"tcp"`
UDP uint64 `json:"udp"`
Blocked uint64 `json:"blocked"`
Allowed uint64 `json:"allowed"`
}
// NameCount is a name/count pair used by the various "top N" lists.
type NameCount struct {
Name string `json:"name"`
Count uint64 `json:"count"`
}
const (
defaultEventBufferSize = 512
subscriberQueueSize = 256
maxCounterEntries = 4096
topNLimit = 10
)
// Hub collects statistics from the engine and fans live events out to the
// connected web UI clients. All methods are safe for concurrent use, and the
// ones called from the engine hot path are kept as cheap as possible.
type Hub struct {
startTime time.Time
// Hot path counters. Updated with atomics, no locking involved.
tcpStreams atomic.Uint64
udpStreams atomic.Uint64
actAllow atomic.Uint64
actBlock atomic.Uint64
actDrop atomic.Uint64
actModify atomic.Uint64
actMaybe atomic.Uint64
errors atomic.Uint64
workersAlive atomic.Int64
mu sync.Mutex
seq uint64
events []*Event // ring buffer, oldest first once wrapped
evHead int
evFilled bool
short *series // 10 seconds per bucket, 10 minutes total
long *series // 1 minute per bucket, 1 hour total
hosts *counterMap
dstIPs *counterMap
rules *counterMap
analyzers *counterMap
subs map[int]chan *Event
next int
}
// NewHub creates a new statistics hub.
func NewHub() *Hub {
return &Hub{
startTime: time.Now(),
events: make([]*Event, defaultEventBufferSize),
short: newSeries(10*time.Second, 60),
long: newSeries(time.Minute, 60),
hosts: newCounterMap(),
dstIPs: newCounterMap(),
rules: newCounterMap(),
analyzers: newCounterMap(),
subs: make(map[int]chan *Event),
}
}
// WorkerStarted / WorkerStopped track the number of running workers.
func (h *Hub) WorkerStarted() { h.workersAlive.Add(1) }
func (h *Hub) WorkerStopped() { h.workersAlive.Add(-1) }
// StreamNew records a newly seen stream.
func (h *Hub) StreamNew(proto string) {
if proto == "udp" {
h.udpStreams.Add(1)
} else {
h.tcpStreams.Add(1)
}
h.mu.Lock()
now := time.Now()
h.short.add(now, proto, "")
h.long.add(now, proto, "")
h.mu.Unlock()
}
// PropUpdate records the analyzer properties of a stream. It is used to build
// the "top hosts" list and the per-analyzer hit counters.
func (h *Hub) PropUpdate(props Props) {
if len(props) == 0 {
return
}
host := HostOf(props)
h.mu.Lock()
for name := range props {
h.analyzers.inc(name)
}
if host != "" {
h.hosts.inc(host)
}
h.mu.Unlock()
}
// StreamAction records the verdict of a stream and, unless the verdict is the
// neutral "maybe", publishes an event to the live feed.
func (h *Hub) StreamAction(info StreamInfo, action string) {
switch action {
case "allow":
h.actAllow.Add(1)
case "block":
h.actBlock.Add(1)
case "drop":
h.actDrop.Add(1)
case "modify":
h.actModify.Add(1)
default:
h.actMaybe.Add(1)
return
}
now := time.Now()
h.mu.Lock()
h.short.add(now, "", action)
h.long.add(now, "", action)
if action == "block" || action == "drop" {
if info.DstIP != "" {
h.dstIPs.inc(info.DstIP)
}
}
h.mu.Unlock()
h.publish(now, EventKindAction, info, action, "", "")
}
// RuleLog records a `log: true` hit of a ruleset rule.
func (h *Hub) RuleLog(info StreamInfo, rule string) {
h.mu.Lock()
h.rules.inc(rule)
h.mu.Unlock()
h.publish(time.Now(), EventKindLog, info, "", rule, "")
}
// Error records an engine or ruleset error.
func (h *Hub) Error(info StreamInfo, rule, message string) {
h.errors.Add(1)
h.publish(time.Now(), EventKindError, info, "", rule, message)
}
// StreamInfo is the subset of ruleset.StreamInfo the hub cares about.
type StreamInfo struct {
ID int64
Proto string
SrcIP string
SrcPort uint16
DstIP string
DstPort uint16
Props Props
}
func (h *Hub) publish(now time.Time, kind string, info StreamInfo, action, rule, message string) {
h.mu.Lock()
h.seq++
ev := &Event{
Seq: h.seq,
Time: now.UnixMilli(),
Kind: kind,
ID: info.ID,
Proto: info.Proto,
SrcIP: info.SrcIP,
SrcPort: info.SrcPort,
DstIP: info.DstIP,
DstPort: info.DstPort,
Action: action,
Rule: rule,
Message: message,
Host: HostOf(info.Props),
Props: info.Props,
}
h.events[h.evHead] = ev
h.evHead = (h.evHead + 1) % len(h.events)
if h.evHead == 0 {
h.evFilled = true
}
for _, ch := range h.subs {
select {
case ch <- ev:
default: // Slow client, drop the event rather than blocking the engine
}
}
h.mu.Unlock()
}
// Subscribe returns a channel of live events and a function to unsubscribe.
func (h *Hub) Subscribe() (<-chan *Event, func()) {
ch := make(chan *Event, subscriberQueueSize)
h.mu.Lock()
id := h.next
h.next++
h.subs[id] = ch
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
if c, ok := h.subs[id]; ok {
delete(h.subs, id)
close(c)
}
h.mu.Unlock()
}
}
// Events returns up to limit most recent events, newest first.
func (h *Hub) Events(limit int) []*Event {
h.mu.Lock()
defer h.mu.Unlock()
size := len(h.events)
n := h.evHead
if h.evFilled {
n = size
}
if limit <= 0 || limit > n {
limit = n
}
out := make([]*Event, 0, limit)
for i := 0; i < limit; i++ {
idx := (h.evHead - 1 - i + size*2) % size
if h.events[idx] == nil {
break
}
out = append(out, h.events[idx])
}
return out
}
// Metrics is the snapshot of everything the dashboard needs.
type Metrics struct {
UptimeSeconds int64 `json:"uptimeSeconds"`
Workers int64 `json:"workers"`
Streams StreamStats `json:"streams"`
Actions ActionStats `json:"actions"`
Errors uint64 `json:"errors"`
Short []Bucket `json:"short"`
Long []Bucket `json:"long"`
TopHosts []NameCount `json:"topHosts"`
TopBlockedIPs []NameCount `json:"topBlockedIPs"`
TopRules []NameCount `json:"topRules"`
Analyzers []NameCount `json:"analyzers"`
}
type StreamStats struct {
TCP uint64 `json:"tcp"`
UDP uint64 `json:"udp"`
Total uint64 `json:"total"`
}
type ActionStats struct {
Allow uint64 `json:"allow"`
Block uint64 `json:"block"`
Drop uint64 `json:"drop"`
Modify uint64 `json:"modify"`
Maybe uint64 `json:"maybe"`
}
// Metrics returns a consistent snapshot of the collected statistics.
func (h *Hub) Metrics() Metrics {
tcp, udp := h.tcpStreams.Load(), h.udpStreams.Load()
m := Metrics{
UptimeSeconds: int64(time.Since(h.startTime).Seconds()),
Workers: h.workersAlive.Load(),
Streams: StreamStats{TCP: tcp, UDP: udp, Total: tcp + udp},
Actions: ActionStats{
Allow: h.actAllow.Load(),
Block: h.actBlock.Load(),
Drop: h.actDrop.Load(),
Modify: h.actModify.Load(),
Maybe: h.actMaybe.Load(),
},
Errors: h.errors.Load(),
}
now := time.Now()
h.mu.Lock()
m.Short = h.short.snapshot(now)
m.Long = h.long.snapshot(now)
m.TopHosts = h.hosts.top(topNLimit)
m.TopBlockedIPs = h.dstIPs.top(topNLimit)
m.TopRules = h.rules.top(topNLimit)
m.Analyzers = h.analyzers.top(maxCounterEntries)
h.mu.Unlock()
return m
}
// HostOf extracts the most meaningful hostname out of a property map, so that
// the UI can show something more useful than a bare IP address.
func HostOf(props Props) string {
if props == nil {
return ""
}
for _, path := range [][]string{
{"tls", "req", "sni"},
{"quic", "req", "sni"},
{"http", "req", "headers", "host"},
{"dns", "questions", "0", "name"},
} {
if s := nestedString(props, path...); s != "" {
return s
}
}
return ""
}
// nestedString walks a path of keys (or numeric slice indices) and returns the
// value at the end of it if it is a string. Analyzers use named map types, so
// the lookups go through reflection instead of plain type assertions.
func nestedString(root interface{}, path ...string) string {
cur := root
for _, key := range path {
next, ok := index(cur, key)
if !ok {
return ""
}
cur = next
}
s, _ := cur.(string)
return s
}
func index(container interface{}, key string) (interface{}, bool) {
if container == nil {
return nil, false
}
if m, ok := container.(map[string]interface{}); ok {
v, ok := m[key]
return v, ok
}
v := reflect.ValueOf(container)
switch v.Kind() {
case reflect.Map:
if v.Type().Key().Kind() != reflect.String {
return nil, false
}
mv := v.MapIndex(reflect.ValueOf(key).Convert(v.Type().Key()))
if !mv.IsValid() {
return nil, false
}
return mv.Interface(), true
case reflect.Slice, reflect.Array:
i, err := strconv.Atoi(key)
if err != nil || i < 0 || i >= v.Len() {
return nil, false
}
return v.Index(i).Interface(), true
default:
return nil, false
}
}
// series is a fixed size ring of time buckets.
type series struct {
dur time.Duration
buckets []Bucket
head int // index of the newest bucket
}
func newSeries(dur time.Duration, size int) *series {
s := &series{dur: dur, buckets: make([]Bucket, size)}
now := time.Now()
for i := range s.buckets {
s.buckets[i].Time = s.align(now.Add(-time.Duration(len(s.buckets)-1-i) * dur))
}
s.head = len(s.buckets) - 1
return s
}
func (s *series) align(t time.Time) int64 {
return t.Truncate(s.dur).UnixMilli()
}
// rotate advances the ring so that the newest bucket matches now.
func (s *series) rotate(now time.Time) *Bucket {
ts := s.align(now)
head := &s.buckets[s.head]
if head.Time == ts {
return head
}
if ts < head.Time {
// Clock went backwards, keep using the current bucket.
return head
}
steps := int((ts - head.Time) / s.dur.Milliseconds())
if steps > len(s.buckets) {
steps = len(s.buckets)
}
for i := 0; i < steps; i++ {
s.head = (s.head + 1) % len(s.buckets)
s.buckets[s.head] = Bucket{Time: head.Time + int64(i+1)*s.dur.Milliseconds()}
}
s.buckets[s.head].Time = ts
return &s.buckets[s.head]
}
func (s *series) add(now time.Time, proto, action string) {
b := s.rotate(now)
switch proto {
case "tcp":
b.TCP++
case "udp":
b.UDP++
}
switch action {
case "block", "drop":
b.Blocked++
case "allow":
b.Allowed++
}
}
// snapshot returns the buckets in chronological order.
func (s *series) snapshot(now time.Time) []Bucket {
s.rotate(now)
out := make([]Bucket, 0, len(s.buckets))
for i := 1; i <= len(s.buckets); i++ {
out = append(out, s.buckets[(s.head+i)%len(s.buckets)])
}
return out
}
// counterMap is a bounded string counter used for the "top N" lists.
type counterMap struct {
m map[string]uint64
}
func newCounterMap() *counterMap {
return &counterMap{m: make(map[string]uint64)}
}
func (c *counterMap) inc(key string) {
if len(c.m) >= maxCounterEntries {
if _, ok := c.m[key]; !ok {
c.prune()
}
}
c.m[key]++
}
// prune keeps the most frequent half of the entries.
func (c *counterMap) prune() {
keep := c.top(maxCounterEntries / 2)
m := make(map[string]uint64, len(keep))
for _, kv := range keep {
m[kv.Name] = kv.Count
}
c.m = m
}
func (c *counterMap) top(n int) []NameCount {
out := make([]NameCount, 0, len(c.m))
for k, v := range c.m {
out = append(out, NameCount{Name: k, Count: v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Name < out[j].Name
})
if len(out) > n {
out = out[:n]
}
return out
}
+262
View File
@@ -0,0 +1,262 @@
package web
import (
"context"
"errors"
"io/fs"
"net"
"net/http"
"path"
"strings"
"time"
)
var (
errBadCredentials = errors.New("invalid password")
errTooManyAttempts = errors.New("too many failed attempts, try again later")
errInternal = errors.New("internal error")
)
// Rule is the JSON representation of a single ruleset rule. The YAML tags
// mirror the rule file format, so the same struct can be written back out.
type Rule struct {
Name string `json:"name" yaml:"name"`
Action string `json:"action,omitempty" yaml:"action,omitempty"`
Log bool `json:"log,omitempty" yaml:"log,omitempty"`
Modifier *RuleModifier `json:"modifier,omitempty" yaml:"modifier,omitempty"`
Expr string `json:"expr" yaml:"expr"`
}
// RuleModifier is the modifier attached to a `modify` rule.
type RuleModifier struct {
Name string `json:"name" yaml:"name"`
Args map[string]interface{} `json:"args,omitempty" yaml:"args,omitempty"`
}
// RuleManager gives the web server access to the running ruleset. The
// implementation lives in the cmd package, which owns both the rule file and
// the engine.
type RuleManager interface {
// Path returns the path of the rule file.
Path() string
// Load reads the rule file and returns its raw content and parsed rules.
Load() (string, []Rule, error)
// Validate parses and compiles the given YAML without applying it.
Validate(raw string) ([]Rule, error)
// Marshal serializes structured rules back to YAML.
Marshal(rules []Rule) (string, error)
// Apply validates the given YAML, writes it to the rule file and hot
// reloads the engine.
Apply(raw string) ([]Rule, error)
}
// Info is the static information about the running instance shown by the UI.
type Info struct {
Version string `json:"version"`
Commit string `json:"commit,omitempty"`
Platform string `json:"platform"`
GoVersion string `json:"goVersion"`
Hostname string `json:"hostname"`
RuleFile string `json:"ruleFile"`
Config ConfigDigest `json:"config"`
}
// ConfigDigest is a read-only summary of the engine configuration.
type ConfigDigest struct {
IOQueueSize uint32 `json:"ioQueueSize"`
IOLocal bool `json:"ioLocal"`
IORST bool `json:"ioRST"`
Workers int `json:"workers"`
WorkerQueue int `json:"workerQueueSize"`
UDPMaxStreams int `json:"udpMaxStreams"`
GeoIP string `json:"geoip,omitempty"`
GeoSite string `json:"geosite,omitempty"`
}
// MetaInfo describes what the engine is capable of. The rule editor uses it
// for autocompletion and validation hints.
type MetaInfo struct {
Analyzers []AnalyzerInfo `json:"analyzers"`
Modifiers []string `json:"modifiers"`
Actions []string `json:"actions"`
Functions []string `json:"functions"`
}
// AnalyzerInfo describes a single analyzer.
type AnalyzerInfo struct {
Name string `json:"name"`
Proto string `json:"proto"`
}
// Config is the web server configuration.
type Config struct {
// Listen is the address to listen on, e.g. ":8080".
Listen string
// Secret is the password required to log in. Must not be empty.
Secret string
// CertFile / KeyFile enable HTTPS when both are set.
CertFile string
KeyFile string
Hub *Hub
Rules RuleManager
Meta MetaInfo
Info func() Info
// Logf is used for the few messages the server produces. Optional.
Logf func(format string, args ...interface{})
}
// Server serves the web UI and its API.
type Server struct {
config Config
auth *authenticator
mux *http.ServeMux
static http.Handler
}
// NewServer creates a new web UI server.
func NewServer(config Config) (*Server, error) {
if config.Hub == nil {
return nil, errors.New("web: hub is required")
}
if config.Secret == "" {
return nil, errors.New("web: secret is required")
}
if config.Listen == "" {
config.Listen = ":8080"
}
if config.Logf == nil {
config.Logf = func(string, ...interface{}) {}
}
s := &Server{
config: config,
auth: newAuthenticator(config.Secret),
mux: http.NewServeMux(),
static: staticHandler(),
}
s.routes()
return s, nil
}
// Secret returns the password in use, which is useful when it was generated.
func (s *Server) Secret() string { return s.config.Secret }
// Addr returns the address the server listens on.
func (s *Server) Addr() string { return s.config.Listen }
// TLS reports whether the server serves HTTPS.
func (s *Server) TLS() bool { return s.config.CertFile != "" && s.config.KeyFile != "" }
func (s *Server) routes() {
s.mux.HandleFunc("/api/v1/login", s.handleLogin)
s.mux.HandleFunc("/api/v1/logout", s.guard(s.handleLogout, true))
s.mux.HandleFunc("/api/v1/info", s.guard(s.handleInfo, false))
s.mux.HandleFunc("/api/v1/meta", s.guard(s.handleMeta, false))
s.mux.HandleFunc("/api/v1/metrics", s.guard(s.handleMetrics, false))
s.mux.HandleFunc("/api/v1/events", s.guard(s.handleEvents, false))
s.mux.HandleFunc("/api/v1/live", s.guard(s.handleLive, false))
s.mux.HandleFunc("/api/v1/rules", s.guard(s.handleRules, false))
s.mux.HandleFunc("/api/v1/rules/validate", s.guard(s.handleRulesValidate, true))
s.mux.HandleFunc("/", s.handleStatic)
}
// Run starts the server and blocks until the context is cancelled.
func (s *Server) Run(ctx context.Context) error {
srv := &http.Server{
Handler: s.securityHeaders(s.mux),
ReadHeaderTimeout: 10 * time.Second,
BaseContext: func(net.Listener) context.Context { return ctx },
}
ln, err := net.Listen("tcp", s.config.Listen)
if err != nil {
return err
}
errChan := make(chan error, 1)
go func() {
if s.TLS() {
errChan <- srv.ServeTLS(ln, s.config.CertFile, s.config.KeyFile)
} else {
errChan <- srv.Serve(ln)
}
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
return nil
case err := <-errChan:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
// guard wraps a handler with authentication. When mutating is true, a bearer
// token is required (a session cookie alone is not enough), which makes the
// endpoint immune to cross-site request forgery.
func (s *Server) guard(next http.HandlerFunc, mutating bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" && !mutating && r.Method == http.MethodGet {
token = cookieToken(r)
}
if !s.auth.valid(token) {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, "not found")
return
}
s.static.ServeHTTP(w, r)
}
// staticHandler serves the embedded single page application, falling back to
// index.html so that client side routing works on a hard refresh.
func staticHandler() http.Handler {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "web UI is not built", http.StatusNotImplemented)
})
}
files := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name == "" || name == "." {
name = "index.html"
}
if _, err := fs.Stat(sub, name); err != nil {
// Unknown path: let the SPA router handle it.
r = r.Clone(r.Context())
r.URL.Path = "/"
w.Header().Set("Cache-Control", "no-store")
files.ServeHTTP(w, r)
return
}
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
})
}