feat: Phase 1 — Type 0 Router with synchronous Wallarm inspection, circuit breaker, config UI
This commit is contained in:
parent
9944009ada
commit
85d90bef1e
10 changed files with 596 additions and 25 deletions
76
circuit_breaker.go
Normal file
76
circuit_breaker.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CircuitState represents the breaker's current state.
|
||||
type CircuitState int
|
||||
|
||||
const (
|
||||
StateClosed CircuitState = iota // normal — requests pass through
|
||||
StateOpen // tripped — fail-open
|
||||
StateHalfOpen // testing recovery
|
||||
)
|
||||
|
||||
// CircuitBreaker implements a simple fail-open pattern.
|
||||
// When failures exceed the threshold within the window, the breaker opens.
|
||||
// After a cooldown, it moves to half-open to test recovery.
|
||||
type CircuitBreaker struct {
|
||||
mu sync.Mutex
|
||||
state CircuitState
|
||||
failures int
|
||||
lastFailure time.Time
|
||||
maxFailures int
|
||||
window time.Duration
|
||||
cooldown time.Duration
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
func newCircuitBreaker() *CircuitBreaker {
|
||||
return &CircuitBreaker{
|
||||
state: StateClosed,
|
||||
maxFailures: 5,
|
||||
window: 30 * time.Second,
|
||||
cooldown: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// state returns the current state, testing for recovery if half-open and cooldown elapsed.
|
||||
func (cb *CircuitBreaker) State() CircuitState {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
if cb.state == StateOpen && time.Since(cb.openedAt) > cb.cooldown {
|
||||
cb.state = StateHalfOpen
|
||||
}
|
||||
// Reset failure count if window elapsed.
|
||||
if time.Since(cb.lastFailure) > cb.window {
|
||||
cb.failures = 0
|
||||
if cb.state == StateHalfOpen {
|
||||
cb.state = StateClosed
|
||||
}
|
||||
}
|
||||
return cb.state
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) RecordSuccess() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
if cb.state == StateHalfOpen {
|
||||
cb.state = StateClosed
|
||||
}
|
||||
cb.failures = 0
|
||||
}
|
||||
|
||||
func (cb *CircuitBreaker) RecordFailure() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
cb.failures++
|
||||
cb.lastFailure = time.Now()
|
||||
if cb.failures >= cb.maxFailures {
|
||||
cb.state = StateOpen
|
||||
cb.openedAt = time.Now()
|
||||
}
|
||||
}
|
||||
65
config.go
Normal file
65
config.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Config holds all WAF plugin settings, persisted as JSON.
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
NodeURL string `json:"node_url"` // e.g. http://192.168.1.50:8080
|
||||
ReturnPort int `json:"return_port"` // port for Wallarm async callbacks
|
||||
TimeoutMs int `json:"timeout_ms"` // inspection timeout in milliseconds
|
||||
}
|
||||
|
||||
const configFile = "waf_config.json"
|
||||
|
||||
var (
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
)
|
||||
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
Enabled: false,
|
||||
NodeURL: "http://127.0.0.1:8080",
|
||||
ReturnPort: 9090,
|
||||
TimeoutMs: 3000,
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
cfgMu.Lock()
|
||||
defer cfgMu.Unlock()
|
||||
|
||||
f, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
cfg = defaultConfig()
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
|
||||
log.Printf("WARN: corrupt config, using defaults: %v", err)
|
||||
cfg = defaultConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func saveConfig() error {
|
||||
cfgMu.RLock()
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
cfgMu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configFile, data, 0644)
|
||||
}
|
||||
|
||||
func getConfig() Config {
|
||||
cfgMu.RLock()
|
||||
defer cfgMu.RUnlock()
|
||||
return cfg
|
||||
}
|
||||
114
main.go
114
main.go
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
|
@ -13,18 +14,24 @@ import (
|
|||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
loadConfig()
|
||||
|
||||
spec := &zoraxy_plugin.IntroSpect{
|
||||
ID: "zoraxy-waf",
|
||||
Name: "Zoraxy WAF",
|
||||
Author: "Zoraxy Community",
|
||||
AuthorContact: "",
|
||||
Description: "Web Application Firewall plugin for Zoraxy.",
|
||||
Description: "Web Application Firewall — synchronous Wallarm inspection with circuit breaker and fail-open protection.",
|
||||
URL: "",
|
||||
Type: zoraxy_plugin.PluginType_Utilities,
|
||||
Type: zoraxy_plugin.PluginType_Router, // Type 0 — intercepts traffic
|
||||
VersionMajor: 1,
|
||||
VersionMinor: 0,
|
||||
VersionPatch: 0,
|
||||
UIPath: "/ui",
|
||||
|
||||
// Intercept ALL proxied requests.
|
||||
StaticCapturePaths: []zoraxy_plugin.StaticCaptureRule{{CapturePath: "/"}},
|
||||
StaticCaptureIngress: "/inspect",
|
||||
}
|
||||
|
||||
config, err := zoraxy_plugin.ServeAndRecvSpec(spec)
|
||||
|
|
@ -34,22 +41,37 @@ func main() {
|
|||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(
|
||||
spec.ID,
|
||||
&webFS,
|
||||
"web",
|
||||
spec.UIPath,
|
||||
)
|
||||
// --- Static capture handler (traffic interception) ---
|
||||
mux.HandleFunc("/inspect", handleInspect)
|
||||
|
||||
// Register API endpoints here.
|
||||
// Example: uiRouter.HandleFunc("/api/status", handleStatus, mux)
|
||||
// --- UI + config API ---
|
||||
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(
|
||||
spec.ID, &webFS, "web", spec.UIPath,
|
||||
)
|
||||
uiRouter.HandleFunc("/api/config", handleGetConfig, mux)
|
||||
uiRouter.HandleFunc("/api/config/save", handleSaveConfig, mux)
|
||||
uiRouter.HandleFunc("/api/stats", handleGetStats, mux)
|
||||
|
||||
uiRouter.RegisterTerminateHandler(func() {
|
||||
log.Println("zoraxy-waf shutting down")
|
||||
}, mux)
|
||||
|
||||
uiRouter.AttachHandlerToMux(mux)
|
||||
|
||||
// --- Return port listener for Wallarm async callbacks (future) ---
|
||||
cfg := getConfig()
|
||||
if cfg.ReturnPort > 0 {
|
||||
go func() {
|
||||
rmux := http.NewServeMux()
|
||||
rmux.HandleFunc("/verdict", handleReturnVerdict)
|
||||
addr := fmt.Sprintf(":%d", cfg.ReturnPort)
|
||||
log.Printf("WAF return listener on %s", addr)
|
||||
if err := http.ListenAndServe(addr, rmux); err != nil {
|
||||
log.Printf("WAF return listener error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// --- Main plugin listener ---
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
|
||||
log.Printf("zoraxy-waf v%d.%d.%d listening on %s",
|
||||
spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr)
|
||||
|
|
@ -58,3 +80,73 @@ func main() {
|
|||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config API handlers ---
|
||||
|
||||
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
c := getConfig()
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid form"})
|
||||
return
|
||||
}
|
||||
|
||||
cfgMu.Lock()
|
||||
cfg.Enabled = r.FormValue("enabled") == "true"
|
||||
if u := r.FormValue("node_url"); u != "" {
|
||||
cfg.NodeURL = u
|
||||
}
|
||||
if p := r.FormValue("return_port"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &cfg.ReturnPort)
|
||||
}
|
||||
if t := r.FormValue("timeout_ms"); t != "" {
|
||||
fmt.Sscanf(t, "%d", &cfg.TimeoutMs)
|
||||
}
|
||||
cfgMu.Unlock()
|
||||
|
||||
if err := saveConfig(); err != nil {
|
||||
log.Printf("ERROR saving config: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "save failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "saved"})
|
||||
}
|
||||
|
||||
func handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
state := breaker.State()
|
||||
var stateStr string
|
||||
switch state {
|
||||
case StateClosed:
|
||||
stateStr = "closed"
|
||||
case StateOpen:
|
||||
stateStr = "open"
|
||||
case StateHalfOpen:
|
||||
stateStr = "half-open"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"circuit": stateStr,
|
||||
"enabled": getConfig().Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
func handleReturnVerdict(w http.ResponseWriter, r *http.Request) {
|
||||
// Future: Wallarm async callbacks arrive here.
|
||||
var resp InspectionResponse
|
||||
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad verdict"})
|
||||
return
|
||||
}
|
||||
log.Printf("WAF async verdict: %s (reason=%s)", resp.Verdict, resp.Reason)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
|
|
|||
55
middleware.go
Normal file
55
middleware.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"zoraxy-waf/zoraxy_plugin"
|
||||
)
|
||||
|
||||
var breaker = newCircuitBreaker()
|
||||
|
||||
// handleInspect is the static capture ingress handler.
|
||||
// Zoraxy proxies matching requests here. The plugin decides:
|
||||
// - Return 280 (CAPTURED) → block the request
|
||||
// - Return 284 (UNHANDLED) → forward as normal
|
||||
func handleInspect(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := getConfig()
|
||||
if !cfg.Enabled {
|
||||
// WAF disabled — pass through.
|
||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||
return
|
||||
}
|
||||
|
||||
state := breaker.State()
|
||||
if state == StateOpen {
|
||||
// Fail-open: Wallarm is down, let traffic through.
|
||||
log.Printf("WAF: breaker OPEN — fail-open, passing %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := inspect(r)
|
||||
if err != nil {
|
||||
log.Printf("WAF: inspection error: %v", err)
|
||||
breaker.RecordFailure()
|
||||
// Fail-open on error.
|
||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||
return
|
||||
}
|
||||
|
||||
breaker.RecordSuccess()
|
||||
|
||||
if resp.Verdict == "block" {
|
||||
log.Printf("WAF: BLOCKED %s %s — reason: %s", r.Method, r.URL.Path, resp.Reason)
|
||||
// Return the Zoraxy control code for "captured" (blocked).
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_CAPTURED))
|
||||
fmt.Fprintf(w, `<html><body><h1>403 Forbidden</h1><p>Request blocked by WAF.</p></body></html>`)
|
||||
return
|
||||
}
|
||||
|
||||
// Allowed — let Zoraxy forward normally.
|
||||
w.WriteHeader(int(zoraxy_plugin.ControlStatusCode_UNHANDLED))
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package main
|
||||
|
||||
// Data models go here.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package main
|
||||
|
||||
// HTTP handlers and core logic go here.
|
||||
86
wallarm_client.go
Normal file
86
wallarm_client.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InspectionRequest is sent to the Wallarm node.
|
||||
type InspectionRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
ContentType string `json:"content_type"`
|
||||
ContentLength int64 `json:"content_length"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
BodySample []byte `json:"body_sample"` // first N bytes
|
||||
}
|
||||
|
||||
// InspectionResponse is returned by the Wallarm node.
|
||||
type InspectionResponse struct {
|
||||
Verdict string `json:"verdict"` // "allow" or "block"
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Score int `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
const maxBodySample = 4096 // only send first 4KB to Wallarm
|
||||
|
||||
// inspect sends a synchronous inspection request to the Wallarm node.
|
||||
// Returns nil if allowed, or an error describing the block reason.
|
||||
func inspect(r *http.Request) (*InspectionResponse, error) {
|
||||
c := getConfig()
|
||||
|
||||
// Sample the first N bytes of the body.
|
||||
var bodySample []byte
|
||||
if r.Body != nil {
|
||||
bodySample = make([]byte, maxBodySample)
|
||||
n, _ := io.ReadFull(r.Body, bodySample)
|
||||
bodySample = bodySample[:n]
|
||||
// Restore body for downstream use.
|
||||
r.Body = io.NopCloser(io.MultiReader(
|
||||
bytes.NewReader(bodySample),
|
||||
r.Body,
|
||||
))
|
||||
}
|
||||
|
||||
inspReq := InspectionRequest{
|
||||
Method: r.Method,
|
||||
URL: r.URL.String(),
|
||||
Host: r.Host,
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
ContentLength: r.ContentLength,
|
||||
Headers: map[string][]string(r.Header),
|
||||
BodySample: bodySample,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(inspReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal inspection request: %w", err)
|
||||
}
|
||||
|
||||
timeout := time.Duration(c.TimeoutMs) * time.Millisecond
|
||||
client := &http.Client{Timeout: timeout}
|
||||
|
||||
resp, err := client.Post(c.NodeURL+"/inspect", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wallarm node unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var inspResp InspectionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&inspResp); err != nil {
|
||||
return nil, fmt.Errorf("decode wallarm response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("WAF: %s %s → %s (score=%d reason=%s)",
|
||||
r.Method, r.URL.Path, inspResp.Verdict, inspResp.Score, inspResp.Reason)
|
||||
|
||||
return &inspResp, nil
|
||||
}
|
||||
57
web/app.js
57
web/app.js
|
|
@ -1,4 +1,55 @@
|
|||
// Zoraxy WAF — client-side logic
|
||||
$(document).ready(function () {
|
||||
console.log('Zoraxy WAF plugin loaded');
|
||||
// Zoraxy WAF — config panel
|
||||
|
||||
function loadConfig() {
|
||||
$.get('./api/config', function (data) {
|
||||
$('#waf-enabled').prop('checked', data.enabled);
|
||||
$('#enabled-label').text(data.enabled ? 'WAF Enabled' : 'WAF Disabled');
|
||||
$('#node-url').val(data.node_url);
|
||||
$('#return-port').val(data.return_port);
|
||||
$('#timeout-ms').val(data.timeout_ms);
|
||||
});
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
$.get('./api/stats', function (data) {
|
||||
$('#circuit-state').text(data.circuit).attr('class', 'stat-value ' +
|
||||
(data.circuit === 'open' ? 'warn' : data.circuit === 'closed' ? 'ok' : 'warn'));
|
||||
$('#waf-status').text(data.enabled ? 'Active' : 'Disabled').attr('class', 'stat-value ' +
|
||||
(data.enabled ? 'ok' : 'off'));
|
||||
});
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
$('#save-status').text('Saving...');
|
||||
$.cjax({
|
||||
url: './api/config/save',
|
||||
type: 'POST',
|
||||
data: {
|
||||
enabled: $('#waf-enabled').is(':checked'),
|
||||
node_url: $('#node-url').val().trim(),
|
||||
return_port: $('#return-port').val(),
|
||||
timeout_ms: $('#timeout-ms').val()
|
||||
},
|
||||
success: function () {
|
||||
$('#save-status').text('Saved ✓');
|
||||
loadConfig();
|
||||
loadStats();
|
||||
setTimeout(function () { $('#save-status').text(''); }, 2000);
|
||||
},
|
||||
error: function () {
|
||||
$('#save-status').css('color', '#d63031').text('Save failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#waf-enabled').on('change', function () {
|
||||
$('#enabled-label').text(this.checked ? 'WAF Enabled' : 'WAF Disabled');
|
||||
});
|
||||
|
||||
$('#save-btn').on('click', saveConfig);
|
||||
|
||||
$(document).ready(function () {
|
||||
loadConfig();
|
||||
loadStats();
|
||||
setInterval(loadStats, 10000); // refresh stats every 10s
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,11 +10,53 @@
|
|||
<body>
|
||||
<header>
|
||||
<h1>Zoraxy WAF</h1>
|
||||
<p>Web Application Firewall — coming soon.</p>
|
||||
<p>Web Application Firewall with Wallarm inspection.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p>Configure firewall rules, rate limits, and IP blocking from this panel.</p>
|
||||
<section class="config-panel">
|
||||
<h2>Configuration</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="waf-enabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span id="enabled-label">WAF Disabled</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="node-url">Wallarm Node URL</label>
|
||||
<input type="text" id="node-url" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="return-port">Return Port (async callbacks)</label>
|
||||
<input type="number" id="return-port" placeholder="9090" min="1" max="65535">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="timeout-ms">Inspection Timeout (ms)</label>
|
||||
<input type="number" id="timeout-ms" placeholder="3000" min="100" max="30000">
|
||||
</div>
|
||||
|
||||
<button id="save-btn" class="btn btn-primary">Save Configuration</button>
|
||||
<span id="save-status"></span>
|
||||
</section>
|
||||
|
||||
<section class="stats-panel">
|
||||
<h2>Status</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Circuit Breaker:</span>
|
||||
<span id="circuit-state" class="stat-value">--</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">WAF Status:</span>
|
||||
<span id="waf-status" class="stat-value">--</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/script/jquery-3.6.0.min.js"></script>
|
||||
<script src="/script/utils.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
|
|
|
|||
116
web/style.css
116
web/style.css
|
|
@ -10,9 +10,119 @@ body {
|
|||
color: #1a1a2e;
|
||||
background: #f5f6fa;
|
||||
padding: 20px;
|
||||
max-width: 960px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 { font-size: 20px; margin-bottom: 8px; }
|
||||
p { color: #636e72; }
|
||||
h1 { font-size: 20px; margin-bottom: 4px; }
|
||||
header p { color: #636e72; margin-bottom: 20px; }
|
||||
h2 { font-size: 16px; margin: 20px 0 12px; border-bottom: 1px solid #eee; padding-bottom: 6px; }
|
||||
|
||||
section {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #636e72;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field input[type="text"],
|
||||
.field input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #dcdde1;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: #0984e3;
|
||||
box-shadow: 0 0 0 2px rgba(9,132,227,0.15);
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.toggle input { display: none; }
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: #dcdde1;
|
||||
border-radius: 24px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 18px; width: 18px;
|
||||
left: 3px; bottom: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .slider { background: #00b894; }
|
||||
.toggle input:checked + .slider::before { transform: translateX(20px); }
|
||||
|
||||
#enabled-label {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary { background: #0984e3; color: #fff; }
|
||||
.btn-primary:hover { background: #0773c5; }
|
||||
|
||||
#save-status {
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
color: #00b894;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f5;
|
||||
}
|
||||
.stat-row:last-child { border-bottom: none; }
|
||||
.stat-label { color: #636e72; }
|
||||
.stat-value { font-weight: 600; }
|
||||
.stat-value.ok { color: #00b894; }
|
||||
.stat-value.warn { color: #e17055; }
|
||||
.stat-value.off { color: #b2bec3; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue