diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbc37f1..9bba201 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,27 @@ jobs: container: ${{ matrix.container || '' }} env: GOFLAGS: -buildvcs=false + PRO_TOKEN: ${{ secrets.PRO_TOKEN }} + PRO_SSH_KEY: ${{ secrets.PRO_SSH_KEY }} defaults: run: shell: bash steps: - uses: actions/checkout@v5 + - uses: actions/checkout@v5 + if: ${{ env.PRO_TOKEN != '' || env.PRO_SSH_KEY != '' }} + with: + repository: githubflyideas/traffic66-pro + token: ${{ secrets.PRO_TOKEN || github.token }} + ssh-key: ${{ secrets.PRO_SSH_KEY }} + path: .pro + - name: Registered features + if: ${{ env.PRO_TOKEN != '' || env.PRO_SSH_KEY != '' }} + run: | + cp -R .pro/overlay/tree/. . + rm -rf .pro + echo "T66_PRO=1" >> "$GITHUB_ENV" + echo "GOFLAGS=-buildvcs=false -tags=pro" >> "$GITHUB_ENV" - uses: actions/setup-go@v6 with: go-version: "1.26.9" @@ -96,6 +112,7 @@ jobs: # a tag push and a one-click release (a version entered by hand) # are both releases; only the builds of every push stay community kind=community; [[ "$GITHUB_REF" == refs/tags/* || -n "${{ inputs.version }}" ]] && kind=official + if [[ $kind == official && -z "$T66_PRO" ]]; then echo "::error::a release needs the PRO_SSH_KEY or PRO_TOKEN secret"; exit 1; fi ldflags="-s -w -X main.version=${ver} -X main.buildKind=${kind}" tags="" export CGO_ENABLED=1 @@ -114,6 +131,7 @@ jobs: macOS) export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}" ;; esac + [[ -n "$T66_PRO" ]] && tags="${tags:+$tags,}pro" go build -trimpath ${tags:+-tags "$tags"} -ldflags "$ldflags" -o "$out" ./cmd/traffic66 cp README.md "dist/traffic66-${{ matrix.target }}/" cp -r docs "dist/traffic66-${{ matrix.target }}/" diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d316c..b73c599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ The release notes on GitHub are taken from this file: the section whose heading is the version number. +## 2.5.0 + +- Overview: inbound and outbound follow the interface charted above. +- Rooms and racks: new look; a device and its ports are edited in one form. +- Filter chips only on the traffic pages. +- Black is the default theme. +- Syslog for findings can be set from the findings page. +- Links that show one address's bandwidth on another platform (registered installations). + ## 2.4.1 - Overview: Inbound and Outbound now mean which way the bytes went — what arrived at your hosts, what they sent — instead of who opened the connection. On a network whose hosts only connect outwards, Inbound used to be empty (and said the data was still arriving), although most of its traffic is the replies it downloads. diff --git a/cmd/traffic66/main.go b/cmd/traffic66/main.go index 1f1508e..8ab754a 100644 --- a/cmd/traffic66/main.go +++ b/cmd/traffic66/main.go @@ -40,7 +40,6 @@ import ( "github.com/githubflyideas/traffic66/internal/sim" "github.com/githubflyideas/traffic66/internal/snmp" "github.com/githubflyideas/traffic66/internal/store" - "github.com/githubflyideas/traffic66/internal/syslogout" "github.com/githubflyideas/traffic66/internal/tlscert" "github.com/githubflyideas/traffic66/internal/tui" "github.com/githubflyideas/traffic66/internal/web" @@ -56,7 +55,7 @@ var version = "" // CHANGELOG.md. TestDevVersionMatchesChangelog keeps the two in step: the // default used to be a hardcoded 0.1.0-dev, and because nothing checked it // it stayed that way for two major versions. -const devVersion = "2.4.1" +const devVersion = "2.5.0" func init() { if version == "" { @@ -262,9 +261,19 @@ type serveFlags struct { l2 int hold time.Duration tuiAfter bool - syslog, syslogNet, syslogFmt, syslogCA, syslogBase string - syslogSev int - syslogASCII bool +} + +// Parts compiled in separately add their flags and start with the +// collector, once the licence and the detector exist. +var ( + extFlags []func(fs *flag.FlagSet) + extServe []func(x serveExt) (stop func()) +) + +type serveExt struct { + data string + lic *license.Checker + det *detect.Detector } func serve(args []string, demo bool) { @@ -280,13 +289,9 @@ func serve(args []string, demo bool) { fs.BoolVar(&f.tls, "tls", false, "serve the web UI over HTTPS with a certificate traffic66 keeps in the data directory (registered installations)") fs.StringVar(&f.tlsCert, "tls-cert", "", "serve the web UI over HTTPS with this certificate (registered installations)") fs.StringVar(&f.tlsKey, "tls-key", "", "the private key for -tls-cert") - fs.StringVar(&f.syslog, "syslog", "", "send findings to a syslog collector at host:port (registered installations)") - fs.StringVar(&f.syslogNet, "syslog-transport", "udp", "how to reach the collector: udp, tcp or tls") - fs.StringVar(&f.syslogFmt, "syslog-format", "cef", "message format: cef (ArcSight, QRadar, Splunk, Graylog, Elastic), leef (QRadar), json (Elastic Common Schema), text") - fs.IntVar(&f.syslogSev, "syslog-min-sev", 1, "least severity to send: 1 low, 2 medium, 3 high") - fs.BoolVar(&f.syslogASCII, "syslog-ascii", false, "escape everything outside ASCII, for collectors that mangle UTF-8") - fs.StringVar(&f.syslogCA, "syslog-ca", "", "certificate authority to trust for -syslog-transport tls") - fs.StringVar(&f.syslogBase, "syslog-url", "", "web address of this installation, to build the evidence link with") + for _, x := range extFlags { + x(fs) + } fs.StringVar(&f.listen, "listen", "sflow=:6343,netflow=:2055,ipfix=:4739", "UDP collectors as name=addr, comma separated; every port accepts every protocol; empty disables") fs.StringVar(&f.user, "user", "admin", "login user") fs.StringVar(&f.password, "password", "", "login password for this run instead of the stored one (or set TRAFFIC66_PASSWORD)") @@ -412,11 +417,11 @@ func serve(args []string, demo bool) { } det := detect.New(st, inv, detect.Config{}) - if sender := startSyslog(f, lic); sender != nil { - defer sender.Close() - base := f.syslogBase - det.OnFinding = func(fi store.Finding) { - sender.Send(syslogout.FromFinding(fi, syslogout.PhaseStart, base)) + if offline == nil { + for _, x := range extServe { + if stop := x(serveExt{data: f.data, lic: lic, det: det}); stop != nil { + defer stop() + } } } if demo { @@ -979,28 +984,3 @@ func setGoMemoryLimit(frac float64) { debug.SetMemoryLimit(lim) log.Printf("memory: database %d MB and program %d MB (soft), from -memory %.2f", lim>>20, lim>>20, frac) } - -// startSyslog prepares the syslog feed, or returns nil when there is -// nothing to send to. Unlike HTTPS, a feed that cannot run is not a reason -// to stop: findings keep being made and keep showing in the web UI, and -// the one thing lost is that they also go somewhere else. -func startSyslog(f serveFlags, lic *license.Checker) *syslogout.Sender { - if f.syslog == "" { - return nil - } - if lic == nil || !lic.State().Allowed(license.FeatSyslog) { - log.Printf("syslog: sending findings to a collector is for registered installations; findings are still made and shown in the web UI") - return nil - } - cfg := syslogout.Config{ - Enabled: true, Address: f.syslog, Network: f.syslogNet, - Format: syslogout.Format(f.syslogFmt), MinSev: f.syslogSev, - ASCII: f.syslogASCII, CAFile: f.syslogCA, BaseURL: f.syslogBase, - } - if err := cfg.Check(); err != nil { - fatalf("%v", err) - } - syslogout.Version = version - log.Printf("syslog: sending findings to %s over %s as %s", cfg.Address, cfg.Network, cfg.Format) - return syslogout.New(cfg) -} diff --git a/internal/api/cleanup.go b/internal/api/cleanup.go index 6c76dd0..a222559 100644 --- a/internal/api/cleanup.go +++ b/internal/api/cleanup.go @@ -4,11 +4,8 @@ import ( "encoding/json" "errors" "net/http" - "net/netip" - "strconv" "time" - "github.com/githubflyideas/traffic66/internal/license" "github.com/githubflyideas/traffic66/internal/store" ) @@ -78,70 +75,3 @@ func (s *Server) allowed(w http.ResponseWriter, feature string) bool { writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_registered", "feature": feature}) return false } - -// ifaceTarget reads the interface and age a per-interface cleanup names. -// days 0 means the interface's whole history. -func ifaceTarget(exporter, ifindex, days string) (string, uint32, time.Time, error) { - var before time.Time - if _, err := netip.ParseAddr(exporter); err != nil { - return "", 0, before, errors.New("exporter must be an address") - } - idx, err := strconv.ParseUint(ifindex, 10, 32) - if err != nil || idx == 0 { - return "", 0, before, errors.New("ifindex missing") - } - if days != "" { - d, err := strconv.Atoi(days) - if err != nil || d < 0 { - return "", 0, before, errors.New("days must be 0 or more") - } - if d > 0 { - before = time.Now().AddDate(0, 0, -d) - } - } - return exporter, uint32(idx), before, nil -} - -// getCleanupIface shows what deleting one interface's data would remove. -func (s *Server) getCleanupIface(w http.ResponseWriter, r *http.Request) { - if !s.allowed(w, license.FeatPurgeIface) { - return - } - q := r.URL.Query() - exp, idx, before, err := ifaceTarget(q.Get("exporter"), q.Get("ifindex"), q.Get("days")) - if err != nil { - fail(w, err) - return - } - writeJSON(w, http.StatusOK, s.Store.PurgeSizeIface(exp, idx, before)) -} - -// postCleanupIface deletes one interface's flow records and counters. -func (s *Server) postCleanupIface(w http.ResponseWriter, r *http.Request) { - if !s.allowed(w, license.FeatPurgeIface) { - return - } - var in struct { - Exporter string `json:"exporter"` - IfIndex uint32 `json:"ifindex"` - Days int `json:"days"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil { - fail(w, errors.New("bad request")) - return - } - exp, idx, before, err := ifaceTarget(in.Exporter, strconv.FormatUint(uint64(in.IfIndex), 10), strconv.Itoa(in.Days)) - if err != nil { - fail(w, err) - return - } - disk := s.Store.Usage().DiskBytes - done, err := s.Store.PurgeIface(exp, idx, before) - if err != nil { - fail(w, err) - return - } - // what the files lost, plus what the database file gave back - done.Bytes += max(0, disk-s.Store.Usage().DiskBytes) - writeJSON(w, http.StatusOK, done) -} diff --git a/internal/api/racks.go b/internal/api/racks.go index 8f155b2..d6d274e 100644 --- a/internal/api/racks.go +++ b/internal/api/racks.go @@ -131,8 +131,15 @@ func (s *Server) getRacks(w http.ResponseWriter, r *http.Request) { func (s *Server) putRackDevice(w http.ResponseWriter, r *http.Request) { var in struct { Exporter, Name, Room, Rack string + // the ports whose name, carrier or circuit changed, so a device + // and all its ports are filled in with one form + Ifaces []struct { + IfIndex uint32 + Name string + ISP, Circuit string + } } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 512<<10)).Decode(&in); err != nil { fail(w, errors.New("bad request")) return } @@ -141,10 +148,26 @@ func (s *Server) putRackDevice(w http.ResponseWriter, r *http.Request) { fail(w, errors.New("bad exporter address")) return } + if len(in.Ifaces) > 4096 { + fail(w, errors.New("too many interfaces")) + return + } + for _, f := range in.Ifaces { + if f.IfIndex == 0 { + fail(w, errors.New("bad interface")) + return + } + } if err := s.Inv.SetDevice(a, clip(in.Name, 80), clip(in.Room, 60), clip(in.Rack, 60)); err != nil { fail(w, err) return } + for _, f := range in.Ifaces { + if err := s.Inv.SetCircuit(a, f.IfIndex, clip(f.Name, 80), clip(f.ISP, 60), clip(f.Circuit, 80)); err != nil { + fail(w, err) + return + } + } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } diff --git a/internal/api/racks_test.go b/internal/api/racks_test.go index 6498b7f..1ddb32f 100644 --- a/internal/api/racks_test.go +++ b/internal/api/racks_test.go @@ -40,11 +40,13 @@ func TestRacks(t *testing.T) { } return w.Code } - if c := put("device", `{"exporter":"10.0.0.1","name":"Core router","room":"Room 1","rack":"A03"}`); c != 200 { + // the device and its ports in one go, as the page's one form sends them + if c := put("device", `{"exporter":"10.0.0.1","name":"Core router","room":"Room 1","rack":"A03", + "ifaces":[{"ifindex":3,"name":"Uplink","isp":"China Telecom","circuit":"SH-12345"}]}`); c != 200 { t.Fatal(c) } - if c := put("iface", `{"exporter":"10.0.0.1","ifindex":3,"name":"Uplink","isp":"China Telecom","circuit":"SH-12345"}`); c != 200 { - t.Fatal(c) + if c := put("device", `{"exporter":"10.0.0.1","ifaces":[{"ifindex":0}]}`); c != 400 { + t.Fatalf("an interface without an index was accepted: %d", c) } if c := put("iface", `{"exporter":"not an address","ifindex":3}`); c != 400 { t.Fatalf("a bad exporter was accepted: %d", c) diff --git a/internal/api/server.go b/internal/api/server.go index f61fe57..e284653 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -29,6 +29,9 @@ import ( "github.com/githubflyideas/traffic66/internal/store" ) +// extRoutes add routes from parts that are compiled in separately. +var extRoutes []func(s *Server, mux *http.ServeMux, api, adminAPI func(string, http.HandlerFunc)) + // Server wires the API together. type Server struct { Store *store.Store @@ -171,8 +174,6 @@ func (s *Server) Handler() http.Handler { api("POST /api/findings", s.data((*Server).setFindings)) api("GET /api/cleanup", s.getCleanup) adminAPI("POST /api/cleanup", s.postCleanup) - api("GET /api/cleanup/iface", s.getCleanupIface) - adminAPI("POST /api/cleanup/iface", s.postCleanupIface) api("GET /api/sandbox", s.getSandbox) api("POST /api/sandbox/files", s.putSandboxFile) adminAPI("DELETE /api/sandbox/files", s.deleteSandbox) @@ -184,6 +185,9 @@ func (s *Server) Handler() http.Handler { adminAPI("POST /api/geo", s.putGeo) adminAPI("DELETE /api/geo", s.deleteGeo) adminAPI("POST /api/geo/dbip", s.updateDBIP) + for _, f := range extRoutes { + f(s, mux, api, adminAPI) + } static := http.FileServer(http.FS(s.Static)) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/internal/store/bytesdir_test.go b/internal/store/bytesdir_test.go index b86054a..4fc8a06 100644 --- a/internal/store/bytesdir_test.go +++ b/internal/store/bytesdir_test.go @@ -43,3 +43,41 @@ func TestBytesDirection(t *testing.T) { t.Error("a bad byte direction was accepted") } } + +// A data centre whose service addresses are public and not listed as its +// own networks: every flow is transit by address, so only the interface +// can say which way it went. In through the uplink is inbound, out +// through it is outbound, the same split as the interface's own chart. +func TestInterfaceDirection(t *testing.T) { + s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ts := time.Now().UTC().Add(-10 * time.Minute) + user, vip := "198.51.100.7", "203.0.113.10" + base := Row{TS: ts, Client: user, Server: vip, Cli4: 0xC6336407, Srv4: 0xCB00710A, Dir: DirTransit, Pkts: 1, Flows: 1, Exporter: "10.0.0.1"} + req, resp := base, base + req.Src, req.Dst, req.Wire, req.InIf, req.OutIf = user, vip, 1000, 7, 3 // in through the uplink 7 + resp.Src, resp.Dst, resp.Wire, resp.InIf, resp.OutIf = vip, user, 9000, 3, 7 // back out through it + resp.Dup = true // and seen by a second device too + if err := s.AppendRows([]Row{req, resp}); err != nil { + t.Fatal(err) + } + q := func(f ...Filter) uint64 { + tot, err := s.Totals(Query{From: ts.Add(-time.Minute), To: ts.Add(time.Minute), Filters: f}) + if err != nil { + t.Fatal(err) + } + return tot.Wire + } + if got := q(Filter{Field: "bytes", Value: "in"}); got != 0 { + t.Errorf("bytes in = %d: by address nothing is inside", got) + } + if got := q(Filter{Field: "iface_in", Value: "10.0.0.1/7"}); got != 1000 { + t.Errorf("in through the uplink = %d, want 1000", got) + } + if got := q(Filter{Field: "iface_out", Value: "10.0.0.1/7"}); got != 9000 { + t.Errorf("out through the uplink = %d, want 9000 (its own duplicate counts)", got) + } +} diff --git a/internal/store/purge_iface.go b/internal/store/purge_iface.go deleted file mode 100644 index fcce233..0000000 --- a/internal/store/purge_iface.go +++ /dev/null @@ -1,247 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "log" - "os" - "path/filepath" - "strings" - "time" -) - -// IfacePurge is what deleting one interface's data would remove, or did. -// -// Bytes is what the flow files lose. The database file is not counted: -// most of it is the summaries, which stay, so its share of one interface -// cannot be worked out — PurgeIface reports what was actually freed. -// -// Rows covers the flow records: those are the only thing stored per -// interface. The hourly and daily summaries (and the findings) record -// traffic by application, host and dimension, not by interface, so an -// interface cannot be subtracted from them; Summaries says whether any are -// held for the range, so the page can say so rather than imply the -// interface is gone from everything. -type IfacePurge struct { - Exporter string `json:"exporter"` - IfIndex uint32 `json:"ifindex"` - Files int `json:"files"` // hourly flow files touched - Bytes int64 `json:"bytes"` - Rows int64 `json:"rows"` - Counters int64 `json:"counters"` // interface counter samples - Summaries int64 `json:"summaries"` // summary rows that stay, by hour -} - -// ifaceWhere matches the flow records that went in or out of one interface -// of one exporter, optionally only those older than a time. -// -// A flow record names the interface it arrived on and the one it left by, -// so a record matched here is also part of the other interface's traffic: -// deleting it takes those flows out of that interface's view too. There is -// no way around that at the record level, and blanking the interface -// instead would leave traffic that can no longer be attributed, so the -// record goes and the pages say as much. -func ifaceWhere(exporter string, ifindex uint32, before time.Time) (string, []any) { - w := "exporter = ? AND (in_if = ? OR out_if = ?)" - args := []any{exporter, ifindex, ifindex} - if !before.IsZero() { - w += " AND ts < ?" - args = append(args, before.UTC()) - } - return w, args -} - -// PurgeSizeIface estimates PurgeIface. A zero time means the interface's -// whole history. -func (s *Store) PurgeSizeIface(exporter string, ifindex uint32, before time.Time) IfacePurge { - p := IfacePurge{Exporter: exporter, IfIndex: ifindex} - where, args := ifaceWhere(exporter, ifindex, before) - - var hot int64 - s.DB.QueryRow(`SELECT count(*) FROM hot WHERE `+where, args...).Scan(&hot) - p.Rows = hot - - for _, c := range s.segmentCounts(where, args) { - if c.match == 0 { - continue - } - p.Files++ - p.Rows += c.match - if fi, err := os.Stat(c.path); err == nil && c.total > 0 { - p.Bytes += fi.Size() * c.match / c.total - } - } - cw, cargs := "exporter = ? AND ifindex = ?", []any{exporter, ifindex} - if !before.IsZero() { - cw += " AND ts < ?" - cargs = append(cargs, before.UTC()) - } - s.DB.QueryRow(`SELECT count(*) FROM ifc WHERE `+cw, cargs...).Scan(&p.Counters) - - sw, sargs := "true", []any{} - if !before.IsZero() { - sw, sargs = "ts < ?", []any{before.UTC()} - } - s.DB.QueryRow(`SELECT count(*) FROM r_ts WHERE `+sw, sargs...).Scan(&p.Summaries) - return p -} - -// PurgeIface deletes one interface's flow records and counter samples, -// either all of them or only those older than a time. Flow files that hold -// nothing else are deleted; the rest are rewritten without those records. -// -// The summaries are left alone: see IfacePurge. This is deliberate — an -// interface's share of an hourly total is not recorded, so removing it -// would mean either dropping other interfaces' traffic with it or -// inventing a number. -func (s *Store) PurgeIface(exporter string, ifindex uint32, before time.Time) (IfacePurge, error) { - done := IfacePurge{Exporter: exporter, IfIndex: ifindex} - if exporter == "" || ifindex == 0 { - return done, fmt.Errorf("store: no interface given") - } - where, args := ifaceWhere(exporter, ifindex, before) - - for _, c := range s.segmentCounts(where, args) { - if c.match == 0 { - continue - } - fi, _ := os.Stat(c.path) - size := int64(0) - if fi != nil { - size = fi.Size() - } - if c.match >= c.total { - if err := os.Remove(c.path); err != nil && !os.IsNotExist(err) { - log.Printf("store: purge %s: %v", c.path, err) - continue - } - s.DB.Exec(`DELETE FROM segments WHERE path = ?`, c.path) - os.Remove(filepath.Dir(c.path)) // the day's directory, once empty - done.Files++ - done.Rows += c.total - done.Bytes += size - continue - } - n, err := s.rewriteSegment(c.path, where, args) - if err != nil { - log.Printf("store: purge %s: %v", c.path, err) - continue - } - done.Files++ - done.Rows += n - if fi2, err := os.Stat(c.path); err == nil { - done.Bytes += max(0, size-fi2.Size()) - } - } - if err := s.loadSegments(); err != nil { - return done, err - } - - s.wmu.Lock() - defer s.wmu.Unlock() - if err := s.appHot.Flush(); err != nil { - return done, err - } - if err := s.appIfc.Flush(); err != nil { - return done, err - } - if r, err := s.DB.Exec(`DELETE FROM hot WHERE `+where, args...); err == nil { - n, _ := r.RowsAffected() - done.Rows += n - } else { - return done, err - } - cw, cargs := "exporter = ? AND ifindex = ?", []any{exporter, ifindex} - if !before.IsZero() { - cw += " AND ts < ?" - cargs = append(cargs, before.UTC()) - } - if r, err := s.DB.Exec(`DELETE FROM ifc WHERE `+cw, cargs...); err == nil { - n, _ := r.RowsAffected() - done.Counters += n - } else { - return done, err - } - s.DB.Exec(`CHECKPOINT`) - log.Printf("store: purged %d flow records and %d counter samples of %s/%d", done.Rows, done.Counters, exporter, ifindex) - return done, nil -} - -// segCount is how much of one flow file a condition matches. -type segCount struct { - path string - match, total int64 -} - -// segmentCounts counts the rows matching a condition in every flow file, -// in one pass. A file DuckDB reports under a path the segment table does -// not know is ignored rather than guessed at. -func (s *Store) segmentCounts(where string, args []any) []segCount { - s.segMu.RLock() - files := make([]string, 0, len(s.segCache)) - known := make(map[string]string, len(s.segCache)) - for _, g := range s.segCache { - files = append(files, "'"+sqlPath(g.path)+"'") - known[sqlPath(g.path)] = g.path - } - s.segMu.RUnlock() - if len(files) == 0 { - return nil - } - q := fmt.Sprintf(`SELECT filename, count(*) FILTER (WHERE %s), count(*) - FROM read_parquet([%s], filename = true) GROUP BY 1`, where, strings.Join(files, ",")) - rows, err := s.DB.Query(q, args...) - if err != nil { - log.Printf("store: counting flow files: %v", err) - return nil - } - defer rows.Close() - var out []segCount - for rows.Next() { - var name string - var c segCount - if err := rows.Scan(&name, &c.match, &c.total); err != nil { - log.Printf("store: counting flow files: %v", err) - return out - } - p, ok := known[sqlPath(name)] - if !ok { - continue - } - c.path = p - out = append(out, c) - } - return out -} - -// rewriteSegment rewrites a flow file without the rows the drop condition -// matches and returns how many it dropped. The file's recorded range and -// row count are updated, because dropping rows from the middle of a file -// can move either end. -func (s *Store) rewriteSegment(path, drop string, args []any) (int64, error) { - var total, kept int64 - var mn, mx sql.NullTime - src := "'" + sqlPath(path) + "'" - // the condition is evaluated once, in the subquery, so its parameters - // are bound once however many aggregates read the result - q := fmt.Sprintf(`SELECT count(*), count(*) FILTER (WHERE keep), min(ts) FILTER (WHERE keep), max(ts) FILTER (WHERE keep) - FROM (SELECT ts, NOT (%s) AS keep FROM read_parquet(%s))`, drop, src) - if err := s.DB.QueryRow(q, args...).Scan(&total, &kept, &mn, &mx); err != nil { - return 0, err - } - tmp := path + ".trim" - cp := fmt.Sprintf(`COPY (SELECT * FROM read_parquet(%s) WHERE NOT (%s) ORDER BY ts, client) TO '%s' (FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 122880)`, - src, drop, sqlPath(tmp)) - if _, err := s.DB.Exec(cp, args...); err != nil { - os.Remove(tmp) - return 0, err - } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return 0, err - } - if _, err := s.DB.Exec(`UPDATE segments SET min_ts = ?, max_ts = ?, rows = ? WHERE path = ?`, mn.Time, mx.Time, kept, path); err != nil { - return 0, err - } - return total - kept, nil -} diff --git a/internal/store/purge_iface_test.go b/internal/store/purge_iface_test.go deleted file mode 100644 index fc6b7ef..0000000 --- a/internal/store/purge_iface_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package store - -import ( - "testing" - "time" -) - -// Deleting one interface's data takes its flow records out of the hot -// table and out of the flow files — rewriting a file it shares with -// another interface, deleting one it does not — and takes its counter -// samples with it. Everything else stays. -func TestPurgeIface(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - // Half past the hour, not the minute the test happens to run in: Seal - // rounds down to the hour after subtracting five minutes, so a base time - // in the first five minutes of an hour put the first Seal's boundary an - // hour earlier than this test needs and sealed nothing. That made the - // test fail for five minutes in every sixty with nothing wrong. - now := time.Now().UTC().Truncate(time.Hour).Add(-30 * time.Minute) - old := now.Add(-3 * time.Hour) - row := func(ts time.Time, in, out uint32, exp string) Row { - return Row{TS: ts, Src: "10.0.0.1", Dst: "10.0.0.2", Client: "10.0.0.1", Server: "10.0.0.2", - Exporter: exp, InIf: in, OutIf: out, Bytes: 100, Wire: 100, Pkts: 1, Flows: 1} - } - // a file of its own, then a file shared with interface 7 - if err := s.AppendRows([]Row{row(old, 3, 0, "127.0.0.1"), row(old, 0, 3, "127.0.0.1")}); err != nil { - t.Fatal(err) - } - if err := s.Seal(old.Add(time.Hour)); err != nil { - t.Fatal(err) - } - mid := now.Add(-2 * time.Hour) - if err := s.AppendRows([]Row{row(mid, 3, 0, "127.0.0.1"), row(mid, 7, 0, "127.0.0.1"), row(mid, 3, 0, "127.0.0.2")}); err != nil { - t.Fatal(err) - } - if err := s.Seal(mid.Add(time.Hour)); err != nil { - t.Fatal(err) - } - // and rows still in the hot table - if err := s.AppendRows([]Row{row(now, 3, 0, "127.0.0.1"), row(now, 7, 0, "127.0.0.1")}); err != nil { - t.Fatal(err) - } - for _, ts := range []time.Time{old, mid, now} { - if err := s.AppendCounter(ts, "127.0.0.1", 3, 1e9, 10, 10, 1, 1); err != nil { - t.Fatal(err) - } - if err := s.AppendCounter(ts, "127.0.0.1", 7, 1e9, 10, 10, 1, 1); err != nil { - t.Fatal(err) - } - } - - est := s.PurgeSizeIface("127.0.0.1", 3, time.Time{}) - if est.Rows != 4 { // two in the first file, one in the second, one hot - t.Fatalf("estimated %d rows, want 4", est.Rows) - } - if est.Counters != 3 { - t.Fatalf("estimated %d counter samples, want 3", est.Counters) - } - if est.Files != 2 { - t.Fatalf("estimated %d files, want 2", est.Files) - } - - done, err := s.PurgeIface("127.0.0.1", 3, time.Time{}) - if err != nil { - t.Fatal(err) - } - if done.Rows != 4 { - t.Fatalf("deleted %d rows, want 4", done.Rows) - } - if done.Counters != 3 { - t.Fatalf("deleted %d counter samples, want 3", done.Counters) - } - - from, to := old.Add(-time.Hour), now.Add(time.Hour) - var left int - if err := s.DB.QueryRow(`SELECT count(*) FROM ` + s.Source(from, to) + - ` WHERE exporter = '127.0.0.1' AND (in_if = 3 OR out_if = 3)`).Scan(&left); err != nil { - t.Fatal(err) - } - if left != 0 { - t.Fatalf("%d records of the interface are left", left) - } - // interface 7 of the same exporter, and interface 3 of another, stay - for _, c := range []struct { - where string - want int - }{ - {"exporter = '127.0.0.1' AND in_if = 7", 2}, - {"exporter = '127.0.0.2' AND in_if = 3", 1}, - } { - var n int - if err := s.DB.QueryRow(`SELECT count(*) FROM ` + s.Source(from, to) + ` WHERE ` + c.where).Scan(&n); err != nil { - t.Fatal(err) - } - if n != c.want { - t.Fatalf("%s: %d records, want %d", c.where, n, c.want) - } - } - var ctr int - s.DB.QueryRow(`SELECT count(*) FROM ifc`).Scan(&ctr) - if ctr != 3 { - t.Fatalf("%d counter samples left, want 3 (interface 7's)", ctr) - } - // the rewritten file's recorded range and row count must match it - rows, err := s.DB.Query(`SELECT path, min_ts, max_ts, rows FROM segments`) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - files := 0 - for rows.Next() { - var path string - var mn, mx time.Time - var n int64 - if err := rows.Scan(&path, &mn, &mx, &n); err != nil { - t.Fatal(err) - } - files++ - var real int64 - var rmn, rmx time.Time - if err := s.DB.QueryRow(`SELECT count(*), min(ts), max(ts) FROM read_parquet('`+sqlPath(path)+`')`).Scan(&real, &rmn, &rmx); err != nil { - t.Fatal(err) - } - if n != real || !mn.Equal(rmn) || !mx.Equal(rmx) { - t.Fatalf("%s: recorded %d rows %v..%v, file has %d rows %v..%v", path, n, mn, mx, real, rmn, rmx) - } - } - if files != 1 { // the interface's own file is gone, the shared one is not - t.Fatalf("%d flow files left, want 1", files) - } -} - -// Deleting by age as well as by interface leaves the interface's newer -// records alone. -func TestPurgeIfaceBefore(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - now := time.Now().UTC().Truncate(time.Minute) - row := func(ts time.Time) Row { - return Row{TS: ts, Src: "10.0.0.1", Dst: "10.0.0.2", Client: "10.0.0.1", Server: "10.0.0.2", - Exporter: "127.0.0.1", InIf: 3, Bytes: 100, Wire: 100, Pkts: 1, Flows: 1} - } - if err := s.AppendRows([]Row{row(now.AddDate(0, 0, -10)), row(now)}); err != nil { - t.Fatal(err) - } - done, err := s.PurgeIface("127.0.0.1", 3, now.AddDate(0, 0, -5)) - if err != nil { - t.Fatal(err) - } - if done.Rows != 1 { - t.Fatalf("deleted %d rows, want 1", done.Rows) - } - var n int - s.DB.QueryRow(`SELECT count(*) FROM hot`).Scan(&n) - if n != 1 { - t.Fatalf("%d rows left, want 1", n) - } -} - -// An interface that was never seen is not an error and deletes nothing. -func TestPurgeIfaceUnknown(t *testing.T) { - s, err := Open(Options{Dir: t.TempDir(), Threads: 1}) - if err != nil { - t.Fatal(err) - } - defer s.Close() - done, err := s.PurgeIface("127.0.0.1", 99, time.Time{}) - if err != nil { - t.Fatal(err) - } - if done.Rows != 0 || done.Files != 0 { - t.Fatalf("deleted %+v", done) - } - if _, err := s.PurgeIface("", 1, time.Time{}); err == nil { - t.Fatal("an empty exporter was accepted") - } -} diff --git a/internal/store/query.go b/internal/store/query.go index 50466f9..fc3e0f4 100644 --- a/internal/store/query.go +++ b/internal/store/query.go @@ -92,7 +92,7 @@ func v4range(p netip.Prefix) (uint32, uint32, bool) { // device they are its own traffic and count. func (q Query) oneDevice() bool { for _, f := range q.Filters { - if !f.Neg && (f.Field == "exporter" || f.Field == "iface") { + if !f.Neg && (f.Field == "exporter" || strings.HasPrefix(f.Field, "iface")) { return true } } @@ -133,6 +133,15 @@ func (q Query) where() (string, []any, error) { } } c = "(" + strings.Join(parts, " OR ") + ")" + case "src", "dst": + // one address as the sender, or as the receiver, of the bytes: + // what went to an address and what came from it + a, err := netip.ParseAddr(v) + if err != nil { + return "", nil, fmt.Errorf("bad address %q", v) + } + c = f.Field + " = ?" + args = append(args, a.Unmap().String()) case "port": port := v if i := strings.IndexByte(v, '/'); i > 0 { @@ -226,8 +235,9 @@ func (q Query) where() (string, []any, error) { } c = "vlan = ?" args = append(args, n) - case "iface": - // exporter/ifindex, either direction + case "iface", "iface_in", "iface_out": + // exporter/ifindex: either direction, or only what came in + // through it, or only what went out through it i := strings.LastIndexByte(v, '/') if i <= 0 { return "", nil, fmt.Errorf("bad interface %q", v) @@ -236,8 +246,17 @@ func (q Query) where() (string, []any, error) { if err != nil { return "", nil, fmt.Errorf("bad interface %q", v) } - c = "(exporter = ? AND (in_if = ? OR out_if = ?))" - args = append(args, v[:i], n, n) + switch f.Field { + case "iface_in": + c = "(exporter = ? AND in_if = ?)" + args = append(args, v[:i], n) + case "iface_out": + c = "(exporter = ? AND out_if = ?)" + args = append(args, v[:i], n) + default: + c = "(exporter = ? AND (in_if = ? OR out_if = ?))" + args = append(args, v[:i], n, n) + } default: return "", nil, fmt.Errorf("unknown filter field %q", f.Field) } diff --git a/internal/syslogout/event.go b/internal/syslogout/event.go deleted file mode 100644 index 256d42a..0000000 --- a/internal/syslogout/event.go +++ /dev/null @@ -1,216 +0,0 @@ -// Package syslogout sends findings to a syslog collector. -// -// What makes this worth having is not that it can send a line of text: it -// is that the line can be parsed, counted and summed by whatever the -// customer already runs. Three things decide that, and they are the -// reason this package exists rather than a fmt.Fprintf somewhere: -// -// - The event type is a stable word (scan, brute, exfil …) that never -// changes with the wording, the translation or the version, so a -// dashboard grouped by it does not break when a sentence is improved. -// - A finding is one event with one id, sent when it starts and when it -// ends, not once per detection run. Otherwise one night of password -// guessing is counted as forty incidents and somebody turns the feed -// off. -// - The sampling rate travels with every event. traffic66 detects on -// sampled data, so the byte and packet counts are estimates; a -// collector that sums them without knowing that produces a number -// nobody can defend. -package syslogout - -import ( - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - "time" - - "github.com/githubflyideas/traffic66/internal/store" -) - -// Phase says what happened to a finding, which is what decides whether -// anything is sent at all. -type Phase string - -const ( - PhaseStart Phase = "start" // first seen - PhaseEnd Phase = "end" // closed, by a person or by time - PhaseTest Phase = "test" // the "send test" button -) - -// Event is one thing to report, already flattened out of a finding so the -// formats do not each have to understand the store. -type Event struct { - Phase Phase - ID string // stable for the life of one finding - Kind string // scan, portscan, brute, lateral, exfil, flood, threat - Severity int // 1 low, 2 medium, 3 high - Text string // one line a person can read - - Src, Dst string - Port string - Proto string - - First, Last time.Time - Hits int // detection runs that saw it - - Bytes, Packets uint64 // what the source sent; estimates when Sampling is set - Down uint64 // what came back, where the rule counted it - Sampling string // "1:4096", empty when the data was not sampled - Exporter string - IfIndex uint32 - Country string - ASN string - Org string - Evidence string // link to the finding, empty when there is none - - Extra map[string]string // whatever else the rule recorded -} - -// FromFinding flattens a finding. base is the address the web UI is -// reachable at, used to build the evidence link; empty leaves it out. -func FromFinding(f store.Finding, phase Phase, base string) Event { - e := Event{ - Phase: phase, ID: strconv.FormatInt(f.ID, 10), Kind: f.Kind, - Severity: f.Sev, Src: f.Src, Dst: f.Dst, Port: f.Port, - First: f.First, Last: f.Last, Hits: f.Hits, - Extra: map[string]string{}, - } - if host, proto, ok := strings.Cut(f.Port, "/"); ok { - e.Port, e.Proto = host, strings.ToUpper(proto) - } - if base != "" && f.ID != 0 { - e.Evidence = strings.TrimRight(base, "/") + "/#v=findings&finding=" + e.ID - } - for k, v := range f.Ev { - switch k { - // The rules record what they actually counted: "up" is what the - // source sent, which is the number that matters for an upload to - // somewhere new, and "down" is what came back. - case "up": - e.Bytes = asUint(v) - case "down": - e.Down = asUint(v) - case "pkts": - e.Packets = asUint(v) - case "sampling": - if n := asUint(v); n > 1 { - e.Sampling = "1:" + strconv.FormatUint(n, 10) - } - case "cc": - e.Country = asString(v) - case "org": - e.Org = asString(v) - case "asn": - e.ASN = asString(v) - case "examples": - // a list of sample addresses; useful to a person, noise in a - // field a collector has to index - default: - if s := asString(v); s != "" { - e.Extra[k] = s - } - } - } - e.Text = Describe(e) - return e -} - -// Describe writes the one line a person reads, in English: a collector is -// indexed and searched in one language, and the translated wording lives -// in the web UI where a person is actually reading it. -func Describe(e Event) string { - who := e.Src - if who == "" { - who = "an unknown source" - } - target := e.Dst - if target != "" && e.Port != "" { - target += ":" + e.Port - } - switch e.Kind { - case "scan": - return fmt.Sprintf("%s scanned the network on port %s", who, e.Port) - case "portscan": - return fmt.Sprintf("%s probed many ports of %s", who, e.Dst) - case "brute": - return fmt.Sprintf("%s tried to log in to %s again and again", who, target) - case "lateral": - return fmt.Sprintf("%s reached %s, a service never offered there before", who, target) - case "exfil": - return fmt.Sprintf("%s uploaded %s to %s, a destination it had not used before", who, bytes(e.Bytes), e.Dst) - case "flood": - return fmt.Sprintf("%s was flooded with packets", e.Dst) - case "threat": - return fmt.Sprintf("%s communicated with %s, which is on a threat list", who, e.Dst) - case "test": - return "test message from traffic66" - } - return fmt.Sprintf("%s: %s to %s", e.Kind, who, target) -} - -func bytes(n uint64) string { - switch { - case n >= 1<<30: - return fmt.Sprintf("%.1f GB", float64(n)/(1<<30)) - case n >= 1<<20: - return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) - case n >= 1<<10: - return fmt.Sprintf("%.1f kB", float64(n)/(1<<10)) - } - return fmt.Sprintf("%d B", n) -} - -func asUint(v any) uint64 { - switch n := v.(type) { - case float64: - if n > 0 { - return uint64(n) - } - case int: - if n > 0 { - return uint64(n) - } - case int64: - if n > 0 { - return uint64(n) - } - case uint64: - return n - case json.Number: - if f, err := n.Float64(); err == nil && f > 0 { - return uint64(f) - } - } - return 0 -} - -func asString(v any) string { - switch s := v.(type) { - case string: - return s - case float64: - return strconv.FormatFloat(s, 'f', -1, 64) - case int: - return strconv.Itoa(s) - case int64: - return strconv.FormatInt(s, 10) - case bool: - return strconv.FormatBool(s) - case nil: - return "" - } - return fmt.Sprint(v) -} - -// extraKeys lists the extra fields in a fixed order, so two messages about -// the same thing look the same and a collector's field list stays stable. -func (e Event) extraKeys() []string { - out := make([]string, 0, len(e.Extra)) - for k := range e.Extra { - out = append(out, k) - } - sort.Strings(out) - return out -} diff --git a/internal/syslogout/format.go b/internal/syslogout/format.go deleted file mode 100644 index dd72e88..0000000 --- a/internal/syslogout/format.go +++ /dev/null @@ -1,441 +0,0 @@ -package syslogout - -import ( - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - "time" -) - -// Formats of the message body. The envelope is RFC 5424 either way; this -// is what goes in it, and it is a choice because which one parses by -// itself depends entirely on what the customer already runs. -type Format string - -const ( - // FormatCEF is ArcSight's Common Event Format, and the one with the - // widest out-of-the-box support: ArcSight, QRadar, Splunk, Graylog, - // Elastic and Wazuh all read it without anyone writing a parser. - FormatCEF Format = "cef" - // FormatLEEF is IBM's, read natively by QRadar. - FormatLEEF Format = "leef" - // FormatJSON uses Elastic Common Schema field names, so an Elastic or - // Splunk pipeline needs no mapping written for it. - FormatJSON Format = "json" - // FormatText is for a person reading the file. - FormatText Format = "text" -) - -// Formats lists what can be chosen, for the settings page and the flag. -var Formats = []Format{FormatCEF, FormatLEEF, FormatJSON, FormatText} - -// Valid reports whether f is a format this knows. -func (f Format) Valid() bool { - for _, k := range Formats { - if f == k { - return true - } - } - return false -} - -// Vendor, Product and the enterprise number identify traffic66 in the -// formats that ask. 32473 is the number IANA reserves for examples and -// private use, which is honest: we have not registered one. -const ( - Vendor = "traffic66" - Product = "traffic66" - Enterprise = "32473" -) - -// severity turns our three levels into the 0–10 the formats expect. -func severity(sev int) int { - switch sev { - case 3: - return 8 - case 2: - return 5 - case 1: - return 3 - } - return 1 -} - -// priority is the syslog PRI: facility times eight plus the level. High -// findings go out as warnings, the rest as notices, because a finding is -// something to look at, not something that is on fire. -func priority(facility, sev int) int { - level := 5 // notice - if sev >= 3 { - level = 4 // warning - } - return facility*8 + level -} - -// Message builds the whole line, envelope and body. -func Message(cfg Config, e Event, now time.Time) string { - host := cfg.Hostname - if host == "" { - host, _ = os.Hostname() - } - if host == "" { - host = "-" - } - body := body(cfg.Format, e) - if cfg.ASCII { - body, host = ascii(body), ascii(host) - } - // RFC 5424: 1 TIMESTAMP HOSTNAME APP PROCID MSGID [SD] MSG - sd := structured(e) - return fmt.Sprintf("<%d>1 %s %s %s %d %s %s %s", - priority(cfg.Facility, e.Severity), - now.UTC().Format("2006-01-02T15:04:05.000Z"), - nilable(host), Product, os.Getpid(), nilable(e.Kind), sd, body) -} - -// structured is the RFC 5424 structured data: the few fields worth having -// even when the body is plain text, so a collector that understands only -// the envelope still gets the identity of the event and the sampling rate. -func structured(e Event) string { - var b strings.Builder - fmt.Fprintf(&b, `[traffic66@%s id="%s" kind="%s" phase="%s"`, Enterprise, sdEsc(e.ID), sdEsc(e.Kind), sdEsc(string(e.Phase))) - if e.Sampling != "" { - fmt.Fprintf(&b, ` sampling="%s"`, sdEsc(e.Sampling)) - } - b.WriteString("]") - return b.String() -} - -func body(f Format, e Event) string { - switch f { - case FormatLEEF: - return leef(e) - case FormatJSON: - return asJSON(e) - case FormatText: - return text(e) - } - return cef(e) -} - -// cef writes ArcSight's Common Event Format. The header's seven fields are -// pipe separated; everything else is key=value. -func cef(e Event) string { - var b strings.Builder - fmt.Fprintf(&b, "CEF:0|%s|%s|%s|%s|%s|%d|", Vendor, Product, version(), cefHdr(e.Kind), cefHdr(e.Text), severity(e.Severity)) - kv := func(k, v string) { - if v != "" { - fmt.Fprintf(&b, "%s=%s ", k, cefVal(v)) - } - } - kv("src", e.Src) - kv("dst", e.Dst) - kv("dpt", e.Port) - kv("proto", e.Proto) - if e.Bytes > 0 { - kv("out", strconv.FormatUint(e.Bytes, 10)) - } - if e.Down > 0 { - kv("in", strconv.FormatUint(e.Down, 10)) - } - if e.Packets > 0 { - kv("cn1Label", "packets") - kv("cn1", strconv.FormatUint(e.Packets, 10)) - } - if e.Hits > 0 { - kv("cnt", strconv.Itoa(e.Hits)) - } - if !e.First.IsZero() { - kv("start", strconv.FormatInt(e.First.UnixMilli(), 10)) - } - if !e.Last.IsZero() { - kv("end", strconv.FormatInt(e.Last.UnixMilli(), 10)) - } - kv("externalId", e.ID) - kv("act", string(e.Phase)) - label(&b, "cs1", "sampling", e.Sampling) - label(&b, "cs2", "findingUrl", e.Evidence) - label(&b, "cs3", "exporter", exporter(e)) - label(&b, "cs4", "peerOrg", strings.TrimSpace(e.Org+" "+e.ASN)) - if e.Country != "" { - kv("cs5Label", "peerCountry") - kv("cs5", e.Country) - } - for _, k := range e.extraKeys() { - kv("traffic66"+title(k), e.Extra[k]) - } - return strings.TrimRight(b.String(), " ") -} - -func label(b *strings.Builder, key, name, v string) { - if v == "" { - return - } - fmt.Fprintf(b, "%sLabel=%s %s=%s ", key, name, key, cefVal(v)) -} - -// leef writes IBM's format, which QRadar reads natively. Version 2.0 names -// its own delimiter; a tab keeps values readable and never appears in one. -func leef(e Event) string { - var b strings.Builder - fmt.Fprintf(&b, "LEEF:2.0|%s|%s|%s|%s|\t|", Vendor, Product, version(), leefEsc(e.Kind)) - kv := func(k, v string) { - if v != "" { - fmt.Fprintf(&b, "%s=%s\t", k, leefEsc(v)) - } - } - kv("cat", e.Kind) - kv("sev", strconv.Itoa(severity(e.Severity))) - kv("src", e.Src) - kv("dst", e.Dst) - kv("dstPort", e.Port) - kv("proto", e.Proto) - if e.Bytes > 0 { - kv("srcBytes", strconv.FormatUint(e.Bytes, 10)) - } - if e.Down > 0 { - kv("dstBytes", strconv.FormatUint(e.Down, 10)) - } - if e.Packets > 0 { - kv("dstPackets", strconv.FormatUint(e.Packets, 10)) - } - if !e.First.IsZero() { - kv("devTimeFormat", "MMM dd yyyy HH:mm:ss") - kv("devTime", e.First.UTC().Format("Jan 02 2006 15:04:05")) - } - kv("identSrc", e.Src) - kv("findingId", e.ID) - kv("phase", string(e.Phase)) - kv("hits", nonZero(e.Hits)) - kv("sampling", e.Sampling) - kv("exporter", exporter(e)) - kv("peerCountry", e.Country) - kv("peerOrg", strings.TrimSpace(e.Org+" "+e.ASN)) - kv("findingUrl", e.Evidence) - kv("msg", e.Text) - for _, k := range e.extraKeys() { - kv(k, e.Extra[k]) - } - return strings.TrimRight(b.String(), "\t") -} - -// asJSON writes Elastic Common Schema names, so an Elastic or Splunk -// pipeline needs no mapping written for it and the SIEM views recognise -// the event on their own. -func asJSON(e Event) string { - m := map[string]any{ - "@timestamp": e.Last.UTC().Format(time.RFC3339Nano), - "message": e.Text, - "event": map[string]any{ - "kind": "alert", - "category": []string{"network", "intrusion_detection"}, - "action": e.Kind, - "id": e.ID, - "severity": severity(e.Severity), - "count": e.Hits, - "start": rfc(e.First), - "end": rfc(e.Last), - "module": Product, - "provider": Vendor, - }, - "rule": map[string]any{"name": e.Kind, "id": e.Kind}, - "observer": observer(e), - } - if e.Evidence != "" { - m["event"].(map[string]any)["reference"] = e.Evidence - } - if e.Src != "" { - src := map[string]any{"ip": e.Src} - if e.Bytes > 0 { - src["bytes"] = e.Bytes - } - if e.Packets > 0 { - src["packets"] = e.Packets - } - m["source"] = src - } - dst := map[string]any{} - if e.Dst != "" { - dst["ip"] = e.Dst - } - if p, err := strconv.Atoi(e.Port); err == nil { - dst["port"] = p - } - if e.Down > 0 { - dst["bytes"] = e.Down - } - if e.Country != "" { - dst["geo"] = map[string]any{"country_iso_code": e.Country} - } - if e.ASN != "" || e.Org != "" { - as := map[string]any{} - if n, err := strconv.Atoi(strings.TrimPrefix(e.ASN, "AS")); err == nil { - as["number"] = n - } - if e.Org != "" { - as["organization"] = map[string]any{"name": e.Org} - } - dst["as"] = as - } - if len(dst) > 0 { - m["destination"] = dst - } - if e.Proto != "" { - m["network"] = map[string]any{"transport": strings.ToLower(e.Proto), "bytes": e.Bytes + e.Down} - } - var related []string - for _, ip := range []string{e.Src, e.Dst} { - if ip != "" { - related = append(related, ip) - } - } - if len(related) > 0 { - m["related"] = map[string]any{"ip": related} - } - // What a collector cannot work out for itself, under our own name so - // it never collides with a schema field. - own := map[string]any{"phase": string(e.Phase)} - if e.Sampling != "" { - own["sampling"] = e.Sampling - own["estimated"] = true // the byte and packet counts are weighted - } - for k, v := range e.Extra { - own[k] = v - } - m["traffic66"] = own - b, err := json.Marshal(m) - if err != nil { - return `{"message":"traffic66: could not encode this finding"}` - } - return string(b) -} - -func observer(e Event) map[string]any { - o := map[string]any{"vendor": Vendor, "product": Product, "type": "flow"} - if e.Exporter != "" { - o["ip"] = e.Exporter - } - if e.IfIndex != 0 { - o["ingress"] = map[string]any{"interface": map[string]any{"id": strconv.FormatUint(uint64(e.IfIndex), 10)}} - } - return o -} - -// text is for a person: everything that matters on one line, no escaping -// games, no schema. -func text(e Event) string { - var parts []string - add := func(f string, a ...any) { parts = append(parts, fmt.Sprintf(f, a...)) } - add("%s", e.Text) - add("kind=%s", e.Kind) - add("severity=%d", e.Severity) - add("id=%s", e.ID) - add("phase=%s", e.Phase) - if e.Sampling != "" { - add("sampling=%s (counts are estimates)", e.Sampling) - } - if ex := exporter(e); ex != "" { - add("exporter=%s", ex) - } - if e.Evidence != "" { - add("%s", e.Evidence) - } - return strings.Join(parts, " · ") -} - -func exporter(e Event) string { - switch { - case e.Exporter != "" && e.IfIndex != 0: - return fmt.Sprintf("%s/%d", e.Exporter, e.IfIndex) - case e.Exporter != "": - return e.Exporter - } - return "" -} - -func rfc(t time.Time) any { - if t.IsZero() { - return nil - } - return t.UTC().Format(time.RFC3339Nano) -} - -func nonZero(n int) string { - if n == 0 { - return "" - } - return strconv.Itoa(n) -} - -func nilable(s string) string { - if s == "" { - return "-" - } - return s -} - -// Version is the traffic66 version put in the message headers; main sets -// it at startup. -var Version = "0.0.0" - -func version() string { return cefHdr(Version) } - -// cefHdr escapes a CEF header field, where a pipe would end it. -func cefHdr(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, "|", `\|`) - return oneLine(s) -} - -// cefVal escapes a CEF extension value. The specification asks only for -// the backslash and the equals sign, but parsers differ on a pipe or a tab -// inside a value — some split the header on every pipe they see — so both -// are escaped too. Nothing is lost by being stricter than the format -// requires, and a field that swallows the rest of an event is the kind of -// bug that is only ever found in production. -func cefVal(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, "=", `\=`) - s = strings.ReplaceAll(s, "|", `\|`) - s = strings.ReplaceAll(s, "\t", `\t`) - return oneLine(s) -} - -// leefEsc escapes the tab that separates LEEF fields. -func leefEsc(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, "\t", `\t`) - return oneLine(s) -} - -// sdEsc escapes an RFC 5424 structured data value. -func sdEsc(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - s = strings.ReplaceAll(s, "]", `\]`) - return oneLine(s) -} - -// oneLine keeps a message to one line: a newline in the middle would look -// like the start of another event to every collector there is. -func oneLine(s string) string { - s = strings.ReplaceAll(s, "\r", " ") - return strings.ReplaceAll(s, "\n", " ") -} - -// ascii replaces anything outside ASCII. Plenty of older collectors -// mangle UTF-8, and a Japanese host name should not be the reason a feed -// stops being readable. -func ascii(s string) string { - var b strings.Builder - for _, r := range s { - if r < 0x20 || r > 0x7e { - fmt.Fprintf(&b, "\\u%04x", r) - continue - } - b.WriteRune(r) - } - return b.String() -} diff --git a/internal/syslogout/format_test.go b/internal/syslogout/format_test.go deleted file mode 100644 index 92e3ccb..0000000 --- a/internal/syslogout/format_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package syslogout - -import ( - "encoding/json" - "net" - "strconv" - "strings" - "testing" - "time" - - "github.com/githubflyideas/traffic66/internal/store" -) - -var when = time.Date(2026, 10, 14, 2, 59, 0, 0, time.UTC) - -func sample() Event { - e := Event{ - Phase: PhaseStart, ID: "41", Kind: "brute", Severity: 3, - Src: "10.10.3.27", Dst: "10.20.0.15", Port: "3389", Proto: "TCP", - First: when, Last: when.Add(15 * time.Minute), Hits: 22, - Bytes: 3740, Packets: 90112, Sampling: "1:4096", - Exporter: "192.0.2.1", IfIndex: 3, Country: "JP", ASN: "AS64500", Org: "Example Telecom", - Evidence: "https://traffic66.example.com/#v=findings&finding=41", - Extra: map[string]string{"conns": "22"}, - } - e.Text = Describe(e) - return e -} - -func lineFor(t *testing.T, f Format, e Event) string { - t.Helper() - cfg := Config{Enabled: true, Address: "collector:514", Format: f, Hostname: "sensor1"} - cfg.defaults() - return Message(cfg, e, when) -} - -// Whatever the body, the envelope carries the identity of the event and -// the sampling rate, so a collector that understands only RFC 5424 still -// gets the two things it cannot work out for itself. -func TestEnvelope(t *testing.T) { - for _, f := range Formats { - line := lineFor(t, f, sample()) - if !strings.HasPrefix(line, "<108>1 2026-10-14T02:59:00.000Z sensor1 traffic66 ") { - t.Fatalf("%s: envelope is %.60q", f, line) - } - for _, want := range []string{`traffic66@32473`, `id="41"`, `kind="brute"`, `phase="start"`, `sampling="1:4096"`} { - if !strings.Contains(line, want) { - t.Errorf("%s: missing %s", f, want) - } - } - if strings.Count(line, "\n") != 0 { - t.Errorf("%s: a message must be one line", f) - } - } -} - -func TestCEF(t *testing.T) { - line := lineFor(t, FormatCEF, sample()) - body := line[strings.Index(line, "CEF:"):] - head := strings.SplitN(body, "|", 8) - if len(head) != 8 { - t.Fatalf("CEF header has %d fields: %q", len(head), body) - } - if head[0] != "CEF:0" || head[1] != "traffic66" || head[4] != "brute" { - t.Fatalf("header %v", head[:5]) - } - if head[6] != "8" { - t.Fatalf("a high finding should be severity 8, got %s", head[6]) - } - for _, want := range []string{ - "src=10.10.3.27", "dst=10.20.0.15", "dpt=3389", "proto=TCP", - "out=3740", "cnt=22", "externalId=41", "act=start", - "start=" + strconv.FormatInt(when.UnixMilli(), 10), - "end=" + strconv.FormatInt(when.Add(15*time.Minute).UnixMilli(), 10), - "cs1Label=sampling cs1=1:4096", - "cs3Label=exporter cs3=192.0.2.1/3", - "cs5Label=peerCountry cs5=JP", - } { - if !strings.Contains(body, want) { - t.Errorf("missing %q in %s", want, body) - } - } -} - -func TestLEEF(t *testing.T) { - line := lineFor(t, FormatLEEF, sample()) - body := line[strings.Index(line, "LEEF:"):] - if !strings.HasPrefix(body, "LEEF:2.0|traffic66|traffic66|") { - t.Fatalf("header %.40q", body) - } - head := strings.SplitN(body, "|", 7) - if head[4] != "brute" { - t.Fatalf("event id %q", head[4]) - } - if head[5] != "\t" { - t.Fatalf("version 2.0 names its delimiter, got %q", head[5]) - } - for _, want := range []string{"src=10.10.3.27", "dstPort=3389", "sev=8", "findingId=41", "sampling=1:4096"} { - if !strings.Contains(body, want) { - t.Errorf("missing %q", want) - } - } -} - -func TestJSONIsECS(t *testing.T) { - line := lineFor(t, FormatJSON, sample()) - body := line[strings.Index(line, "{"):] - var m map[string]any - if err := json.Unmarshal([]byte(body), &m); err != nil { - t.Fatalf("%v in %s", err, body) - } - ev := m["event"].(map[string]any) - if ev["kind"] != "alert" || ev["action"] != "brute" { - t.Fatalf("event %v", ev) - } - if m["source"].(map[string]any)["ip"] != "10.10.3.27" { - t.Fatal("source.ip") - } - dst := m["destination"].(map[string]any) - if dst["port"].(float64) != 3389 { - t.Fatalf("destination %v", dst) - } - if m["source"].(map[string]any)["bytes"].(float64) != 3740 { - t.Fatalf("what the source sent belongs on the source: %v", m["source"]) - } - if dst["geo"].(map[string]any)["country_iso_code"] != "JP" { - t.Fatal("destination.geo") - } - if dst["as"].(map[string]any)["number"].(float64) != 64500 { - t.Fatalf("destination.as %v", dst["as"]) - } - own := m["traffic66"].(map[string]any) - if own["sampling"] != "1:4096" || own["estimated"] != true { - t.Fatalf("sampled counts must be marked as estimates: %v", own) - } - obs := m["observer"].(map[string]any) - if obs["ip"] != "192.0.2.1" { - t.Fatalf("observer %v", obs) - } -} - -// Someone reading the file should see the sampling warning in words, not -// have to know what 1:4096 means for the numbers next to it. -func TestTextSaysTheCountsAreEstimates(t *testing.T) { - line := lineFor(t, FormatText, sample()) - if !strings.Contains(line, "counts are estimates") { - t.Fatalf("got %s", line) - } -} - -// A value carrying the separator of its own format would split one event -// into two, or swallow the rest of it. -func TestSeparatorsAreEscaped(t *testing.T) { - e := sample() - e.Org = "Pipe | Equals = Back\\slash" - e.Text = "a|b=c" - e.Extra = map[string]string{"note": "tab\there"} - - cef := lineFor(t, FormatCEF, e) - if strings.Contains(cef, "Pipe | Equals = ") { - t.Errorf("CEF did not escape: %s", cef) - } - if n := unescapedPipes(cef[strings.Index(cef, "CEF:"):]); n != 7 { - t.Errorf("CEF should have exactly 7 separating pipes, got %d in %s", n, cef) - } - leef := lineFor(t, FormatLEEF, e) - fields := strings.Split(leef[strings.Index(leef, "LEEF:"):], "\t") - for _, f := range fields[1:] { - if f != "" && !strings.Contains(f, "=") { - t.Errorf("LEEF field %q has no key, so a tab leaked through", f) - } - } -} - -// Older collectors mangle UTF-8, and a Japanese host name should not be -// the reason a feed stops being readable. -// unescapedPipes counts the pipes that separate CEF header fields: one -// written in a value is escaped and does not. -func unescapedPipes(s string) int { - n := 0 - for i := 0; i < len(s); i++ { - if s[i] == '\\' { - i++ - continue - } - if s[i] == '|' { - n++ - } - } - return n -} - -func TestASCIIOption(t *testing.T) { - e := sample() - e.Text = "株式会社サンプル" - cfg := Config{Enabled: true, Address: "c:514", Format: FormatCEF, Hostname: "センサー", ASCII: true} - cfg.defaults() - line := Message(cfg, e, when) - for _, r := range line { - if r > 0x7e { - t.Fatalf("%q is not ASCII: %s", r, line) - } - } - if !strings.Contains(line, "\\u682a") { - t.Fatalf("non-ASCII should be escaped, not dropped: %s", line) - } -} - -func TestSeverityAndPriority(t *testing.T) { - for _, c := range []struct{ sev, cef, pri int }{{3, 8, 108}, {2, 5, 109}, {1, 3, 109}} { - if got := severity(c.sev); got != c.cef { - t.Errorf("severity(%d) = %d, want %d", c.sev, got, c.cef) - } - if got := priority(13, c.sev); got != c.pri { - t.Errorf("priority(13, %d) = %d, want %d", c.sev, got, c.pri) - } - } -} - -// The event type is what a dashboard groups by, so it must not change -// with the wording. -func TestKindIsTheStableKey(t *testing.T) { - e := sample() - e.Text = "完全に違う文章" - line := lineFor(t, FormatCEF, e) - if !strings.Contains(line, `kind="brute"`) || !strings.Contains(line, "|brute|") { - t.Fatalf("the event type should survive any wording: %s", line) - } -} - -func TestFromFinding(t *testing.T) { - f := store.Finding{ - ID: 41, Kind: "exfil", Sev: 3, Src: "10.10.3.27", Dst: "203.0.113.200", - Port: "443/tcp", First: when, Last: when.Add(time.Hour), Hits: 4, - Ev: map[string]any{"up": float64(11_900_000_000), "down": float64(372_000_000), "sampling": float64(1000), "cc": "RU", "org": "Demo Hosting", "conns": float64(7)}, - } - e := FromFinding(f, PhaseStart, "https://traffic66.example.com/") - if e.Port != "443" || e.Proto != "TCP" { - t.Fatalf("port %q proto %q", e.Port, e.Proto) - } - if e.Sampling != "1:1000" { - t.Fatalf("sampling %q", e.Sampling) - } - if e.Bytes != 11_900_000_000 || e.Down != 372_000_000 || e.Country != "RU" || e.Org != "Demo Hosting" { - t.Fatalf("%+v", e) - } - if e.Extra["conns"] != "7" { - t.Fatalf("extra %v", e.Extra) - } - if !strings.Contains(e.Evidence, "finding=41") { - t.Fatalf("evidence %q", e.Evidence) - } - if !strings.Contains(e.Text, "11.1 GB") { - t.Fatalf("text %q", e.Text) - } -} - -// Sampling of 1 is not sampling, and saying "1:1" would make a reader -// think the counts were weighted. -func TestUnsampledSaysNothing(t *testing.T) { - f := store.Finding{ID: 1, Kind: "scan", Sev: 1, Ev: map[string]any{"sampling": float64(1)}} - if e := FromFinding(f, PhaseStart, ""); e.Sampling != "" { - t.Fatalf("sampling %q", e.Sampling) - } -} - -func TestConfigCheck(t *testing.T) { - ok := Config{Enabled: true, Address: "siem.example.com:514", Network: "tls", Format: FormatLEEF, MinSev: 2} - if err := ok.Check(); err != nil { - t.Fatal(err) - } - for _, c := range []struct { - what string - cfg Config - }{ - {"no address", Config{Enabled: true}}, - {"no port", Config{Enabled: true, Address: "siem.example.com"}}, - {"unknown transport", Config{Enabled: true, Address: "a:514", Network: "carrier-pigeon"}}, - {"unknown format", Config{Enabled: true, Address: "a:514", Format: "xml"}}, - {"severity out of range", Config{Enabled: true, Address: "a:514", MinSev: 9}}, - } { - if err := c.cfg.Check(); err == nil { - t.Errorf("%s was accepted", c.what) - } - } - // Switched off, nothing else matters. - if err := (Config{Address: "nonsense"}).Check(); err != nil { - t.Fatalf("a feed that is off should not complain: %v", err) - } -} - -// A flood of findings must not become a flood of syslog, or the feed gets -// turned off and nobody hears about the next one either. -func TestCeiling(t *testing.T) { - s := New(Config{Enabled: true, Address: "203.0.113.9:514", PerMin: 3}) - now := time.Now() - for i := 0; i < 5; i++ { - s.mu.Lock() - got := s.allow(now) - s.mu.Unlock() - if want := i < 3; got != want { - t.Fatalf("message %d: allowed=%v, want %v", i+1, got, want) - } - } - s.mu.Lock() - next := s.allow(now.Add(time.Minute)) - s.mu.Unlock() - if !next { - t.Fatal("the next minute starts again") - } -} - -// It has to actually reach a collector. -func TestSendsOverUDP(t *testing.T) { - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer pc.Close() - s := New(Config{Enabled: true, Address: pc.LocalAddr().String(), Format: FormatCEF}) - defer s.Close() - s.Send(sample()) - - buf := make([]byte, 4096) - pc.SetReadDeadline(time.Now().Add(3 * time.Second)) - n, _, err := pc.ReadFrom(buf) - if err != nil { - t.Fatal(err) - } - if got := string(buf[:n]); !strings.Contains(got, "CEF:0|traffic66") || !strings.Contains(got, "src=10.10.3.27") { - t.Fatalf("got %s", got) - } -} - -func TestBelowSeverityIsNotSent(t *testing.T) { - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer pc.Close() - s := New(Config{Enabled: true, Address: pc.LocalAddr().String(), MinSev: 3}) - defer s.Close() - low := sample() - low.Severity = 1 - s.Send(low) - pc.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) - if _, _, err := pc.ReadFrom(make([]byte, 512)); err == nil { - t.Fatal("a finding below the chosen severity was sent anyway") - } -} - -// A stream needs to know where one message ends. -func TestTCPIsFramed(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - defer ln.Close() - got := make(chan string, 1) - go func() { - c, err := ln.Accept() - if err != nil { - return - } - defer c.Close() - b := make([]byte, 4096) - n, _ := c.Read(b) - got <- string(b[:n]) - }() - s := New(Config{Enabled: true, Address: ln.Addr().String(), Network: "tcp", Format: FormatText}) - defer s.Close() - s.Send(sample()) - select { - case line := <-got: - size, rest, ok := strings.Cut(line, " ") - if !ok { - t.Fatalf("no octet count: %q", line) - } - if len(rest) == 0 || size == "" { - t.Fatalf("framing %q", line) - } - case <-time.After(3 * time.Second): - t.Fatal("nothing arrived") - } -} diff --git a/internal/syslogout/sender.go b/internal/syslogout/sender.go deleted file mode 100644 index 2a3cbba..0000000 --- a/internal/syslogout/sender.go +++ /dev/null @@ -1,270 +0,0 @@ -package syslogout - -import ( - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "log" - "net" - "os" - "strings" - "sync" - "time" -) - -// Config is what a customer sets. It is deliberately small: an address, a -// format, and the two knobs that stop a feed from being turned off again -// (what is worth sending, and how much of it at most). -type Config struct { - Enabled bool `json:"enabled"` - Address string `json:"address"` // host:port - Network string `json:"network"` // udp, tcp, tls - Format Format `json:"format"` // cef, leef, json, text - MinSev int `json:"min_sev"` // 1 low, 2 medium, 3 high - Facility int `json:"facility"` // syslog facility, default 13 (log audit) - Hostname string `json:"hostname"` // what to put in the header; empty is this machine's - ASCII bool `json:"ascii"` // escape everything outside ASCII - PerMin int `json:"per_minute"` // hard ceiling, 0 takes the default - CAFile string `json:"ca_file"` // trust for a TLS collector with its own CA - Insecure bool `json:"insecure"` // accept any certificate; for a lab, not for a customer - BaseURL string `json:"base_url"` // where the web UI is reachable, for the evidence link -} - -// Defaults for anything left empty. -const ( - DefaultFacility = 13 // log audit - DefaultPerMin = 60 - dialTimeout = 5 * time.Second - writeTimeout = 5 * time.Second -) - -func (c *Config) defaults() { - if c.Network == "" { - c.Network = "udp" - } - if c.Format == "" { - c.Format = FormatCEF - } - if c.Facility == 0 { - c.Facility = DefaultFacility - } - if c.PerMin == 0 { - c.PerMin = DefaultPerMin - } - if c.MinSev == 0 { - c.MinSev = 1 - } -} - -// Check reports what is wrong with a configuration, so the settings page -// can say so before anything is saved. -func (c Config) Check() error { - c.defaults() - if !c.Enabled { - return nil - } - if c.Address == "" { - return errors.New("syslog: no collector address") - } - if _, _, err := net.SplitHostPort(c.Address); err != nil { - return fmt.Errorf("syslog: address should be host:port, not %q", c.Address) - } - switch c.Network { - case "udp", "tcp", "tls": - default: - return fmt.Errorf("syslog: %q is not udp, tcp or tls", c.Network) - } - if !c.Format.Valid() { - return fmt.Errorf("syslog: %q is not one of %v", c.Format, Formats) - } - if c.MinSev < 1 || c.MinSev > 3 { - return fmt.Errorf("syslog: the least severity to send is 1, 2 or 3, not %d", c.MinSev) - } - return nil -} - -// Sender holds the connection and the throttle. -type Sender struct { - mu sync.Mutex - cfg Config - conn net.Conn - - minute time.Time - sent int - dropped int -} - -// New prepares a sender. It does not connect: a collector that is down -// when traffic66 starts is not a reason for traffic66 not to start. -func New(cfg Config) *Sender { - cfg.defaults() - return &Sender{cfg: cfg} -} - -// Config returns the configuration in use. -func (s *Sender) Config() Config { - s.mu.Lock() - defer s.mu.Unlock() - return s.cfg -} - -// SetConfig swaps the configuration, closing any connection to the old -// collector. -func (s *Sender) SetConfig(cfg Config) { - cfg.defaults() - s.mu.Lock() - defer s.mu.Unlock() - s.cfg = cfg - s.close() -} - -// Dropped is how many messages the ceiling has thrown away, for the -// settings page: a feed that is silently losing events is worse than one -// that says so. -func (s *Sender) Dropped() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.dropped -} - -// Send reports one event. Anything below the configured severity, and -// anything over the ceiling, is dropped here rather than at the collector. -func (s *Sender) Send(e Event) { - s.mu.Lock() - cfg := s.cfg - if !cfg.Enabled || cfg.Address == "" { - s.mu.Unlock() - return - } - if e.Phase != PhaseTest && e.Severity < cfg.MinSev { - s.mu.Unlock() - return - } - if !s.allow(time.Now()) { - s.dropped++ - s.mu.Unlock() - return - } - s.mu.Unlock() - - if err := s.write(Message(cfg, e, time.Now())); err != nil { - log.Printf("syslog: %v", err) - } -} - -// Test sends one message and reports whether it went, for the button that -// says so on the settings page. -func (s *Sender) Test() error { - cfg := s.Config() - if err := cfg.Check(); err != nil { - return err - } - if !cfg.Enabled { - return errors.New("syslog: not switched on") - } - now := time.Now() - e := Event{ - Phase: PhaseTest, ID: "0", Kind: "test", Severity: 1, - First: now, Last: now, Extra: map[string]string{}, - } - e.Text = Describe(e) - return s.write(Message(cfg, e, now)) -} - -// allow is the ceiling: a flood of findings must not become a flood of -// syslog, or the customer turns the feed off and nobody hears about the -// next one either. -func (s *Sender) allow(now time.Time) bool { - m := now.Truncate(time.Minute) - if m != s.minute { - s.minute, s.sent = m, 0 - } - if s.sent >= s.cfg.PerMin { - return false - } - s.sent++ - return true -} - -// write sends one line, reconnecting once if the connection has gone. A -// collector that restarts should cost one message at most, not the feed. -func (s *Sender) write(msg string) error { - s.mu.Lock() - defer s.mu.Unlock() - for attempt := 0; attempt < 2; attempt++ { - if s.conn == nil { - c, err := s.dial() - if err != nil { - return err - } - s.conn = c - } - s.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) - var err error - if s.cfg.Network == "udp" { - _, err = s.conn.Write([]byte(msg)) - } else { - // Octet counting (RFC 6587): a stream needs to know where one - // message ends, and a length is the framing collectors agree on. - _, err = fmt.Fprintf(s.conn, "%d %s", len(msg), msg) - } - if err == nil { - return nil - } - s.close() - if attempt == 1 { - return fmt.Errorf("sending to %s: %w", s.cfg.Address, err) - } - } - return nil -} - -func (s *Sender) dial() (net.Conn, error) { - switch s.cfg.Network { - case "tls": - cfg := &tls.Config{InsecureSkipVerify: s.cfg.Insecure, MinVersion: tls.VersionTLS12} - if s.cfg.CAFile != "" { - pem, err := os.ReadFile(s.cfg.CAFile) - if err != nil { - return nil, fmt.Errorf("syslog: %w", err) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("syslog: %s holds no certificate", s.cfg.CAFile) - } - cfg.RootCAs = pool - } - if host, _, err := net.SplitHostPort(s.cfg.Address); err == nil { - cfg.ServerName = host - } - return tls.DialWithDialer(&net.Dialer{Timeout: dialTimeout}, "tcp", s.cfg.Address, cfg) - case "tcp": - return net.DialTimeout("tcp", s.cfg.Address, dialTimeout) - } - return net.DialTimeout("udp", s.cfg.Address, dialTimeout) -} - -// close drops the connection; the next write dials again. -func (s *Sender) close() { - if s.conn != nil { - s.conn.Close() - s.conn = nil - } -} - -// Close stops using the collector. -func (s *Sender) Close() { - s.mu.Lock() - defer s.mu.Unlock() - s.close() -} - -// title upper-cases the first letter of a field name for CEF, where the -// custom field names are conventionally camel case. -func title(s string) string { - if s == "" { - return s - } - return strings.ToUpper(s[:1]) + s[1:] -} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 0238b4f..a05af34 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -343,32 +343,53 @@ label.btn.disabled{opacity:.5;cursor:not-allowed!important} out to the carrier and circuit it goes to. */ .thrimp{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px} .thrimp input{border:1px solid var(--line-2);border-radius:6px;padding:6px 8px;font:inherit;background:var(--surface);color:var(--ink);min-width:220px} -.rkhint{margin:0 0 14px;font-size:13px;color:var(--ink-3)} -.rkroom{border:1px solid var(--line-2);border-radius:10px;padding:12px 14px 14px;margin-bottom:14px;background:var(--surface-2)} -.rkroom>h3{margin:0 0 10px;font-size:15px;display:flex;align-items:center;gap:8px} +.rksum{margin:0 0 14px;font-size:13px;color:var(--ink-2)} +.rkroom{margin-bottom:18px} +.rkroom>h3{margin:0 0 10px;font-size:15px;display:flex;align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid var(--line)} .rkroom>h3 .ni{opacity:.7} -.rkroom.unplaced{border-style:dashed;background:transparent} -.rkracks{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start} -.rkrack{flex:1 1 360px;max-width:560px;border:2px solid var(--ink-3);border-radius:6px;background:var(--surface);overflow:hidden} -.rkrh{background:var(--ink-3);color:var(--surface);font-size:12.5px;font-weight:600;padding:4px 10px;letter-spacing:.02em} -.rkdev{margin:8px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface)} -.rkdh{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);font-size:13.5px} -.rkdh .mono{color:var(--ink-3)} -.rkif{display:flex;align-items:center;gap:8px;padding:5px 10px;font-size:13px;min-width:0} -.rkif+.rkif{border-top:1px dashed var(--line)} -.rkport{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.rkport .muted{font-size:12px} -.rkwire{flex:1 1 24px;min-width:24px;border-top:2px dotted var(--line-2);align-self:center} -.rkisp{flex:0 0 auto;max-width:60%;padding:2px 9px;border-radius:999px;background:var(--accent);color:#fff;font-size:12.5px; - white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.rkisp .mono{font-size:11.5px;opacity:.9;color:#fff} -.rkisp.none{background:transparent;color:var(--ink-3);border:1px dashed var(--line-2)} -.rkedit{margin-inline-start:auto;flex:none;background:none;border:0;padding:0 2px;color:var(--ink-3);cursor:pointer;font-size:13px} -.rkdh .rkedit{margin-inline-start:auto} -.rkif .rkedit{margin-inline-start:0} -.rkedit:hover{color:var(--accent)} -.rkmore{padding:2px 10px 6px;font-size:12.5px} +.rkroom>h3 .muted{font-size:12.5px;font-weight:400;margin-inline-start:4px} +.rkroom.unplaced>h3{color:var(--ink-2)} +.rkracks{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,420px),1fr));gap:14px;align-items:start} +.rkrack{border:1px solid var(--line-2);border-radius:12px;background:var(--surface-2);overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.08)} +.rkrh{display:flex;align-items:center;gap:8px;padding:7px 12px;font-size:12.5px;font-weight:600;letter-spacing:.03em;color:var(--ink-2); + border-bottom:1px solid var(--line-2);background:var(--surface)} +.rkrh::before{content:"";width:10px;height:12px;border:2px solid currentColor;border-radius:2px;opacity:.6} +/* the rack's two mounting rails, as a row of holes down each side */ +.rkbay{position:relative;padding:10px 18px;display:flex;flex-direction:column;gap:10px} +.rkbay::before,.rkbay::after{content:"";position:absolute;top:6px;bottom:6px;width:6px; + background:radial-gradient(circle,var(--line-2) 1.4px,transparent 1.8px) 0 0/6px 9px repeat-y} +.rkbay::before{left:6px}.rkbay::after{right:6px} +.rkdev{border:1px solid var(--line-2);border-radius:9px;background:var(--surface);overflow:hidden} +.rkfp{display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--line); + background:linear-gradient(180deg,var(--surface-2),var(--surface))} +.rkled{flex:none;width:8px;height:8px;border-radius:50%;background:var(--good);box-shadow:0 0 6px var(--good)} +.rkled.off{background:var(--ink-3);box-shadow:none} +.rkdn{display:flex;flex-direction:column;min-width:0;line-height:1.25} +.rkdn b{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rkdn .mono{font-size:11.5px;color:var(--ink-3)} +.rkfp .rkedit{margin-inline-start:auto;flex:none;padding:3px 12px;font-size:12.5px} +.rkports{padding:2px 0} +.rkif{display:grid;grid-template-columns:minmax(0,1.3fr) auto minmax(0,1fr);align-items:center;gap:10px;padding:6px 12px;font-size:13px} +.rkif+.rkif,.rkmore{border-top:1px solid var(--line)} +.rkport{display:flex;align-items:baseline;gap:6px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.rkport b{font-weight:600;overflow:hidden;text-overflow:ellipsis} +.rkport .muted{font-size:11.5px} +.rkisp{justify-self:start;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap; + background:hsla(var(--h),65%,50%,.16);border:1px solid hsla(var(--h),65%,50%,.55);color:var(--ink)} +.rkisp.none{background:transparent;border:1px dashed var(--line-2);color:var(--ink-3);font-weight:400} +.rkcirc{font-size:12px;color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rkmore{padding:4px 12px 6px;font-size:12.5px} .rkmore summary{cursor:pointer;color:var(--ink-2)} +.rkmore .rkif{padding-inline:0} +dialog.rkdlg{width:min(860px,96vw)} +dialog.rkdlg h2 .mono{font-size:12.5px;font-weight:400;margin-inline-start:6px} +.aform.rkdform{grid-template-columns:2fr 1fr 1fr;margin-bottom:14px} +.rkptab{max-height:52vh;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-bottom:12px} +.rkptab table{width:100%} +.rkptab th{position:sticky;top:0;background:var(--surface);z-index:1} +.rkptab td{padding:4px 6px} +.rkptab input{width:100%;font:inherit;font-size:13px;padding:4px 7px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface);color:var(--ink)} +@media (max-width:600px){.rkif{grid-template-columns:1fr auto}.rkcirc{grid-column:1/-1}.aform.rkdform{grid-template-columns:1fr}} .lnk{background:none;border:0;padding:0;color:var(--accent-ink);cursor:pointer;font:inherit;text-align:start} .lnk:hover{text-decoration:underline} .mono{font-family:"Cascadia Mono",Consolas,ui-monospace,monospace;font-size:12.5px} @@ -542,3 +563,27 @@ body.marked .view.on::after{ .manual table { border-collapse: collapse; } .manual th, .manual td { border: 1px solid var(--line, rgba(127,127,127,.25)); padding: 4px 8px; text-align: start; vertical-align: top; } @media (max-width: 900px) { .help { grid-template-columns: 1fr; } .helptoc { position: static; max-height: none; flex-flow: row wrap; } } + +/* the syslog feed, under the findings */ +.slform{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 14px;align-items:end;margin-bottom:12px} +.slform .slchk{grid-column:1/-1} +.slform .slwide{grid-column:span 2} +.slchk{display:flex;align-items:center;gap:8px;font-size:13.5px} +.slstate{margin-inline-start:auto;padding:2px 10px;border-radius:999px;font-size:12px;border:1px solid var(--line-2);color:var(--ink-3)} +.slstate.on{border-color:var(--good);color:var(--good)} +.slmsg{align-self:center;font-size:13px}.slmsg.ok{color:var(--good)}.slmsg.bad{color:var(--crit)} +.slstats{margin:10px 0 0;font-size:12.5px;color:var(--ink-2)} +.slerr{color:var(--crit)} +@media (max-width:760px){.slform{grid-template-columns:1fr 1fr}.slform .slwide{grid-column:1/-1}} + +/* links for other platforms, on the settings page */ +.embt td{vertical-align:top} +.embt code{font-size:12px} +.embnote{margin:10px 0 0;font-size:12.5px;color:var(--ink-3)} +.embkey{border:1px solid var(--good);border-radius:10px;padding:12px 14px;margin:0 0 14px;display:grid;gap:6px;background:var(--surface-2)} +.embkey label{font-size:12px;color:var(--ink-2);margin-top:4px} +.embcopy{display:flex;gap:8px;align-items:flex-start} +.embcopy code{flex:1;min-width:0;word-break:break-all;font-size:12px;padding:6px 8px;border-radius:6px;background:var(--surface);border:1px solid var(--line)} +dialog.embdlg{width:min(620px,96vw)} +.embdlg textarea{font:inherit;font-size:13px;font-family:ui-monospace,monospace;padding:5px 7px;border:1px solid var(--line-2);border-radius:6px;background:var(--surface);color:var(--ink);resize:vertical} +.afhint{font-size:11.5px;color:var(--ink-3)} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index a3e9f37..b10e2e6 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -169,7 +169,7 @@ async function loadLang(code) { } // the theme button shows the current theme; a click goes to the next one function themeLabel() { - const b = $('#theme'), cur = window.t66Theme ? t66Theme.get() : 'light'; + const b = $('#theme'), cur = window.t66Theme ? t66Theme.get() : 'black'; b.querySelector('span').textContent = t('theme.' + cur); b.title = t('theme.label') + ': ' + t('theme.' + cur) + ' · ' + t('theme.next'); b.setAttribute('aria-label', b.title); @@ -515,7 +515,7 @@ function axisTicks(from, to, max, span) { // dataFrom is when the stored data begins: the buckets that end before it // are left out (a gap), not drawn as traffic of zero. Lines marked keep // (a baseline from another day, device counters) are not cut. -function tsChart(el, {times, areas = [], lines = [], fmtY = fmtAxisBps, fmtV = fmtBps, settledTo = 0, dataFrom = 0}) { +function tsChart(el, {times, areas = [], lines = [], fmtY = fmtAxisBps, fmtV = fmtBps, settledTo = 0, dataFrom = 0, empty = ''}) { const stepMs = times && times.length > 1 ? times[1] - times[0] : 0; if (dataFrom && times) { const cut = d => (d || []).map((v, i) => times[i] + stepMs <= dataFrom ? null : v); @@ -526,7 +526,7 @@ function tsChart(el, {times, areas = [], lines = [], fmtY = fmtAxisBps, fmtV = f const W = el.clientWidth, H = el.clientHeight; if (W < 120 || H < 60 || !times || !times.length) { el.innerHTML = ''; return; } // nothing at all to draw: say so instead of an axis from 0 to 1 const has = d => (d || []).some(v => v != null && v >= 0); - if (!areas.some(a => has(a.data)) && !lines.some(l => has(l.data))) { el.innerHTML = `
${emptyText()}
`; return; } + if (!areas.some(a => has(a.data)) && !lines.some(l => has(l.data))) { el.innerHTML = `
${empty || emptyText()}
`; return; } const rtl = document.documentElement.dir === 'rtl'; const L = rtl ? 8 : 50, R = rtl ? 50 : 8, T = 8, B = 22, n = times.length; const x = i => rtl ? W - R - (W - L - R) * i / Math.max(1, n - 1) : L + (W - L - R) * i / Math.max(1, n - 1); @@ -740,7 +740,9 @@ const FBAR_VIEWS = new Set(['topn', 'traffic']); function renderFbar() { const bar = $('#fbar'), on = FBAR_VIEWS.has(state.v); bar.hidden = !on; - $('#filters').hidden = !on && !state.f.length; + // the pages that are not about the traffic (settings, the inventory, + // rooms and racks, cleanup...) are not filtered, so they show no chips + $('#filters').hidden = NOBAR_VIEWS.has(state.v) || (!on && !state.f.length); if (bar.hidden) return; bar.innerHTML = FBAR.map(([f, lab]) => { const cur = state.f.find(x => x.f === f && !x.neg); @@ -936,17 +938,23 @@ views.help = async (el) => { el.querySelectorAll('a[data-hlang]').forEach(a => a.onclick = e => { e.preventDefault(); helpLang = a.dataset.hlang; render(); window.scrollTo(0, 0); }); }; views.overview = async (el) => { - // total, or only the bytes that came in or only those that went out, - // from your networks' point of view — which way the bytes went, not who - // opened the connection: a network whose hosts only connect outwards - // still downloads, and those replies are its inbound traffic + // total, or only what came in or only what went out. Which way is read + // off the interface charted above, the same way that chart reads it: in + // through it is inbound, out through it outbound. Telling it by address + // instead (outside to inside) left the chart empty wherever the served + // addresses are public and not listed as your networks, or private at + // both ends: by address all of that is transit or internal. Without an + // interface, the address is all there is to go by. const od = ['inbound', 'outbound'].includes(state.od) ? state.od : ''; - const [d, ifs, fd] = await Promise.all([api('overview', {}, od ? [...state.f, {f: 'bytes', v: od === 'inbound' ? 'in' : 'out', neg: false}] : state.f), inSB() ? null : loadIfaces(), api('findings', {limit: 3}).catch(() => null)]); + const [d, ifs, fd] = await Promise.all([api('overview'), inSB() ? null : loadIfaces(), api('findings', {limit: 3}).catch(() => null)]); // the interface chart: the interface picked at the top, else the default, else the busiest const ifl = ifs?.ifaces || [], ikey = f => f.exporter + '/' + f.ifindex; const ifk = state.ifv && state.ifv !== 'all' ? state.ifv : (ifs?.default || ((ifl.find(f => !f.peer) || ifl[0]) ? ikey(ifl.find(f => !f.peer) || ifl[0]) : '')); const ifSel = ifl.find(f => ikey(f) === ifk); - const [iIn, iOut] = ifk && !inSB() ? await Promise.all(['if_in', 'if_out'].map(by => api('series', {by, measure: 'wire', top: 20, dup: 1}, [...state.f, {f: 'iface', v: ifk, neg: false}]).catch(() => null))) : [null, null]; + const viaIf = ifk && !inSB(); + const [iIn, iOut, dirSeries] = await Promise.all([ + ...(viaIf ? ['if_in', 'if_out'].map(by => api('series', {by, measure: 'wire', top: 20, dup: 1}, [...state.f, {f: 'iface', v: ifk, neg: false}]).catch(() => null)) : [null, null]), + od ? api('series', {by: 'app', measure: 'wire', top: 8, dup: 1}, [...state.f, viaIf ? {f: od === 'inbound' ? 'iface_in' : 'iface_out', v: ifk, neg: false} : {f: 'bytes', v: od === 'inbound' ? 'in' : 'out', neg: false}]).catch(() => null) : null]); const tot = d.totals, base = d.base_totals, basis = d.basis; // a baseline that was nearly idle gives figures like +57,695%: more than // ten times the baseline reads ">10×", and a baseline under 1 kB is no @@ -964,9 +972,9 @@ views.overview = async (el) => { acc = any ? {n: nf(worst * 100, 1) + '%', d: bad ? t('kpi.check') : t('kpi.trust'), kind: bad ? 'warn' : 'ok'} : {n: '—', d: t('kpi.no_flow'), kind: null}; } const peakTxt = d.peak_at ? t('kpi.peak', {v: fmtBps(d.peak_bps), t: fmtTime(d.peak_at, spanMs())}) : ''; - const series = d.series || {times: [], names: [], values: []}; + const series = (od ? dirSeries : d.series) || {times: [], names: [], values: []}; const areas = series.names.map((n, i) => ({name: appLabel(n), color: color(i, n), data: series.values[i]})); - const lines = d.baseline && basis ? [{name: baseText(d, 'line'), color: 'var(--base)', dash: true, keep: true, data: d.baseline}] : []; + const lines = !od && d.baseline && basis ? [{name: baseText(d, 'line'), color: 'var(--base)', dash: true, keep: true, data: d.baseline}] : []; const dirParts = (d.dir || []).map((p, i) => ({n: t('dir.' + p.key), v: p.wire, c: color(i), val: DIRS[p.key]})); const protoParts = (d.proto || []).map((p, i) => ({n: p.key === '__other__' ? t('other') : proto(+p.key), v: p.wire, c: color(i, p.key), val: p.key === '__other__' ? null : p.key})); const cl = d.top_clients || [], cmax = Math.max(1, ...cl.map(r => r.wire)); @@ -984,8 +992,8 @@ views.overview = async (el) => { ${iIn ? `

${t('if.bw')} · ${esc(ifSel ? ifName(ifSel) : ifk)}${ifSel?.tag ? `${esc(ifSel.tag)}` : ''}

${esc(t('ov.if_sub'))}
${t('if.in')}${t('if.out')}
` : ''} -

${t({'': 'ov.bw_all', inbound: 'ov.bw_in', outbound: 'ov.bw_out'}[od])}

${lines.length ? esc(baseText(d, 'sub')) : ''} -
${[['', 'ov.dir_total'], ['inbound', 'dir.2'], ['outbound', 'dir.1']].map(([k, l]) => ``).join('')}
+

${t({'': 'ov.bw_all', inbound: 'ov.bw_in', outbound: 'ov.bw_out'}[od])}

${od && viaIf ? esc(ifSel ? ifName(ifSel) : ifk) : lines.length ? esc(baseText(d, 'sub')) : ''} +
${[['', 'ov.dir_total'], ['inbound', 'if.in'], ['outbound', 'if.out']].map(([k, l]) => ``).join('')}
${areas.map((a, i) => `${series.names[i] === '__other__' ? esc(a.name) : V('app', series.names[i], a.name)}`).join('')}${lines.length ? `${esc(lines[0].name)}` : ''}
${panel('c6', t('ov.dir'), '', '
')} @@ -996,7 +1004,9 @@ views.overview = async (el) => { ${sv.map((r, i) => `${i + 1}${ipCell(r.key)}${V('port', r.key2, r.key2)} ${esc(r.extra)}${fmtBytes(r.wire)}${bar(r.wire, smax)}`).join('') || `${emptyText()}`}`)} ${fd ? findingsPanel(fd) : ''}
`; - tsChart($('#chStack'), {times: series.times, areas, lines, settledTo: series.settled_to, dataFrom: series.data_from}); + const dirWord = od && t(od === 'inbound' ? 'if.in' : 'if.out'); + tsChart($('#chStack'), {times: series.times, areas, lines, settledTo: series.settled_to, dataFrom: series.data_from, + empty: od && viaIf ? esc(t('ov.dir_empty', {if: ifSel ? ifName(ifSel) : ifk, d: /^[a-z]/i.test(dirWord) ? dirWord.toLocaleLowerCase(LANG) : dirWord})) : ''}); if (iIn) { const pk = s => { const i = (s?.names || []).indexOf(ifk); return i < 0 ? (s?.times || []).map(() => 0) : s.values[i]; }; tsChart($('#chIf'), {times: iIn.times, settledTo: iIn.settled_to, dataFrom: iIn.data_from, lines: [{name: t('if.in'), color: IF_IN, data: pk(iIn), fill: true}, {name: t('if.out'), color: IF_OUT, data: pk(iOut), fill: true}]}); @@ -1118,11 +1128,61 @@ views.findings = async (el) => {
${learning > Date.now() ? `
${esc(t('fd.learning', {t: new Intl.DateTimeFormat(LANG, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(learning))}))}
` : ''} ${fs.length ? `${findingRows(fs, true)}
` : `
${all ? t('fd.none') : status('ok', t('fd.none_open'))}
`} -

${esc(t('fd.note'))}

`; +

${esc(t('fd.note'))}

`; el.querySelectorAll('[data-fd]').forEach(b => b.onclick = () => { state.fd = b.dataset.fd; render(); }); bindFindings(el); + await syslogPanel($('#slPanel')); }; +// The syslog feed: the findings above, sent on to the customer's SIEM as +// they are made. Registered installations send; the others see the +// settings greyed out and why. Only an administrator changes them. +async function syslogPanel(el) { + const d = await fetch('/api/syslog').then(r => r.json()).catch(() => null); + if (!d || !d.available) { el.remove(); return; } + const c = d.config || {}, st = d.stats || {}, can = d.admin && d.registered; + const dis = can ? '' : ' disabled'; + const opt = (v, cur, lab) => ``; + const fmtLab = {cef: 'CEF', leef: 'LEEF', json: 'JSON', text: t('sl.fmt_text')}; + const when = x => x && !x.startsWith('0001') ? ago(Date.parse(x)) : ''; + el.innerHTML = `

${esc(t('sl.title'))}

${esc(t('sl.sub'))} + ${d.registered ? `${esc(t(c.enabled ? 'sl.on' : 'sl.off'))}` : ''}
+ ${d.registered ? '' : `
${esc(t('sl.locked'))}
`} +
+ +
+
+
+
+
+ +
+ ${can ? `
` : ''} + ${d.registered ? `

${esc(t('sl.stats', {s: nf(st.sent || 0), f: nf(st.failed || 0), d: nf(st.dropped || 0)}))}${st.last_sent && when(st.last_sent) ? ' · ' + esc(t('sl.last_sent', {t: when(st.last_sent)})) : ''} + ${st.last_error ? `
${esc(t('sl.last_err', {t: when(st.last_error_at), e: st.last_error}))}` : ''}

` : ''}`; + if (!can) return; + const msg = (ok, text) => { const m = $('#slMsg'); m.className = 'slmsg ' + (ok ? 'ok' : 'bad'); m.textContent = text; }; + const body = () => ({enabled: $('#slOn').checked, address: $('#slAddr').value.trim(), network: $('#slNet').value, format: $('#slFmt').value, + min_sev: +$('#slSev').value, base_url: $('#slBase').value.trim(), ascii: $('#slAscii').checked}); + const post = async (path, b) => { + const r = await fetch('/api/syslog' + path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: b ? JSON.stringify(b) : undefined}); + const j = await r.json().catch(() => ({})); + return r.ok ? null : j.error === 'not_registered' ? t('sl.locked') : (j.error || r.statusText); + }; + $('#slSave').onclick = async () => { + const err = await post('', body()); + if (err) { msg(false, err); return; } + await syslogPanel(el); msg(true, t('sl.saved')); + }; + // a test sends with what is saved: save first, so what is on screen is + // what is tried + $('#slTest').onclick = async () => { + const err = await post('', body()) || await post('/test'); + await syslogPanel(el); + msg(!err, err || t('sl.test_ok')); + }; +} + // ------------------------------------------------------------ series charts // One measure over time, split by a dimension: the top 8 values in the @@ -1671,7 +1731,7 @@ async function ifaceCleanup(el) { est.textContent = t('cl.if_estimating'); const res = await fetch(`/api/cleanup/iface?exporter=${encodeURIComponent(tg.exporter)}&ifindex=${tg.ifindex}&days=${tg.days}`); const j = await res.json().catch(() => ({})); - if (!res.ok) { est.textContent = j.error === 'not_registered' ? t('cl.if_locked') : (j.error || res.statusText); return; } + if (!res.ok) { est.textContent = (j.error === 'not_registered' || res.status === 404) ? t('cl.if_locked') : (j.error || res.statusText); return; } const parts = [t('cl.rows', {n: nf(j.rows || 0)}), t('cl.if_counters', {n: nf(j.counters || 0)})]; if (j.bytes) parts.push(t('cl.if_frees', {s: fmtBytes(j.bytes)})); est.textContent = parts.join(' · ') + (j.summaries ? ' — ' + t('cl.if_keep', {n: nf(j.summaries)}) : ''); @@ -1685,7 +1745,7 @@ async function ifaceCleanup(el) { run.disabled = true; const res = await fetch('/api/cleanup/iface', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(tg)}); const j = await res.json().catch(() => ({})); - if (!res.ok) { m.style.color = 'var(--crit)'; m.textContent = j.error === 'not_registered' ? t('cl.if_locked') : (j.error || res.statusText); return; } + if (!res.ok) { m.style.color = 'var(--crit)'; m.textContent = (j.error === 'not_registered' || res.status === 404) ? t('cl.if_locked') : (j.error || res.statusText); return; } m.style.color = 'var(--good)'; m.textContent = t('cl.if_done', {i: name, n: nf(j.rows || 0), c: nf(j.counters || 0), s: fmtBytes(j.bytes || 0)}); loadStatus(); @@ -1709,14 +1769,19 @@ const admOnly = html => adm() ? html : ''; // beside the names in the inventory file, and nothing is looked up in it. // Devices and ports come from the flows themselves; only the places and // the carriers are typed in. -const RK_SHOWN = 6; // ports listed before the rest fold away +const RK_SHOWN = 8; // ports listed before the rest fold away +// one colour per carrier, the same on every device, so the eye finds all +// of one carrier's lines at once +const rkHue = s => { let h = 0; for (const c of s) h = (h * 31 + c.codePointAt(0)) % 360; return h; }; +const rkISP = f => f.isp || f.circuit + ? `${esc(f.isp || '—')}` + : `${esc(t('rk.no_carrier'))}`; views.racks = async (el) => { const res = await fetch('/api/racks'); if (res.status === 401) { showLogin(); return; } const d = await res.json().catch(() => ({})); const devs = d.devices || [], can = !!d.admin; - const head = `

${esc(t('rk.hint'))}

`; - if (!devs.length) { el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), head + `
${esc(t('rk.empty'))}
`)}
`; return; } + if (!devs.length) { el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), `
${esc(t('rk.empty'))}
`)}
`; return; } const rooms = new Map(); devs.forEach((x, i) => { const room = x.room || '', rack = x.rack || ''; @@ -1726,66 +1791,167 @@ views.racks = async (el) => { racks.get(rack).push(i); }); // rooms in name order, the devices nobody has placed yet last - const order = [...rooms.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true})); - const pencil = attrs => can ? `` : ''; - const port = (i, f) => `
- ${esc(f.name || t('rk.ifindex', {n: f.ifindex}))} #${f.ifindex}${f.speed ? ' · ' + esc(fmtBps(f.speed)) : ''} - - ${f.isp || f.circuit - ? esc(f.isp || '') + (f.circuit ? ` ${f.isp ? '· ' : ''}${esc(f.circuit)}` : '') - : esc(t('rk.no_carrier'))} - ${pencil(`data-rki="${i}:${f.ifindex}"`)}
`; + const byName = (a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true}); + const order = [...rooms.keys()].sort(byName); + const ports = devs.flatMap(x => x.ifaces || []); + const sum = t('rk.summary', {r: nf(order.filter(Boolean).length), k: nf([...rooms.values()].reduce((n, m) => n + [...m.keys()].filter(Boolean).length, 0)), + d: nf(devs.length), p: nf(ports.filter(f => f.isp || f.circuit).length), n: nf(ports.length)}); + const port = f => `
+ ${esc(f.name || t('rk.ifindex', {n: f.ifindex}))}#${f.ifindex}${f.speed ? ' · ' + esc(fmtBps(f.speed)) : ''} + ${rkISP(f)}${esc(f.circuit || '')}
`; const device = i => { const x = devs[i], fs = x.ifaces || []; // the ports with a carrier always show; of the rest, the busiest const lead = fs.filter((f, k) => f.isp || f.circuit || k < RK_SHOWN), rest = fs.filter(f => !lead.includes(f)); - return `
${esc(x.name || x.exporter)}${x.name ? ` ${esc(x.exporter)}` : ''} - ${x.seen ? '' : `${esc(t('rk.quiet'))}`}${pencil(`data-rkd="${i}"`)}
- ${lead.map(f => port(i, f)).join('') || `
${esc(t('rk.no_ifaces'))}
`} - ${rest.length ? `
${esc(t('rk.more', {n: nf(rest.length)}))}${rest.map(f => port(i, f)).join('')}
` : ''}
`; + return `
+ ${esc(x.name || x.exporter)}${x.name ? `${esc(x.exporter)}` : ''} + ${x.seen ? '' : `${esc(t('rk.quiet'))}`} + ${can ? `` : ''}
+
${lead.map(port).join('') || `
${esc(t('rk.no_ifaces'))}
`} + ${rest.length ? `
${esc(t('rk.more', {n: nf(rest.length)}))}${rest.map(port).join('')}
` : ''}
`; }; const roomIcon = ''; const body = order.map(room => { - const racks = rooms.get(room), names = [...racks.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b, LANG, {numeric: true})); - return `

${roomIcon}${esc(room || t('rk.unplaced'))}

- ${names.map(rack => `
${esc(rack ? t('rk.rack_n', {x: rack}) : t('rk.no_rack'))}
${racks.get(rack).map(device).join('')}
`).join('')} + const racks = rooms.get(room), names = [...racks.keys()].sort(byName); + const n = [...racks.values()].reduce((k, v) => k + v.length, 0); + return `

${roomIcon}${esc(room || t('rk.unplaced'))}${esc(t('rk.room_n', {k: nf(names.filter(Boolean).length), d: nf(n)}))}

+ ${names.map(rack => `
${esc(rack ? t('rk.rack_n', {x: rack}) : t('rk.no_rack'))}
${racks.get(rack).map(device).join('')}
`).join('')}
`; }).join(''); - el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), head + body)}
`; + el.innerHTML = `
${panel('c12', t('nav.racks'), t('rk.sub'), `
${esc(sum)}
` + body)}
`; const uniq = k => [...new Set(devs.map(x => x[k]).filter(Boolean))].sort(); - const carriers = [...new Set(devs.flatMap(x => (x.ifaces || []).map(f => f.isp)).filter(Boolean))].sort(); - const send = async (path, body) => { - const r = await fetch('/api/racks/' + path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); - if (!r.ok) { const j = await r.json().catch(() => ({})); toast(j.error || r.statusText); return; } - ifList = null; render(); - }; + const carriers = [...new Set(ports.map(f => f.isp).filter(Boolean))].sort(); el.querySelectorAll('[data-rkd]').forEach(b => b.onclick = async () => { const x = devs[+b.dataset.rkd]; - const v = await rkForm(x.name || x.exporter, [['name', t('rk.device_name'), x.name], ['room', t('rk.room'), x.room, uniq('room')], ['rack', t('rk.rack'), x.rack, uniq('rack')]]); - if (v) send('device', {exporter: x.exporter, ...v}); - }); - el.querySelectorAll('[data-rki]').forEach(b => b.onclick = async () => { - const [i, idx] = b.dataset.rki.split(':').map(Number), x = devs[i], f = (x.ifaces || []).find(y => y.ifindex === idx) || {ifindex: idx}; - const v = await rkForm((x.name || x.exporter) + ' · #' + idx, [['name', t('rk.iface_name'), f.name], ['isp', t('rk.isp'), f.isp, carriers], ['circuit', t('rk.circuit'), f.circuit]]); - if (v) send('iface', {exporter: x.exporter, ifindex: idx, ...v}); + const v = await rkEdit(x, {rooms: uniq('room'), racks: uniq('rack'), carriers}); + if (!v) return; + const r = await fetch('/api/racks/device', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({exporter: x.exporter, ...v})}); + if (!r.ok) { const j = await r.json().catch(() => ({})); toast(j.error || r.statusText); return; } + ifList = null; render(); }); }; -// rkForm asks for a few short values and resolves to them, or to null when -// cancelled. A list offers what was typed for other devices before, so one -// room is not written three ways. -function rkForm(title, fields) { +// rkEdit is one form for a device and every port on it: where it stands, +// what it is called, and for each port the carrier and circuit. It +// resolves to what to save (only the ports that changed), or to null when +// cancelled. The lists offer what was typed before, so one room or one +// carrier is not written three ways. +function rkEdit(x, {rooms, racks, carriers}) { return new Promise(resolve => { + const fs = x.ifaces || []; + const dl = (id, list) => list.length ? `${list.map(o => `` : ''; + const inp = (name, v, list, ph) => ``; const dlg = document.createElement('dialog'); - dlg.className = 'aedit'; - dlg.innerHTML = `

${esc(title)}

${fields.map(([k, lab, v, list]) => `
- - ${list && list.length ? `${list.map(o => `` : ''}
`).join('')}
+ dlg.className = 'aedit rkdlg'; + dlg.innerHTML = `

${esc(x.name || x.exporter)} ${esc(x.exporter)}

+
+
${inp('name', x.name)}
+
${inp('room', x.room, 'rkl_room')}
+
${inp('rack', x.rack, 'rkl_rack')}
+
+ ${fs.length ? `
+ ${fs.map((f, k) => ` + `).join('')} +
${esc(t('rk.port'))}${esc(t('rk.iface_name'))}${esc(t('rk.isp'))}${esc(t('rk.circuit'))}
#${f.ifindex}${f.speed ? ` ${esc(fmtBps(f.speed))}` : ''}${inp('name', f.name, '', t('rk.ifindex', {n: f.ifindex}))}${inp('isp', f.isp, 'rkl_isp')}${inp('circuit', f.circuit)}
` : `

${esc(t('rk.no_ifaces'))}

`} + ${dl('rkl_room', rooms)}${dl('rkl_rack', racks)}${dl('rkl_isp', carriers)}
`; document.body.appendChild(dlg); dlg.addEventListener('close', () => { - const out = {}; - dlg.querySelectorAll('input').forEach(x => { out[x.name] = x.value.trim(); }); + const val = (root, n) => root.querySelector(`input[name="${n}"]`).value.trim(); + const top = dlg.querySelector('.rkdform'); + const out = {name: val(top, 'name'), room: val(top, 'room'), rack: val(top, 'rack'), ifaces: []}; + dlg.querySelectorAll('tr[data-k]').forEach(tr => { + const f = fs[+tr.dataset.k], v = {name: val(tr, 'name'), isp: val(tr, 'isp'), circuit: val(tr, 'circuit')}; + if (v.name !== (f.name || '') || v.isp !== (f.isp || '') || v.circuit !== (f.circuit || '')) out.ifaces.push({ifindex: f.ifindex, ...v}); + }); + const ok = dlg.returnValue === 'ok'; + dlg.remove(); + resolve(ok ? out : null); + }); + dlg.showModal(); + dlg.querySelector('input')?.focus(); + }); +} + +// ------------------------------------------------------------ links for other platforms +// One key per platform. Its links are the same URL with the address put +// in, so an asset platform adds a "traffic" button beside each of its +// service addresses without keeping a secret per address. The key is +// shown once, when it is made or replaced. +async function embedPanel(el) { + const d = await fetch('/api/embeds').then(r => r.json()).catch(() => null); + if (!d) { el.remove(); return; } + const links = d.links || [], reg = d.registered; + const when = x => x && !x.startsWith('0001') ? ago(Date.parse(x)) : t('emb.never'); + const list = xs => (xs || []).length ? xs.map(x => `${esc(x)}`).join(' ') : `${esc(t('emb.any'))}`; + el.innerHTML = `

${esc(t('emb.title'))}

${esc(t('emb.sub'))} + ${reg ? `` : ''}
+ ${reg ? '' : `
${esc(t('emb.locked'))}
`} + ${location.protocol === 'http:' ? `

${esc(t('emb.http_warn'))}

` : ''} +
+ ${links.length ? ` + ${links.map((x, i) => ` + + + `).join('')}
${esc(t('emb.col_name'))}${esc(t('emb.col_ranges'))}${esc(t('emb.col_origins'))}${esc(t('emb.col_clients'))}${esc(t('emb.col_key'))}${esc(t('emb.col_used'))}
${esc(x.name)}${list(x.ranges)}${list(x.origins)}${list(x.clients)}${esc(x.prefix)}…${x.old_until ? `
${esc(t('emb.old_until', {t: new Intl.DateTimeFormat(LANG, {dateStyle: 'short', timeStyle: 'short'}).format(new Date(x.old_until))}))}` : ''}
${esc(when(x.last_used))}
${esc(t('emb.hits', {n: nf(x.hits || 0)}))}
${reg ? ` ` : ''}
` + : `
${esc(t('emb.none'))}
`} +

${esc(t('emb.rate', {n: nf(d.per_minute || 60)}))}

`; + const send = async (method, path, body) => { + const r = await fetch('/api/embeds' + path, {method, headers: {'Content-Type': 'application/json'}, body: body ? JSON.stringify(body) : undefined}); + const j = await r.json().catch(() => ({})); + if (!r.ok) { toast(j.error === 'not_registered' ? t('emb.locked') : (j.error || r.statusText)); return null; } + return j; + }; + // the key, shown this once, as the link the platform builds + const showKey = async key => { + await embedPanel(el); + const link = `${location.origin}/embed/vip?ip={VIP}&k=${key}`; + const frame = ``; + $('#embKey').innerHTML = `
${esc(t('emb.key_once'))} +
${esc(link)}
+
${esc(frame)}
`; + el.querySelectorAll('[data-copy]').forEach(b => b.onclick = async () => { + try { await navigator.clipboard.writeText(b.dataset.copy); toast(t('emb.copied')); } catch (e) { toast(b.dataset.copy); } + }); + }; + const edit = async x => { + const v = await embedForm(x || {}); + if (!v) return; + const j = await send('POST', '', {...v, id: x?.id || ''}); + if (!j) return; + if (j.key) showKey(j.key); else embedPanel(el); + }; + if ($('#embNew')) $('#embNew').onclick = () => edit(null); + el.querySelectorAll('[data-emed]').forEach(b => b.onclick = () => edit(links[+b.dataset.emed])); + el.querySelectorAll('[data-emrot]').forEach(b => b.onclick = async () => { + const x = links[+b.dataset.emrot]; + if (!confirm(t('emb.rotate_confirm', {n: x.name}))) return; + const j = await send('POST', '/rotate', {id: x.id}); + if (j?.key) showKey(j.key); + }); + el.querySelectorAll('[data-emdel]').forEach(b => b.onclick = async () => { + const x = links[+b.dataset.emdel]; + if (!confirm(t('emb.revoke_confirm', {n: x.name}))) return; + if (await send('DELETE', '?id=' + encodeURIComponent(x.id))) embedPanel(el); + }); +} +// embedForm asks for one platform's name and limits; one entry per line. +function embedForm(x) { + return new Promise(resolve => { + const area = (k, v, hint, ph) => `
+ ${esc(t(hint))}
`; + const dlg = document.createElement('dialog'); + dlg.className = 'aedit embdlg'; + dlg.innerHTML = `

${esc(x.name || t('emb.new'))}

+
+ ${area('ranges', x.ranges, 'emb.f_ranges_hint', '203.0.113.0/24\n198.51.100.10')} + ${area('origins', x.origins, 'emb.f_origins_hint', 'assets.example.com')} + ${area('clients', x.clients, 'emb.f_clients_hint', '192.0.2.0/24')} +
`; + document.body.appendChild(dlg); + dlg.addEventListener('close', () => { + const lines = n => dlg.querySelector(`[name="${n}"]`).value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean); + const out = {name: dlg.querySelector('[name="name"]').value.trim(), ranges: lines('ranges'), origins: lines('origins'), clients: lines('clients')}; const ok = dlg.returnValue === 'ok'; dlg.remove(); resolve(ok ? out : null); @@ -2171,6 +2337,7 @@ views.sources = async (el) => {
`))} + ${admOnly('
')} ${admOnly(blocked.length ? panel('c12', t('blk.title'), t('blk.sub'), ` ${blocked.map(b => ` @@ -2208,6 +2375,7 @@ views.sources = async (el) => { loadStatus(); // the badge, the footer and the watermark all follow from it }; } + if (adm()) embedPanel($('#embPanel')); const geoMsg = (ok, text) => { const m = $('#geoMsg'); m.style.color = ok === null ? '' : ok ? 'var(--good)' : 'var(--crit)'; m.textContent = text; }; if (adm()) { const reblock = async (method, path, okMsg) => { diff --git a/internal/web/static/embed.css b/internal/web/static/embed.css new file mode 100644 index 0000000..b9e660b --- /dev/null +++ b/internal/web/static/embed.css @@ -0,0 +1,41 @@ +/* The page other platforms open for one address. It sits in a frame or a + tab of somebody else's site, so it is plain and fills what it is given. */ +:root{ + --bg:#000;--surface:#1c1c1e;--line:#2c2c2e;--ink:#fff;--ink-2:#d1d1d6;--ink-3:#8e8e93; + --in:#30d158;--out:#0a84ff;--accent:#0a84ff;color-scheme:dark} +:root[data-theme=light]{ + --bg:#fff;--surface:#f2f2f7;--line:#e5e5ea;--ink:#1c1c1e;--ink-2:#3a3a3c;--ink-3:#8e8e93; + --in:#34c759;--out:#007aff;--accent:#007aff;color-scheme:light} +*{box-sizing:border-box} +html,body{margin:0;height:100%;background:var(--bg);color:var(--ink); + font:14px/1.4 system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} +main{display:flex;flex-direction:column;min-height:100%;padding:16px;gap:14px} +header{display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px} +header h1{margin:0;font-size:19px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:600} +header span{font-size:12.5px;color:var(--ink-3)} +nav{margin-inline-start:auto;display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden} +nav button{font:inherit;font-size:13px;padding:5px 12px;border:0;background:transparent;color:var(--ink-2);cursor:pointer} +nav button+button{border-inline-start:1px solid var(--line)} +nav button[aria-pressed=true]{background:var(--accent);color:#fff} +.emks{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.emk{background:var(--surface);border-radius:12px;padding:12px 14px;border-top:3px solid var(--in)} +.emk.out{border-top-color:var(--out)} +.emk>b{font-size:13px;color:var(--ink-2)} +.emk dl{display:flex;flex-wrap:wrap;gap:4px 22px;margin:6px 0 0} +.emk dl div{min-width:0} +.emk dt{font-size:11.5px;color:var(--ink-3)} +.emk dd{margin:0;font-size:18px;font-weight:600;font-variant-numeric:tabular-nums} +.emch{position:relative;flex:1;min-height:240px;background:var(--surface);border-radius:12px;overflow:hidden} +.emch svg{display:block;touch-action:none} +.g{stroke:var(--line);stroke-width:1} +.ya,.xa{fill:var(--ink-3);font-size:11px} +.fi{fill:var(--in);opacity:.18}.fo{fill:var(--out);opacity:.18} +.li{fill:none;stroke:var(--in);stroke-width:1.6}.lo{fill:none;stroke:var(--out);stroke-width:1.6} +.cur{stroke:var(--ink-3);stroke-dasharray:3 3} +.tip{position:absolute;top:10px;pointer-events:none;background:var(--bg);border:1px solid var(--line);border-radius:8px; + padding:6px 9px;font-size:12px;display:flex;flex-direction:column;gap:2px;white-space:nowrap} +.tip[hidden]{display:none} +.tip .in{color:var(--in)}.tip .out{color:var(--out)} +.emerr{padding:40px 16px;text-align:center;color:var(--ink-3)} +footer{font-size:11px;color:var(--ink-3);text-align:end} +@media (max-width:520px){.emks{grid-template-columns:1fr}nav{margin-inline-start:0}} diff --git a/internal/web/static/embed.js b/internal/web/static/embed.js new file mode 100644 index 0000000..88c3af3 --- /dev/null +++ b/internal/web/static/embed.js @@ -0,0 +1,138 @@ +// The page other platforms open for one address: its bandwidth in and out +// over a period, and nothing else. It stands alone: no session, no menu, +// the key from its own link, redrawn every minute. +(function () { + 'use strict'; + const LANGS = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar', 'hi', 'bn', 'ur', 'id', 'vi']; + const RANGES = ['1h', '24h', '7d', '30d']; + const qs = new URLSearchParams(location.search); + const root = document.getElementById('em'), ip = root.dataset.ip, key = qs.get('k') || ''; + // dark unless the platform asks for light, to sit in a light page + document.documentElement.dataset.theme = qs.get('theme') === 'light' ? 'light' : 'dark'; + let range = RANGES.includes(qs.get('range')) ? qs.get('range') : '24h'; + let L = {}, lang = 'en', data = null, timer = 0; + const t = (k, p) => { let s = L[k] || k; for (const [a, b] of Object.entries(p || {})) s = s.split('{' + a + '}').join(b); return s; }; + const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); + const nf = (v, d = 0) => new Intl.NumberFormat(lang, {maximumFractionDigits: d, minimumFractionDigits: d}).format(v); + function bps(v) { + if (v == null || !isFinite(v)) return '—'; + for (const [n, m] of [['Tbps', 1e12], ['Gbps', 1e9], ['Mbps', 1e6], ['Kbps', 1e3]]) if (v >= m) return nf(v / m, v / m < 10 ? 2 : v / m < 100 ? 1 : 0) + ' ' + n; + return nf(v) + ' bps'; + } + function axis(v) { + if (v === 0) return '0'; + for (const [n, m] of [['T', 1e12], ['G', 1e9], ['M', 1e6], ['K', 1e3]]) if (v >= m) return nf(v / m, v / m < 10 && v % m ? 1 : 0) + n; + return nf(v); + } + function pick() { + const want = (qs.get('lang') || '').toLowerCase(); + if (LANGS.includes(want)) return want; + for (const l of navigator.languages || [navigator.language || 'en']) { + const c = l.toLowerCase().split('-')[0]; + if (LANGS.includes(c)) return c; + } + return 'en'; + } + + async function load() { + clearTimeout(timer); + const p = new URLSearchParams({ip, k: key, range, tz: String(-new Date().getTimezoneOffset())}); + try { + const r = await fetch('/embed/vip/data?' + p, {cache: 'no-store'}); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || r.statusText); + data = j; + draw(); + } catch (e) { + data = null; + root.innerHTML = head() + `
${esc(e.message)}
`; + bind(); + } + timer = setTimeout(load, 60000); + } + + function head() { + return `

${esc(ip)}

${esc(t('em.sub'))}
+
`; + } + function bind() { + root.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { range = b.dataset.r; load(); }); + } + + // the minutes still arriving are left off the end rather than drawn as + // a fall to nothing + function settled(d, vals) { + return vals.map((v, i) => (d.settled_to && d.times[i] >= d.settled_to) || (d.data_from && d.times[i] + d.step * 1000 <= d.data_from) ? null : v); + } + function stats(v) { + const xs = v.filter(x => x != null); + if (!xs.length) return {now: null, peak: null, avg: null}; + return {now: xs[xs.length - 1], peak: Math.max(...xs), avg: xs.reduce((a, b) => a + b, 0) / xs.length}; + } + + function draw() { + const d = data, vin = settled(d, d.in || []), vout = settled(d, d.out || []); + const si = stats(vin), so = stats(vout); + const card = (cls, name, s) => `
${esc(name)} +
${esc(t('em.now'))}
${bps(s.now)}
${esc(t('em.peak'))}
${bps(s.peak)}
${esc(t('em.avg'))}
${bps(s.avg)}
`; + root.innerHTML = head() + `
${card('in', t('if.in'), si)}${card('out', t('if.out'), so)}
+
traffic66
`; + bind(); + chart(document.getElementById('ch'), d.times || [], vin, vout); + } + + function chart(el, times, a, b) { + const W = el.clientWidth, H = el.clientHeight, n = times.length; + const has = v => v.some(x => x != null && x > 0); + if (n < 2 || (!has(a) && !has(b))) { el.innerHTML = `
${esc(t('em.nodata'))}
`; return; } + const rtl = document.documentElement.dir === 'rtl'; + const Lp = rtl ? 10 : 52, Rp = rtl ? 52 : 10, T = 10, B = 24; + const top = Math.max(1, ...a.filter(x => x != null), ...b.filter(x => x != null)); + const mag = Math.pow(10, Math.floor(Math.log10(top))), nice = [1, 2, 2.5, 5, 10].map(m => m * mag).find(m => m * 4 >= top) || top; + const ymax = nice * 4; + const x = i => rtl ? W - Rp - (W - Lp - Rp) * i / (n - 1) : Lp + (W - Lp - Rp) * i / (n - 1); + const y = v => T + (H - T - B) * (1 - v / ymax); + const path = v => { let s = '', on = false; v.forEach((p, i) => { if (p == null) { on = false; return; } s += (on ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(p).toFixed(1); on = true; }); return s; }; + const area = v => { let s = '', run = []; const flush = () => { if (run.length) s += 'M' + x(run[0]).toFixed(1) + ' ' + y(0) + run.map(i => 'L' + x(i).toFixed(1) + ' ' + y(v[i]).toFixed(1)).join('') + 'L' + x(run[run.length - 1]).toFixed(1) + ' ' + y(0) + 'Z'; run = []; }; v.forEach((p, i) => p == null ? flush() : run.push(i)); flush(); return s; }; + const span = times[n - 1] - times[0]; + const fmt = new Intl.DateTimeFormat(lang, span > 2 * 864e5 ? {month: 'numeric', day: 'numeric'} : {hour: '2-digit', minute: '2-digit'}); + const tip = new Intl.DateTimeFormat(lang, {month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit'}); + let grid = ''; + for (let k = 0; k <= 4; k++) { + const v = nice * k, yy = y(v).toFixed(1); + grid += `${axis(v)}`; + } + const ticks = Math.max(2, Math.min(7, Math.floor((W - Lp - Rp) / 90))); + for (let k = 0; k < ticks; k++) { + const i = Math.round(k * (n - 1) / (ticks - 1)); + // the first and last labels keep inside the chart + const anchor = k === 0 ? (rtl ? 'end' : 'start') : k === ticks - 1 ? (rtl ? 'start' : 'end') : 'middle'; + grid += `${esc(fmt.format(new Date(times[i])))}`; + } + el.innerHTML = `${grid} + + `; + const svg = el.querySelector('svg'), cur = el.querySelector('.cur'), tp = el.querySelector('.tip'); + svg.addEventListener('pointermove', e => { + const r = svg.getBoundingClientRect(), px = e.clientX - r.left; + let i = Math.round((rtl ? W - Rp - px : px - Lp) / (W - Lp - Rp) * (n - 1)); + i = Math.max(0, Math.min(n - 1, i)); + cur.setAttribute('x1', x(i)); cur.setAttribute('x2', x(i)); cur.setAttribute('visibility', 'visible'); + tp.hidden = false; + tp.innerHTML = `${esc(tip.format(new Date(times[i])))}${esc(t('if.in'))} ${bps(a[i])}${esc(t('if.out'))} ${bps(b[i])}`; + tp.style.left = Math.min(W - tp.offsetWidth - 4, Math.max(4, x(i) + 10)) + 'px'; + }); + svg.addEventListener('pointerleave', () => { cur.setAttribute('visibility', 'hidden'); tp.hidden = true; }); + } + + async function start() { + lang = pick(); + document.documentElement.lang = lang; + if (['ar', 'ur'].includes(lang)) document.documentElement.dir = 'rtl'; + try { L = await (await fetch('/i18n/' + lang + '.json')).json(); } catch (e) { L = {}; } + await load(); + let w = innerWidth; + addEventListener('resize', () => { if (data && innerWidth !== w) { w = innerWidth; draw(); } }); + } + start(); +})(); diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index 9372571..717e2ee 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -276,6 +276,65 @@ "sev.high": "عالية", "sev.medium": "متوسطة", "sev.low": "منخفضة", +"emb.title": "روابط للمنصات الأخرى", +"emb.sub": "امنح منصة أخرى، مثل منصة الأصول، رابطًا لعرض النطاق لعنوان VIP واحد: زر بجانب كل VIP مع وضع عنوانه في الرابط. يعرض فقط عرض النطاق الوارد والصادر لهذا الـ VIP.", +"emb.locked": "روابط المنصات الأخرى ميزة للتثبيتات المسجّلة. لا يزال بالإمكان سحب الروابط الحالية.", +"emb.new": "رابط جديد", +"emb.none": "لا توجد روابط بعد.", +"emb.http_warn": "يقدّم هذا الجهاز الواجهة عبر HTTP؛ لتضمينها في منصة HTTPS يلزمه HTTPS أيضًا، وإلا سيحجبها المتصفح.", +"emb.col_name": "الاسم", +"emb.col_ranges": "عناوين VIP المسموح بعرضها", +"emb.col_origins": "المواقع المسموح بها", +"emb.col_clients": "شبكات المشاهدين", +"emb.col_key": "المفتاح", +"emb.col_used": "آخر استخدام", +"emb.never": "لم يُستخدم", +"emb.any": "أي", +"emb.hits": "{n} طلب", +"emb.old_until": "المفتاح القديم صالح حتى {t}", +"emb.edit": "تعديل", +"emb.rotate": "مفتاح جديد", +"emb.revoke": "سحب", +"emb.rotate_confirm": "منح «{n}» مفتاحًا جديدًا؟ يبقى القديم صالحًا 24 ساعة ريثما تنتقل المنصة.", +"emb.revoke_confirm": "سحب «{n}»؟ تتوقف روابطه فورًا.", +"emb.key_once": "يظهر المفتاح هذه المرة فقط: انسخه الآن. ضع عنوان الـ VIP مكان {VIP}.", +"emb.link": "الرابط (لزر يفتحه)", +"emb.frame": "شيفرة التضمين (iframe)", +"emb.copy": "نسخ", +"emb.copied": "تم النسخ", +"emb.rate": "لكل مشاهد {n} طلبًا في الدقيقة كحد أقصى؛ يُرفض ما يزيد لفترة. تُحدَّث الصفحة كل دقيقة.", +"emb.f_name": "الاسم (مثلًا: منصة أصول العميل A)", +"emb.f_ranges": "عناوين VIP المسموح بعرضها", +"emb.f_ranges_hint": "عنوان أو نطاق في كل سطر، مثل 203.0.113.0/24. يُرفض أي عنوان آخر.", +"emb.f_origins": "المواقع المسموح بها", +"emb.f_origins_hint": "اسم موقع في كل سطر، مثل assets.example.com؛ يشمل *.example.com النطاقات الفرعية؛ و* يسمح بأي موقع. تظهر الصفحة فقط عند فتحها أو تضمينها من هذه المواقع.", +"emb.f_clients": "شبكات المشاهدين (اختياري)", +"emb.f_clients_hint": "نطاق في كل سطر. عند ضبطه لا يفتحها إلا المشاهدون من هذه الشبكات.", +"em.sub": "عرض النطاق · الوارد والصادر", +"em.now": "الآن", +"em.peak": "الذروة", +"em.avg": "المتوسط", +"em.nodata": "لا توجد حركة في هذه الفترة.", +"sl.title": "الإرسال إلى syslog", +"sl.sub": "تُرسل النتائج إلى نظام SIEM أو منصة السجلات لديك فور ظهورها (Splunk وQRadar وArcSight وGraylog وElastic)", +"sl.on": "يُرسل", +"sl.off": "متوقف", +"sl.locked": "الإرسال إلى syslog ميزة للتثبيتات المسجّلة. تستمر النتائج في الظهور على هذه الصفحة.", +"sl.enabled": "إرسال النتائج", +"sl.address": "عنوان المُجمِّع (المضيف:المنفذ)", +"sl.network": "طريقة النقل", +"sl.format": "التنسيق", +"sl.fmt_text": "نص عادي", +"sl.min_sev": "أدنى خطورة", +"sl.base_url": "عنوان traffic66 هذا (لرابط الدليل، اختياري)", +"sl.ascii": "ASCII فقط (للمُجمِّعات القديمة)", +"sl.save": "حفظ", +"sl.test": "إرسال اختبار", +"sl.saved": "تم الحفظ", +"sl.test_ok": "أُرسلت رسالة الاختبار", +"sl.stats": "أُرسل {s} · فشل {f} · أُسقط {d} لتجاوز الحد في الدقيقة", +"sl.last_sent": "آخر إرسال {t}", +"sl.last_err": "آخر فشل ({t}): {e}", "fd.open": "غير معالَجة", "fd.all": "الكل", "fd.none": "لم يُكتشف شيء في هذه الفترة.", @@ -543,6 +602,7 @@ "ov.bw_all": "عرض النطاق (وارد + صادر)، حسب التطبيق", "ov.bw_in": "عرض النطاق الوارد، حسب التطبيق", "ov.bw_out": "عرض النطاق الصادر، حسب التطبيق", +"ov.dir_empty": "لا توجد حركة ({d}) عبر {if} في هذه الفترة.", "src.manual": "مضبوط يدويًا", "src.templates": "القوالب المستلمة (أرقام الحقول؛ s = النطاق):", "src.stale": "متوقفة (الجهاز يرسل الآن بطريقة أخرى):", @@ -685,7 +745,9 @@ "ob.copy": "نسخ", "nav.help": "المساعدة", "rk.sub": "أين يقف كل جهاز يرسل التدفقات، وإلى أي مشغّل ودائرة يخرج كل منفذ", -"rk.hint": "املأه مرة واحدة. الأجهزة والمنافذ تأتي من التدفقات؛ ولا يُكتب إلا الغرفة والخزانة والمشغّل والدائرة، وتُحفظ في ملف السجل.", +"rk.summary": "{r} غرف · {k} خزائن · {d} أجهزة · المزوّد محدد على {p} من {n} منافذ", +"rk.room_n": "{k} خزائن · {d} أجهزة", +"rk.port": "المنفذ", "rk.empty": "لم يرسل أي جهاز تدفقات بعد.", "rk.unplaced": "لم يُحدَّد مكانه بعد", "rk.no_rack": "بلا خزانة", diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 769833f..7a0c40e 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -276,6 +276,65 @@ "sev.high": "উচ্চ", "sev.medium": "মাঝারি", "sev.low": "নিম্ন", +"emb.title": "অন্য প্ল্যাটফর্মের জন্য লিঙ্ক", +"emb.sub": "অ্যাসেট প্ল্যাটফর্মের মতো অন্য প্ল্যাটফর্মকে একটি VIP-এর ব্যান্ডউইথের লিঙ্ক দিন: প্রতিটি VIP-এর পাশে একটি বোতাম, লিঙ্কে VIP-এর ঠিকানা বসিয়ে। এতে শুধু সেই VIP-এর আগত ও বহির্গামী ব্যান্ডউইথ দেখা যায়।", +"emb.locked": "অন্য প্ল্যাটফর্মের লিঙ্ক নিবন্ধিত ইনস্টলেশনের জন্য। বিদ্যমান লিঙ্ক এখনও প্রত্যাহার করা যায়।", +"emb.new": "নতুন লিঙ্ক", +"emb.none": "এখনও কোনো লিঙ্ক নেই।", +"emb.http_warn": "এই মেশিন HTTP-তে ওয়েব UI দেয়; HTTPS প্ল্যাটফর্মে বসাতে এখানেও HTTPS লাগবে, নইলে ব্রাউজার আটকে দেবে।", +"emb.col_name": "নাম", +"emb.col_ranges": "যে VIP দেখানো যাবে", +"emb.col_origins": "অনুমোদিত সাইট", +"emb.col_clients": "দর্শকদের নেটওয়ার্ক", +"emb.col_key": "কী", +"emb.col_used": "শেষ ব্যবহার", +"emb.never": "কখনো নয়", +"emb.any": "যেকোনো", +"emb.hits": "{n}টি অনুরোধ", +"emb.old_until": "পুরোনো কী {t} পর্যন্ত চলবে", +"emb.edit": "সম্পাদনা", +"emb.rotate": "নতুন কী", +"emb.revoke": "প্রত্যাহার", +"emb.rotate_confirm": "“{n}”-কে নতুন কী দেবেন? প্ল্যাটফর্ম বদলানোর জন্য পুরোনোটি আরও ২৪ ঘণ্টা চলবে।", +"emb.revoke_confirm": "“{n}” প্রত্যাহার করবেন? এর লিঙ্ক সঙ্গে সঙ্গে বন্ধ হবে।", +"emb.key_once": "কী শুধু এবারই দেখানো হচ্ছে: এখনই কপি করুন। {VIP}-এর জায়গায় VIP-এর ঠিকানা বসান।", +"emb.link": "লিঙ্ক (যে বোতাম এটি খোলে তার জন্য)", +"emb.frame": "এম্বেড কোড (iframe)", +"emb.copy": "কপি", +"emb.copied": "কপি হয়েছে", +"emb.rate": "প্রতি দর্শক প্রতি মিনিটে সর্বোচ্চ {n}টি অনুরোধ করতে পারেন; বেশি হলে কিছুক্ষণ ফিরিয়ে দেওয়া হয়। পাতা প্রতি মিনিটে নতুন করে আঁকা হয়।", +"emb.f_name": "নাম (যেমন: গ্রাহক A-এর অ্যাসেট প্ল্যাটফর্ম)", +"emb.f_ranges": "যে VIP দেখানো যাবে", +"emb.f_ranges_hint": "প্রতি লাইনে একটি ঠিকানা বা রেঞ্জ, যেমন 203.0.113.0/24। অন্য ঠিকানা ফিরিয়ে দেওয়া হয়।", +"emb.f_origins": "অনুমোদিত সাইট", +"emb.f_origins_hint": "প্রতি লাইনে একটি সাইটের নাম, যেমন assets.example.com; *.example.com সাবডোমেইনসহ; * মানে যেকোনো। শুধু এই সাইটগুলো থেকে খুললে বা এম্বেড করলে পাতা দেখা যায়।", +"emb.f_clients": "দর্শকদের নেটওয়ার্ক (ঐচ্ছিক)", +"emb.f_clients_hint": "প্রতি লাইনে একটি রেঞ্জ। দিলে শুধু এই নেটওয়ার্কের দর্শকরা খুলতে পারবেন।", +"em.sub": "ব্যান্ডউইথ · আগত ও বহির্গামী", +"em.now": "এখন", +"em.peak": "সর্বোচ্চ", +"em.avg": "গড়", +"em.nodata": "এই সময়ে কোনো ট্রাফিক নেই।", +"sl.title": "syslog-এ পাঠান", +"sl.sub": "ফলাফল তৈরি হওয়ামাত্র আপনার SIEM বা লগ প্ল্যাটফর্মে যায় (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "পাঠানো হচ্ছে", +"sl.off": "বন্ধ", +"sl.locked": "syslog-এ পাঠানো নিবন্ধিত ইনস্টলেশনের জন্য। ফলাফল আগের মতোই তৈরি হয় ও এই পাতায় দেখা যায়।", +"sl.enabled": "ফলাফল পাঠান", +"sl.address": "কালেক্টরের ঠিকানা (হোস্ট:পোর্ট)", +"sl.network": "পরিবহন", +"sl.format": "ফরম্যাট", +"sl.fmt_text": "সাধারণ টেক্সট", +"sl.min_sev": "সর্বনিম্ন তীব্রতা", +"sl.base_url": "এই traffic66-এর ঠিকানা (প্রমাণ লিঙ্কের জন্য, ঐচ্ছিক)", +"sl.ascii": "শুধু ASCII (পুরোনো কালেক্টরের জন্য)", +"sl.save": "সংরক্ষণ", +"sl.test": "পরীক্ষা পাঠান", +"sl.saved": "সংরক্ষিত", +"sl.test_ok": "পরীক্ষার বার্তা পাঠানো হয়েছে", +"sl.stats": "{s}টি পাঠানো · {f}টি ব্যর্থ · মিনিটপ্রতি সীমা ছাড়ানোয় {d}টি বাদ", +"sl.last_sent": "শেষ পাঠানো {t}", +"sl.last_err": "শেষ ব্যর্থতা ({t}): {e}", "fd.open": "অমীমাংসিত", "fd.all": "সব", "fd.none": "এই সময়ে কিছু পাওয়া যায়নি।", @@ -543,6 +602,7 @@ "ov.bw_all": "ব্যান্ডউইথ (আগত + বহির্গামী), অ্যাপ্লিকেশন অনুযায়ী", "ov.bw_in": "আগত ব্যান্ডউইথ, অ্যাপ্লিকেশন অনুযায়ী", "ov.bw_out": "বহির্গামী ব্যান্ডউইথ, অ্যাপ্লিকেশন অনুযায়ী", +"ov.dir_empty": "এই সময়ে {if} দিয়ে কোনো {d} ট্রাফিক নেই।", "src.manual": "হাতে ঠিক করা", "src.templates": "পাওয়া টেমপ্লেট (field id; s = scope):", "src.stale": "বন্ধ (ডিভাইস এখন অন্যভাবে পাঠাচ্ছে):", @@ -685,7 +745,9 @@ "ob.copy": "কপি", "nav.help": "সহায়তা", "rk.sub": "ফ্লো পাঠানো প্রতিটি যন্ত্র কোথায় আছে, আর প্রতিটি পোর্ট কোন ক্যারিয়ার ও সার্কিটে যায়", -"rk.hint": "একবার পূরণ করলেই হয়। যন্ত্র ও পোর্ট ফ্লো থেকে আসে; শুধু রুম, র‍্যাক, ক্যারিয়ার ও সার্কিট লিখতে হয়, যা ইনভেন্টরি ফাইলে থাকে।", +"rk.summary": "{r}টি কক্ষ · {k}টি র‍্যাক · {d}টি ডিভাইস · {n}টি পোর্টের {p}টিতে ক্যারিয়ার দেওয়া", +"rk.room_n": "{k}টি র‍্যাক · {d}টি ডিভাইস", +"rk.port": "পোর্ট", "rk.empty": "এখনও কোনো যন্ত্র ফ্লো পাঠায়নি।", "rk.unplaced": "এখনও স্থান ঠিক হয়নি", "rk.no_rack": "র‍্যাক নেই", diff --git a/internal/web/static/i18n/de.json b/internal/web/static/i18n/de.json index 3064fb3..383a4d2 100644 --- a/internal/web/static/i18n/de.json +++ b/internal/web/static/i18n/de.json @@ -276,6 +276,65 @@ "sev.high": "Hoch", "sev.medium": "Mittel", "sev.low": "Niedrig", +"emb.title": "Links für andere Plattformen", +"emb.sub": "Geben Sie einer anderen Plattform, etwa einer Asset-Plattform, einen Link zur Bandbreite einer VIP: ein Knopf neben jeder VIP, mit der Adresse im Link. Er zeigt nur die ein- und ausgehende Bandbreite dieser VIP.", +"emb.locked": "Links für andere Plattformen sind registrierten Installationen vorbehalten. Bestehende Links lassen sich weiter zurückziehen.", +"emb.new": "Neuer Link", +"emb.none": "Noch keine Links.", +"emb.http_warn": "Diese Maschine liefert die Web-Oberfläche über HTTP; um in eine HTTPS-Plattform eingebettet zu werden, braucht sie ebenfalls HTTPS, sonst blockiert der Browser.", +"emb.col_name": "Name", +"emb.col_ranges": "Erlaubte VIPs", +"emb.col_origins": "Erlaubte Websites", +"emb.col_clients": "Netze der Betrachter", +"emb.col_key": "Schlüssel", +"emb.col_used": "Zuletzt genutzt", +"emb.never": "Nie", +"emb.any": "Beliebig", +"emb.hits": "{n} Anfragen", +"emb.old_until": "Alter Schlüssel gilt bis {t}", +"emb.edit": "Bearbeiten", +"emb.rotate": "Neuer Schlüssel", +"emb.revoke": "Zurückziehen", +"emb.rotate_confirm": "„{n}“ einen neuen Schlüssel geben? Der alte gilt noch 24 Stunden, damit die Plattform umstellen kann.", +"emb.revoke_confirm": "„{n}“ zurückziehen? Seine Links funktionieren sofort nicht mehr.", +"emb.key_once": "Der Schlüssel wird nur dieses eine Mal gezeigt: jetzt kopieren. Ersetzen Sie {VIP} durch die Adresse der VIP.", +"emb.link": "Link (für einen Knopf, der ihn öffnet)", +"emb.frame": "Einbettungscode (iframe)", +"emb.copy": "Kopieren", +"emb.copied": "Kopiert", +"emb.rate": "Jeder Betrachter darf höchstens {n} Anfragen pro Minute stellen; mehr werden eine Weile abgewiesen. Die Seite wird jede Minute neu gezeichnet.", +"emb.f_name": "Name (z. B. Asset-Plattform Kunde A)", +"emb.f_ranges": "Erlaubte VIPs", +"emb.f_ranges_hint": "Eine Adresse oder ein Bereich pro Zeile, etwa 203.0.113.0/24. Jede andere Adresse wird abgewiesen.", +"emb.f_origins": "Erlaubte Websites", +"emb.f_origins_hint": "Ein Hostname pro Zeile, etwa assets.example.com; *.example.com umfasst Subdomains; * erlaubt alle. Die Seite erscheint nur, wenn sie von diesen Websites geöffnet oder eingebettet wird.", +"emb.f_clients": "Netze der Betrachter (optional)", +"emb.f_clients_hint": "Ein Bereich pro Zeile. Wenn gesetzt, können nur Betrachter aus diesen Netzen sie öffnen.", +"em.sub": "Bandbreite · eingehend und ausgehend", +"em.now": "Aktuell", +"em.peak": "Spitze", +"em.avg": "Durchschnitt", +"em.nodata": "Kein Verkehr in diesem Zeitraum.", +"sl.title": "An Syslog senden", +"sl.sub": "Befunde gehen beim Entstehen an Ihr SIEM oder Ihre Log-Plattform (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Aktiv", +"sl.off": "Aus", +"sl.locked": "Das Senden an Syslog ist registrierten Installationen vorbehalten. Befunde werden weiter erstellt und hier angezeigt.", +"sl.enabled": "Befunde senden", +"sl.address": "Collector-Adresse (Host:Port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Klartext", +"sl.min_sev": "Mindestschwere", +"sl.base_url": "Adresse dieses traffic66 (für den Beleg-Link, optional)", +"sl.ascii": "Nur ASCII (für ältere Collector)", +"sl.save": "Speichern", +"sl.test": "Test senden", +"sl.saved": "Gespeichert", +"sl.test_ok": "Testnachricht gesendet", +"sl.stats": "{s} gesendet · {f} fehlgeschlagen · {d} über dem Minutenlimit verworfen", +"sl.last_sent": "zuletzt gesendet {t}", +"sl.last_err": "Letzter Fehler ({t}): {e}", "fd.open": "Offen", "fd.all": "Alle", "fd.none": "In diesem Zeitraum wurde nichts gefunden.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bandbreite (ein + aus) nach Anwendung", "ov.bw_in": "Eingehende Bandbreite nach Anwendung", "ov.bw_out": "Ausgehende Bandbreite nach Anwendung", +"ov.dir_empty": "Kein Verkehr ({d}) über {if} in diesem Zeitraum.", "src.manual": "manuell gesetzt", "src.templates": "Empfangene Templates (Feld-IDs; s = Scope):", "src.stale": "Gestoppt (das Gerät sendet jetzt anders):", @@ -685,7 +745,9 @@ "ob.copy": "Kopieren", "nav.help": "Hilfe", "rk.sub": "Wo jedes Gerät steht, das Flows sendet, und zu welchem Anbieter und welcher Leitung jeder Port führt", -"rk.hint": "Einmal ausfüllen genügt. Geräte und Ports kommen aus den Flows; eingetragen werden nur Raum, Rack, Anbieter und Leitung, gespeichert in der Inventardatei.", +"rk.summary": "{r} Räume · {k} Racks · {d} Geräte · Provider bei {p} von {n} Ports", +"rk.room_n": "{k} Racks · {d} Geräte", +"rk.port": "Port", "rk.empty": "Noch hat kein Gerät Flows gesendet.", "rk.unplaced": "Noch nicht zugeordnet", "rk.no_rack": "Ohne Rack", diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index a02d94d..0a87fcf 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -276,6 +276,65 @@ "sev.high": "High", "sev.medium": "Medium", "sev.low": "Low", +"emb.title": "Links for other platforms", +"emb.sub": "Give another platform, such as an asset platform, a link to one VIP's bandwidth: a button beside each VIP, with the VIP's address put into the link. It shows that VIP's inbound and outbound bandwidth and nothing else.", +"emb.locked": "Links for other platforms are for registered installations. Existing links can still be withdrawn.", +"emb.new": "New link", +"emb.none": "No links yet.", +"emb.http_warn": "This machine serves the web UI over HTTP; to be framed in an HTTPS platform it needs HTTPS too, or the browser blocks it.", +"emb.col_name": "Name", +"emb.col_ranges": "VIPs it may show", +"emb.col_origins": "Sites allowed", +"emb.col_clients": "Viewer networks", +"emb.col_key": "Key", +"emb.col_used": "Last used", +"emb.never": "Never", +"emb.any": "Any", +"emb.hits": "{n} requests", +"emb.old_until": "Old key works until {t}", +"emb.edit": "Edit", +"emb.rotate": "New key", +"emb.revoke": "Withdraw", +"emb.rotate_confirm": "Give “{n}” a new key? The old one keeps working for 24 hours while the platform changes over.", +"emb.revoke_confirm": "Withdraw “{n}”? Its links stop working at once.", +"emb.key_once": "The key is shown this once: copy it now. Put the VIP's address in place of {VIP}.", +"emb.link": "Link (for a button that opens it)", +"emb.frame": "Embed code (iframe)", +"emb.copy": "Copy", +"emb.copied": "Copied", +"emb.rate": "Each viewer may make at most {n} requests a minute; more are refused for a while. The page redraws every minute.", +"emb.f_name": "Name (for example: Customer A asset platform)", +"emb.f_ranges": "VIPs it may show", +"emb.f_ranges_hint": "One address or range per line, such as 203.0.113.0/24. Any other address is refused.", +"emb.f_origins": "Sites allowed", +"emb.f_origins_hint": "One site name per line, such as assets.example.com; *.example.com covers its subdomains; * allows any. The page shows only when opened or framed from these sites.", +"emb.f_clients": "Viewer networks (optional)", +"emb.f_clients_hint": "One range per line. When set, only viewers on these networks can open it.", +"em.sub": "Bandwidth · in and out", +"em.now": "Now", +"em.peak": "Peak", +"em.avg": "Average", +"em.nodata": "No traffic in this period.", +"sl.title": "Send to syslog", +"sl.sub": "Findings go to your SIEM or log platform as they are made (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Sending", +"sl.off": "Off", +"sl.locked": "Sending to syslog is for registered installations. Findings are still made and shown on this page.", +"sl.enabled": "Send findings", +"sl.address": "Collector address (host:port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Plain text", +"sl.min_sev": "Least severity", +"sl.base_url": "Address of this traffic66 (for the evidence link, optional)", +"sl.ascii": "ASCII only (for older collectors)", +"sl.save": "Save", +"sl.test": "Send a test", +"sl.saved": "Saved", +"sl.test_ok": "Test message sent", +"sl.stats": "{s} sent · {f} failed · {d} dropped over the per-minute ceiling", +"sl.last_sent": "last sent {t}", +"sl.last_err": "Last failure ({t}): {e}", "fd.open": "Open", "fd.all": "All", "fd.none": "Nothing was found in this time range.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bandwidth (in + out), by application", "ov.bw_in": "Inbound bandwidth, by application", "ov.bw_out": "Outbound bandwidth, by application", +"ov.dir_empty": "No {d} traffic through {if} in this period.", "src.manual": "set by hand", "src.templates": "Templates received (field ids; s = scope):", "src.stale": "Stopped (the device now sends otherwise):", @@ -685,7 +745,9 @@ "ob.copy": "Copy", "nav.help": "Help", "rk.sub": "Where each device that sends flows stands, and which carrier and circuit each port goes out to", -"rk.hint": "Fill this in once. Devices and ports come from the flows; only the room, rack, carrier and circuit are typed in, and they are kept in the inventory file.", +"rk.summary": "{r} rooms · {k} racks · {d} devices · carrier set on {p} of {n} ports", +"rk.room_n": "{k} racks · {d} devices", +"rk.port": "Port", "rk.empty": "No device has sent flows yet.", "rk.unplaced": "Not placed yet", "rk.no_rack": "No rack", diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index e3cd217..cfb4d9f 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -276,6 +276,65 @@ "sev.high": "Alta", "sev.medium": "Media", "sev.low": "Baja", +"emb.title": "Enlaces para otras plataformas", +"emb.sub": "Dé a otra plataforma, como una de activos, un enlace al ancho de banda de una VIP: un botón junto a cada VIP, con su dirección en el enlace. Solo muestra el ancho de banda entrante y saliente de esa VIP.", +"emb.locked": "Los enlaces para otras plataformas son para instalaciones registradas. Los existentes se pueden seguir retirando.", +"emb.new": "Nuevo enlace", +"emb.none": "Aún no hay enlaces.", +"emb.http_warn": "Esta máquina sirve la interfaz por HTTP; para integrarse en una plataforma HTTPS también necesita HTTPS, o el navegador la bloquea.", +"emb.col_name": "Nombre", +"emb.col_ranges": "VIP que puede mostrar", +"emb.col_origins": "Sitios permitidos", +"emb.col_clients": "Redes de los visitantes", +"emb.col_key": "Clave", +"emb.col_used": "Último uso", +"emb.never": "Nunca", +"emb.any": "Cualquiera", +"emb.hits": "{n} solicitudes", +"emb.old_until": "La clave anterior vale hasta {t}", +"emb.edit": "Editar", +"emb.rotate": "Nueva clave", +"emb.revoke": "Retirar", +"emb.rotate_confirm": "¿Dar a «{n}» una clave nueva? La anterior sigue valiendo 24 horas mientras la plataforma cambia.", +"emb.revoke_confirm": "¿Retirar «{n}»? Sus enlaces dejan de funcionar al instante.", +"emb.key_once": "La clave solo se muestra esta vez: cópiela ahora. Ponga la dirección de la VIP en lugar de {VIP}.", +"emb.link": "Enlace (para un botón que lo abre)", +"emb.frame": "Código para incrustar (iframe)", +"emb.copy": "Copiar", +"emb.copied": "Copiado", +"emb.rate": "Cada visitante puede hacer como máximo {n} solicitudes por minuto; las demás se rechazan un rato. La página se redibuja cada minuto.", +"emb.f_name": "Nombre (por ejemplo: plataforma de activos del cliente A)", +"emb.f_ranges": "VIP que puede mostrar", +"emb.f_ranges_hint": "Una dirección o rango por línea, como 203.0.113.0/24. Cualquier otra dirección se rechaza.", +"emb.f_origins": "Sitios permitidos", +"emb.f_origins_hint": "Un nombre de sitio por línea, como assets.example.com; *.example.com incluye subdominios; * permite cualquiera. La página solo se muestra si se abre o incrusta desde estos sitios.", +"emb.f_clients": "Redes de los visitantes (opcional)", +"emb.f_clients_hint": "Un rango por línea. Si se indica, solo los visitantes de esas redes pueden abrirla.", +"em.sub": "Ancho de banda · entrante y saliente", +"em.now": "Actual", +"em.peak": "Pico", +"em.avg": "Media", +"em.nodata": "Sin tráfico en este periodo.", +"sl.title": "Enviar a syslog", +"sl.sub": "Los hallazgos van a su SIEM o plataforma de registros en cuanto se generan (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Enviando", +"sl.off": "Desactivado", +"sl.locked": "El envío a syslog es para instalaciones registradas. Los hallazgos se siguen generando y se muestran en esta página.", +"sl.enabled": "Enviar hallazgos", +"sl.address": "Dirección del colector (host:puerto)", +"sl.network": "Transporte", +"sl.format": "Formato", +"sl.fmt_text": "Texto plano", +"sl.min_sev": "Gravedad mínima", +"sl.base_url": "Dirección de este traffic66 (para el enlace de evidencia, opcional)", +"sl.ascii": "Solo ASCII (colectores antiguos)", +"sl.save": "Guardar", +"sl.test": "Enviar una prueba", +"sl.saved": "Guardado", +"sl.test_ok": "Mensaje de prueba enviado", +"sl.stats": "{s} enviados · {f} fallidos · {d} descartados por el límite por minuto", +"sl.last_sent": "último envío {t}", +"sl.last_err": "Último fallo ({t}): {e}", "fd.open": "Pendientes", "fd.all": "Todos", "fd.none": "No se encontró nada en este periodo.", @@ -543,6 +602,7 @@ "ov.bw_all": "Ancho de banda (entrada + salida), por aplicación", "ov.bw_in": "Ancho de banda entrante, por aplicación", "ov.bw_out": "Ancho de banda saliente, por aplicación", +"ov.dir_empty": "Sin tráfico ({d}) por {if} en este periodo.", "src.manual": "fijado a mano", "src.templates": "Plantillas recibidas (ids de campo; s = ámbito):", "src.stale": "Detenidos (el equipo ahora envía de otra forma):", @@ -685,7 +745,9 @@ "ob.copy": "Copiar", "nav.help": "Ayuda", "rk.sub": "Dónde está cada equipo que envía flujos y a qué operador y circuito sale cada puerto", -"rk.hint": "Basta con rellenarlo una vez. Equipos y puertos salen de los flujos; solo se escriben sala, rack, operador y circuito, y se guardan en el archivo de inventario.", +"rk.summary": "{r} salas · {k} racks · {d} equipos · operador indicado en {p} de {n} puertos", +"rk.room_n": "{k} racks · {d} equipos", +"rk.port": "Puerto", "rk.empty": "Ningún equipo ha enviado flujos todavía.", "rk.unplaced": "Sin ubicar", "rk.no_rack": "Sin rack", diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index c5c4466..23e468c 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -276,6 +276,65 @@ "sev.high": "Élevée", "sev.medium": "Moyenne", "sev.low": "Faible", +"emb.title": "Liens pour d’autres plateformes", +"emb.sub": "Donnez à une autre plateforme, par exemple une plateforme d’actifs, un lien vers la bande passante d’une VIP : un bouton à côté de chaque VIP, avec son adresse dans le lien. Il ne montre que la bande passante entrante et sortante de cette VIP.", +"emb.locked": "Les liens pour d’autres plateformes sont réservés aux installations enregistrées. Les liens existants peuvent toujours être retirés.", +"emb.new": "Nouveau lien", +"emb.none": "Aucun lien pour l’instant.", +"emb.http_warn": "Cette machine sert l’interface en HTTP ; pour être intégrée dans une plateforme HTTPS, elle doit aussi passer en HTTPS, sinon le navigateur bloque.", +"emb.col_name": "Nom", +"emb.col_ranges": "VIP visibles", +"emb.col_origins": "Sites autorisés", +"emb.col_clients": "Réseaux des visiteurs", +"emb.col_key": "Clé", +"emb.col_used": "Dernière utilisation", +"emb.never": "Jamais", +"emb.any": "Tous", +"emb.hits": "{n} requêtes", +"emb.old_until": "Ancienne clé valable jusqu’au {t}", +"emb.edit": "Modifier", +"emb.rotate": "Nouvelle clé", +"emb.revoke": "Retirer", +"emb.rotate_confirm": "Donner une nouvelle clé à « {n} » ? L’ancienne reste valable 24 heures, le temps que la plateforme bascule.", +"emb.revoke_confirm": "Retirer « {n} » ? Ses liens cessent de fonctionner immédiatement.", +"emb.key_once": "La clé n’est affichée qu’une fois : copiez-la maintenant. Remplacez {VIP} par l’adresse de la VIP.", +"emb.link": "Lien (pour un bouton qui l’ouvre)", +"emb.frame": "Code d’intégration (iframe)", +"emb.copy": "Copier", +"emb.copied": "Copié", +"emb.rate": "Chaque visiteur peut faire au plus {n} requêtes par minute ; au-delà, elles sont refusées un moment. La page se redessine chaque minute.", +"emb.f_name": "Nom (par exemple : plateforme d’actifs du client A)", +"emb.f_ranges": "VIP visibles", +"emb.f_ranges_hint": "Une adresse ou une plage par ligne, comme 203.0.113.0/24. Toute autre adresse est refusée.", +"emb.f_origins": "Sites autorisés", +"emb.f_origins_hint": "Un nom de site par ligne, comme assets.example.com ; *.example.com couvre ses sous-domaines ; * autorise tout. La page ne s’affiche que si elle est ouverte ou intégrée depuis ces sites.", +"emb.f_clients": "Réseaux des visiteurs (facultatif)", +"emb.f_clients_hint": "Une plage par ligne. Si renseigné, seuls les visiteurs de ces réseaux peuvent l’ouvrir.", +"em.sub": "Bande passante · entrante et sortante", +"em.now": "Actuel", +"em.peak": "Pic", +"em.avg": "Moyenne", +"em.nodata": "Aucun trafic sur cette période.", +"sl.title": "Envoyer vers syslog", +"sl.sub": "Les constats partent vers votre SIEM ou plateforme de journaux dès leur création (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Actif", +"sl.off": "Désactivé", +"sl.locked": "L’envoi vers syslog est réservé aux installations enregistrées. Les constats sont toujours produits et affichés ici.", +"sl.enabled": "Envoyer les constats", +"sl.address": "Adresse du collecteur (hôte:port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Texte brut", +"sl.min_sev": "Gravité minimale", +"sl.base_url": "Adresse de ce traffic66 (pour le lien de preuve, facultatif)", +"sl.ascii": "ASCII uniquement (anciens collecteurs)", +"sl.save": "Enregistrer", +"sl.test": "Envoyer un test", +"sl.saved": "Enregistré", +"sl.test_ok": "Message de test envoyé", +"sl.stats": "{s} envoyés · {f} en échec · {d} écartés au-delà du plafond par minute", +"sl.last_sent": "dernier envoi {t}", +"sl.last_err": "Dernier échec ({t}) : {e}", "fd.open": "À traiter", "fd.all": "Toutes", "fd.none": "Rien n'a été détecté sur cette période.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bande passante (entrant + sortant), par application", "ov.bw_in": "Bande passante entrante, par application", "ov.bw_out": "Bande passante sortante, par application", +"ov.dir_empty": "Aucun trafic ({d}) via {if} sur cette période.", "src.manual": "fixé à la main", "src.templates": "Modèles reçus (ids de champ ; s = portée) :", "src.stale": "Arrêtés (l’équipement envoie désormais autrement) :", @@ -685,7 +745,9 @@ "ob.copy": "Copier", "nav.help": "Aide", "rk.sub": "Où se trouve chaque équipement qui envoie des flux, et vers quel opérateur et quel circuit part chaque port", -"rk.hint": "À remplir une seule fois. Équipements et ports viennent des flux ; seuls la salle, la baie, l’opérateur et le circuit sont saisis, et ils sont gardés dans le fichier d’inventaire.", +"rk.summary": "{r} salles · {k} baies · {d} équipements · opérateur saisi sur {p} ports sur {n}", +"rk.room_n": "{k} baies · {d} équipements", +"rk.port": "Port", "rk.empty": "Aucun équipement n’a encore envoyé de flux.", "rk.unplaced": "Pas encore placé", "rk.no_rack": "Sans baie", diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index d628518..2b252f0 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -276,6 +276,65 @@ "sev.high": "उच्च", "sev.medium": "मध्यम", "sev.low": "निम्न", +"emb.title": "दूसरे प्लैटफ़ॉर्म के लिए लिंक", +"emb.sub": "किसी दूसरे प्लैटफ़ॉर्म, जैसे एसेट प्लैटफ़ॉर्म, को एक VIP की बैंडविड्थ का लिंक दें: हर VIP के बगल में एक बटन, लिंक में VIP का पता डालकर। इसमें सिर्फ़ उस VIP की आने और जाने वाली बैंडविड्थ दिखती है।", +"emb.locked": "दूसरे प्लैटफ़ॉर्म के लिंक पंजीकृत इंस्टॉलेशन के लिए हैं। मौजूदा लिंक अब भी वापस लिए जा सकते हैं।", +"emb.new": "नया लिंक", +"emb.none": "अभी कोई लिंक नहीं।", +"emb.http_warn": "यह मशीन वेब UI HTTP पर देती है; HTTPS प्लैटफ़ॉर्म में एम्बेड करने के लिए यहाँ भी HTTPS चाहिए, नहीं तो ब्राउज़र रोक देगा।", +"emb.col_name": "नाम", +"emb.col_ranges": "कौन से VIP दिखें", +"emb.col_origins": "अनुमत साइटें", +"emb.col_clients": "दर्शकों के नेटवर्क", +"emb.col_key": "कुंजी", +"emb.col_used": "आख़िरी उपयोग", +"emb.never": "कभी नहीं", +"emb.any": "कोई भी", +"emb.hits": "{n} अनुरोध", +"emb.old_until": "पुरानी कुंजी {t} तक चलेगी", +"emb.edit": "बदलें", +"emb.rotate": "नई कुंजी", +"emb.revoke": "वापस लें", +"emb.rotate_confirm": "“{n}” को नई कुंजी दें? प्लैटफ़ॉर्म के बदलने तक पुरानी 24 घंटे और चलेगी।", +"emb.revoke_confirm": "“{n}” वापस लें? इसके लिंक तुरंत बंद हो जाएँगे।", +"emb.key_once": "कुंजी सिर्फ़ इस बार दिख रही है: अभी कॉपी करें। {VIP} की जगह VIP का पता डालें।", +"emb.link": "लिंक (उसे खोलने वाले बटन के लिए)", +"emb.frame": "एम्बेड कोड (iframe)", +"emb.copy": "कॉपी", +"emb.copied": "कॉपी हुआ", +"emb.rate": "हर दर्शक प्रति मिनट अधिकतम {n} अनुरोध कर सकता है; ज़्यादा होने पर कुछ देर मना किया जाता है। पेज हर मिनट दोबारा बनता है।", +"emb.f_name": "नाम (जैसे: ग्राहक A का एसेट प्लैटफ़ॉर्म)", +"emb.f_ranges": "कौन से VIP दिखें", +"emb.f_ranges_hint": "हर पंक्ति में एक पता या रेंज, जैसे 203.0.113.0/24। बाकी पते मना होते हैं।", +"emb.f_origins": "अनुमत साइटें", +"emb.f_origins_hint": "हर पंक्ति में एक साइट नाम, जैसे assets.example.com; *.example.com में सबडोमेन शामिल; * यानी कोई भी। पेज तभी दिखता है जब इन साइटों से खोला या एम्बेड किया जाए।", +"emb.f_clients": "दर्शकों के नेटवर्क (वैकल्पिक)", +"emb.f_clients_hint": "हर पंक्ति में एक रेंज। भरने पर सिर्फ़ इन नेटवर्क के दर्शक इसे खोल सकेंगे।", +"em.sub": "बैंडविड्थ · अंदर और बाहर", +"em.now": "अभी", +"em.peak": "शिखर", +"em.avg": "औसत", +"em.nodata": "इस अवधि में कोई ट्रैफ़िक नहीं।", +"sl.title": "syslog पर भेजें", +"sl.sub": "नतीजे बनते ही आपके SIEM या लॉग प्लैटफ़ॉर्म पर जाते हैं (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "भेजा जा रहा है", +"sl.off": "बंद", +"sl.locked": "syslog पर भेजना पंजीकृत इंस्टॉलेशन के लिए है। नतीजे पहले की तरह बनते हैं और इस पेज पर दिखते हैं।", +"sl.enabled": "नतीजे भेजें", +"sl.address": "कलेक्टर का पता (होस्ट:पोर्ट)", +"sl.network": "ट्रांसपोर्ट", +"sl.format": "फ़ॉर्मैट", +"sl.fmt_text": "सादा टेक्स्ट", +"sl.min_sev": "न्यूनतम गंभीरता", +"sl.base_url": "इस traffic66 का पता (सबूत लिंक के लिए, वैकल्पिक)", +"sl.ascii": "सिर्फ़ ASCII (पुराने कलेक्टरों के लिए)", +"sl.save": "सहेजें", +"sl.test": "टेस्ट भेजें", +"sl.saved": "सहेजा गया", +"sl.test_ok": "टेस्ट संदेश भेजा गया", +"sl.stats": "{s} भेजे · {f} विफल · प्रति मिनट सीमा से ऊपर {d} छोड़े", +"sl.last_sent": "आख़िरी बार भेजा {t}", +"sl.last_err": "आख़िरी विफलता ({t}): {e}", "fd.open": "लंबित", "fd.all": "सभी", "fd.none": "इस अवधि में कुछ नहीं मिला।", @@ -543,6 +602,7 @@ "ov.bw_all": "बैंडविड्थ (आने + जाने वाला), ऐप्लिकेशन के अनुसार", "ov.bw_in": "आने वाली बैंडविड्थ, ऐप्लिकेशन के अनुसार", "ov.bw_out": "जाने वाली बैंडविड्थ, ऐप्लिकेशन के अनुसार", +"ov.dir_empty": "इस अवधि में {if} से कोई {d} ट्रैफ़िक नहीं।", "src.manual": "हाथ से सेट", "src.templates": "मिले टेम्पलेट (field id; s = scope):", "src.stale": "बंद (डिवाइस अब दूसरे तरीके से भेज रहा है):", @@ -685,7 +745,9 @@ "ob.copy": "कॉपी करें", "nav.help": "सहायता", "rk.sub": "फ़्लो भेजने वाला हर डिवाइस कहाँ लगा है, और हर पोर्ट किस कैरियर और सर्किट से जुड़ा है", -"rk.hint": "इसे एक बार भरें। डिवाइस और पोर्ट फ़्लो से आते हैं; केवल कमरा, रैक, कैरियर और सर्किट लिखने होते हैं, जो इन्वेंटरी फ़ाइल में रहते हैं।", +"rk.summary": "{r} कक्ष · {k} रैक · {d} डिवाइस · {n} में से {p} पोर्ट पर कैरियर भरा", +"rk.room_n": "{k} रैक · {d} डिवाइस", +"rk.port": "पोर्ट", "rk.empty": "अभी तक किसी डिवाइस ने फ़्लो नहीं भेजा है।", "rk.unplaced": "अभी स्थान तय नहीं", "rk.no_rack": "कोई रैक नहीं", diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index 1bbe015..bc77b01 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -276,6 +276,65 @@ "sev.high": "Tinggi", "sev.medium": "Sedang", "sev.low": "Rendah", +"emb.title": "Tautan untuk platform lain", +"emb.sub": "Beri platform lain, misalnya platform aset, tautan ke bandwidth satu VIP: tombol di samping setiap VIP, dengan alamat VIP dimasukkan ke tautan. Hanya bandwidth masuk dan keluar VIP itu yang terlihat.", +"emb.locked": "Tautan untuk platform lain adalah fitur instalasi terdaftar. Tautan yang ada masih bisa dicabut.", +"emb.new": "Tautan baru", +"emb.none": "Belum ada tautan.", +"emb.http_warn": "Mesin ini menyajikan UI web lewat HTTP; agar bisa disematkan di platform HTTPS, mesin ini juga perlu HTTPS, atau browser akan memblokirnya.", +"emb.col_name": "Nama", +"emb.col_ranges": "VIP yang boleh ditampilkan", +"emb.col_origins": "Situs yang diizinkan", +"emb.col_clients": "Jaringan penonton", +"emb.col_key": "Kunci", +"emb.col_used": "Terakhir dipakai", +"emb.never": "Belum pernah", +"emb.any": "Bebas", +"emb.hits": "{n} permintaan", +"emb.old_until": "Kunci lama berlaku sampai {t}", +"emb.edit": "Ubah", +"emb.rotate": "Kunci baru", +"emb.revoke": "Cabut", +"emb.rotate_confirm": "Beri “{n}” kunci baru? Kunci lama tetap berlaku 24 jam selama platform beralih.", +"emb.revoke_confirm": "Cabut “{n}”? Tautannya langsung berhenti bekerja.", +"emb.key_once": "Kunci hanya ditampilkan kali ini: salin sekarang. Ganti {VIP} dengan alamat VIP.", +"emb.link": "Tautan (untuk tombol yang membukanya)", +"emb.frame": "Kode sematan (iframe)", +"emb.copy": "Salin", +"emb.copied": "Tersalin", +"emb.rate": "Setiap penonton paling banyak {n} permintaan per menit; selebihnya ditolak sementara. Halaman digambar ulang setiap menit.", +"emb.f_name": "Nama (misalnya: platform aset pelanggan A)", +"emb.f_ranges": "VIP yang boleh ditampilkan", +"emb.f_ranges_hint": "Satu alamat atau rentang per baris, misalnya 203.0.113.0/24. Alamat lain ditolak.", +"emb.f_origins": "Situs yang diizinkan", +"emb.f_origins_hint": "Satu nama situs per baris, misalnya assets.example.com; *.example.com mencakup subdomain; * berarti bebas. Halaman hanya tampil bila dibuka atau disematkan dari situs ini.", +"emb.f_clients": "Jaringan penonton (opsional)", +"emb.f_clients_hint": "Satu rentang per baris. Bila diisi, hanya penonton dari jaringan ini yang bisa membukanya.", +"em.sub": "Bandwidth · masuk dan keluar", +"em.now": "Sekarang", +"em.peak": "Puncak", +"em.avg": "Rata-rata", +"em.nodata": "Tidak ada lalu lintas pada periode ini.", +"sl.title": "Kirim ke syslog", +"sl.sub": "Temuan dikirim ke SIEM atau platform log Anda begitu dibuat (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Mengirim", +"sl.off": "Mati", +"sl.locked": "Mengirim ke syslog adalah fitur instalasi terdaftar. Temuan tetap dibuat dan ditampilkan di halaman ini.", +"sl.enabled": "Kirim temuan", +"sl.address": "Alamat kolektor (host:port)", +"sl.network": "Transport", +"sl.format": "Format", +"sl.fmt_text": "Teks biasa", +"sl.min_sev": "Tingkat keparahan minimum", +"sl.base_url": "Alamat traffic66 ini (untuk tautan bukti, opsional)", +"sl.ascii": "Hanya ASCII (untuk kolektor lama)", +"sl.save": "Simpan", +"sl.test": "Kirim uji", +"sl.saved": "Tersimpan", +"sl.test_ok": "Pesan uji terkirim", +"sl.stats": "{s} terkirim · {f} gagal · {d} dibuang melewati batas per menit", +"sl.last_sent": "terakhir dikirim {t}", +"sl.last_err": "Kegagalan terakhir ({t}): {e}", "fd.open": "Belum ditangani", "fd.all": "Semua", "fd.none": "Tidak ada yang ditemukan dalam rentang waktu ini.", @@ -543,6 +602,7 @@ "ov.bw_all": "Bandwidth (masuk + keluar), per aplikasi", "ov.bw_in": "Bandwidth masuk, per aplikasi", "ov.bw_out": "Bandwidth keluar, per aplikasi", +"ov.dir_empty": "Tidak ada lalu lintas {d} melalui {if} pada periode ini.", "src.manual": "diatur manual", "src.templates": "Template yang diterima (id field; s = scope):", "src.stale": "Berhenti (perangkat kini mengirim dengan cara lain):", @@ -685,7 +745,9 @@ "ob.copy": "Salin", "nav.help": "Bantuan", "rk.sub": "Di mana setiap perangkat yang mengirim flow berada, dan ke operator serta sirkuit mana setiap port keluar", -"rk.hint": "Cukup diisi sekali. Perangkat dan port berasal dari flow; hanya ruang, rak, operator, dan sirkuit yang diketik, disimpan di berkas inventaris.", +"rk.summary": "{r} ruang · {k} rak · {d} perangkat · operator diisi pada {p} dari {n} port", +"rk.room_n": "{k} rak · {d} perangkat", +"rk.port": "Port", "rk.empty": "Belum ada perangkat yang mengirim flow.", "rk.unplaced": "Belum ditempatkan", "rk.no_rack": "Tanpa rak", diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index 0c0df1a..73c9a81 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -276,6 +276,65 @@ "sev.high": "高", "sev.medium": "中", "sev.low": "低", +"emb.title": "外部プラットフォーム用リンク", +"emb.sub": "資産管理プラットフォームなどに、VIP ごとの帯域を表示するリンクを渡します。各 VIP の横にボタンを置き、リンクに VIP のアドレスを入れるだけです。表示されるのはその VIP の受信・送信帯域だけです。", +"emb.locked": "外部プラットフォーム用リンクは登録版の機能です。既存のリンクは無効化できます。", +"emb.new": "新しいリンク", +"emb.none": "リンクはまだありません。", +"emb.http_warn": "このマシンは HTTP で Web UI を提供しています。HTTPS のプラットフォームに埋め込むには、こちらも HTTPS が必要です。", +"emb.col_name": "名前", +"emb.col_ranges": "表示できる VIP", +"emb.col_origins": "許可するサイト", +"emb.col_clients": "閲覧者のネットワーク", +"emb.col_key": "キー", +"emb.col_used": "最終利用", +"emb.never": "未使用", +"emb.any": "制限なし", +"emb.hits": "{n} 回", +"emb.old_until": "旧キーは {t} まで有効", +"emb.edit": "編集", +"emb.rotate": "キーを更新", +"emb.revoke": "無効化", +"emb.rotate_confirm": "「{n}」に新しいキーを発行しますか?旧キーは切り替えのため 24 時間有効です。", +"emb.revoke_confirm": "「{n}」を無効化しますか?リンクはすぐに使えなくなります。", +"emb.key_once": "キーは今回だけ表示されます。今コピーしてください。{VIP} を VIP のアドレスに置き換えます。", +"emb.link": "リンク(ボタンから開く)", +"emb.frame": "埋め込みコード(iframe)", +"emb.copy": "コピー", +"emb.copied": "コピーしました", +"emb.rate": "閲覧者ごとに毎分最大 {n} リクエスト。超えると一時的に拒否します。ページは毎分更新されます。", +"emb.f_name": "名前(例:顧客 A 資産プラットフォーム)", +"emb.f_ranges": "表示できる VIP", +"emb.f_ranges_hint": "1 行に 1 つのアドレスまたは範囲(例:203.0.113.0/24)。範囲外は拒否します。", +"emb.f_origins": "許可するサイト", +"emb.f_origins_hint": "1 行に 1 つのドメイン(例:assets.example.com)。*.example.com はサブドメインを含み、* は制限なし。これらのサイトから開く・埋め込む場合のみ表示します。", +"emb.f_clients": "閲覧者のネットワーク(任意)", +"emb.f_clients_hint": "1 行に 1 つの範囲。設定すると、そのネットワークの閲覧者だけが開けます。", +"em.sub": "帯域 · 受信と送信", +"em.now": "現在", +"em.peak": "ピーク", +"em.avg": "平均", +"em.nodata": "この期間のトラフィックはありません。", +"sl.title": "syslog へ送信", +"sl.sub": "検出結果を生成と同時に SIEM やログ基盤へ送ります(Splunk、QRadar、ArcSight、Graylog、Elastic)", +"sl.on": "送信中", +"sl.off": "オフ", +"sl.locked": "syslog への送信は登録版の機能です。検出結果は引き続き生成され、このページに表示されます。", +"sl.enabled": "送信する", +"sl.address": "コレクターのアドレス(ホスト:ポート)", +"sl.network": "転送方式", +"sl.format": "形式", +"sl.fmt_text": "プレーンテキスト", +"sl.min_sev": "最低重大度", +"sl.base_url": "この traffic66 のアドレス(証拠リンク用、省略可)", +"sl.ascii": "ASCII のみ(古いコレクター向け)", +"sl.save": "保存", +"sl.test": "テスト送信", +"sl.saved": "保存しました", +"sl.test_ok": "テストメッセージを送信しました", +"sl.stats": "送信 {s} 件 · 失敗 {f} 件 · 毎分上限超過で破棄 {d} 件", +"sl.last_sent": "最終送信 {t}", +"sl.last_err": "最後の失敗({t}):{e}", "fd.open": "未対応", "fd.all": "すべて", "fd.none": "この期間には何も検知されていません。", @@ -543,6 +602,7 @@ "ov.bw_all": "帯域(受信 + 送信)、アプリ別", "ov.bw_in": "受信帯域、アプリ別", "ov.bw_out": "送信帯域、アプリ別", +"ov.dir_empty": "この期間、{if} の{d}トラフィックはありません。", "src.manual": "手動で指定", "src.templates": "受信したテンプレート(フィールド番号、s はスコープ):", "src.stale": "停止(同じ機器が別の形式で送信中):", @@ -685,7 +745,9 @@ "ob.copy": "コピー", "nav.help": "ヘルプ", "rk.sub": "フローを送る各機器の設置場所と、各ポートがつながる回線事業者と回線", -"rk.hint": "一度入力すれば済みます。機器とポートはフローから自動で並び、入力するのは部屋・ラック・回線事業者・回線 ID だけで、台帳ファイルに保存されます。", +"rk.summary": "{r} 室 · {k} ラック · {d} 台 · {n} ポート中 {p} に回線事業者あり", +"rk.room_n": "{k} ラック · {d} 台", +"rk.port": "ポート", "rk.empty": "まだフローを送った機器はありません。", "rk.unplaced": "設置場所未設定", "rk.no_rack": "ラック未設定", diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index f6ec24d..7248d61 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -276,6 +276,65 @@ "sev.high": "높음", "sev.medium": "중간", "sev.low": "낮음", +"emb.title": "외부 플랫폼용 링크", +"emb.sub": "자산 관리 플랫폼 등에 VIP 하나의 대역폭을 보여 주는 링크를 줍니다. 각 VIP 옆에 버튼을 두고 링크에 VIP 주소만 넣으면 됩니다. 그 VIP의 인바운드·아웃바운드 대역폭만 보입니다.", +"emb.locked": "외부 플랫폼용 링크는 등록판 기능입니다. 기존 링크는 계속 폐기할 수 있습니다.", +"emb.new": "새 링크", +"emb.none": "아직 링크가 없습니다.", +"emb.http_warn": "이 장비는 HTTP로 웹 UI를 제공합니다. HTTPS 플랫폼에 넣으려면 여기도 HTTPS가 필요합니다.", +"emb.col_name": "이름", +"emb.col_ranges": "볼 수 있는 VIP", +"emb.col_origins": "허용 사이트", +"emb.col_clients": "접속자 네트워크", +"emb.col_key": "키", +"emb.col_used": "마지막 사용", +"emb.never": "사용 안 함", +"emb.any": "제한 없음", +"emb.hits": "{n}회", +"emb.old_until": "이전 키 유효 기한 {t}", +"emb.edit": "수정", +"emb.rotate": "키 교체", +"emb.revoke": "폐기", +"emb.rotate_confirm": "‘{n}’의 키를 새로 발급할까요? 이전 키는 전환을 위해 24시간 더 쓸 수 있습니다.", +"emb.revoke_confirm": "‘{n}’을(를) 폐기할까요? 링크가 즉시 동작하지 않습니다.", +"emb.key_once": "키는 이번에만 보입니다. 지금 복사하세요. {VIP} 자리에 VIP 주소를 넣으면 됩니다.", +"emb.link": "링크 (버튼으로 열기)", +"emb.frame": "삽입 코드 (iframe)", +"emb.copy": "복사", +"emb.copied": "복사했습니다", +"emb.rate": "접속자마다 분당 최대 {n}회 요청할 수 있으며, 넘으면 잠시 거부합니다. 페이지는 1분마다 갱신됩니다.", +"emb.f_name": "이름 (예: 고객 A 자산 플랫폼)", +"emb.f_ranges": "볼 수 있는 VIP", +"emb.f_ranges_hint": "한 줄에 주소나 대역 하나 (예: 203.0.113.0/24). 그 밖의 주소는 거부합니다.", +"emb.f_origins": "허용 사이트", +"emb.f_origins_hint": "한 줄에 도메인 하나 (예: assets.example.com). *.example.com은 하위 도메인 포함, *는 제한 없음. 이 사이트에서 열거나 삽입할 때만 보입니다.", +"emb.f_clients": "접속자 네트워크 (선택)", +"emb.f_clients_hint": "한 줄에 대역 하나. 설정하면 이 네트워크의 접속자만 열 수 있습니다.", +"em.sub": "대역폭 · 인바운드와 아웃바운드", +"em.now": "현재", +"em.peak": "최대", +"em.avg": "평균", +"em.nodata": "이 기간에 트래픽이 없습니다.", +"sl.title": "syslog로 보내기", +"sl.sub": "탐지 결과가 생기면 바로 SIEM이나 로그 플랫폼으로 보냅니다 (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "전송 중", +"sl.off": "꺼짐", +"sl.locked": "syslog 전송은 등록판 기능입니다. 탐지 결과는 계속 만들어지고 이 페이지에 표시됩니다.", +"sl.enabled": "전송 켜기", +"sl.address": "수집기 주소 (호스트:포트)", +"sl.network": "전송 방식", +"sl.format": "형식", +"sl.fmt_text": "일반 텍스트", +"sl.min_sev": "최소 심각도", +"sl.base_url": "이 traffic66의 주소 (증거 링크용, 선택)", +"sl.ascii": "ASCII만 (오래된 수집기용)", +"sl.save": "저장", +"sl.test": "테스트 보내기", +"sl.saved": "저장했습니다", +"sl.test_ok": "테스트 메시지를 보냈습니다", +"sl.stats": "보냄 {s}건 · 실패 {f}건 · 분당 한도 초과로 버림 {d}건", +"sl.last_sent": "마지막 전송 {t}", +"sl.last_err": "마지막 실패 ({t}): {e}", "fd.open": "미처리", "fd.all": "전체", "fd.none": "이 기간에는 탐지된 것이 없습니다.", @@ -543,6 +602,7 @@ "ov.bw_all": "대역폭(수신 + 송신), 애플리케이션별", "ov.bw_in": "수신 대역폭, 애플리케이션별", "ov.bw_out": "송신 대역폭, 애플리케이션별", +"ov.dir_empty": "이 기간에 {if}의 {d} 트래픽이 없습니다.", "src.manual": "수동 지정", "src.templates": "받은 템플릿(필드 번호, s는 스코프):", "src.stale": "중지됨(같은 장비가 지금은 다른 방식으로 보냄):", @@ -685,7 +745,9 @@ "ob.copy": "복사", "nav.help": "도움말", "rk.sub": "플로를 보내는 각 장비의 위치와, 각 포트가 연결된 통신사와 회선", -"rk.hint": "한 번만 입력하면 됩니다. 장비와 포트는 플로에서 나오고, 입력하는 것은 전산실·랙·통신사·회선 ID뿐이며 대장 파일에 저장됩니다.", +"rk.summary": "전산실 {r}개 · 랙 {k}개 · 장비 {d}대 · 포트 {n}개 중 {p}개 통신사 입력", +"rk.room_n": "랙 {k}개 · 장비 {d}대", +"rk.port": "포트", "rk.empty": "아직 플로를 보낸 장비가 없습니다.", "rk.unplaced": "위치 미지정", "rk.no_rack": "랙 미지정", diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index 4133229..9c939c4 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -276,6 +276,65 @@ "sev.high": "Alta", "sev.medium": "Média", "sev.low": "Baixa", +"emb.title": "Links para outras plataformas", +"emb.sub": "Dê a outra plataforma, como uma de ativos, um link para a largura de banda de um VIP: um botão ao lado de cada VIP, com o endereço no link. Mostra apenas a largura de banda de entrada e saída desse VIP.", +"emb.locked": "Links para outras plataformas são para instalações registradas. Links existentes ainda podem ser revogados.", +"emb.new": "Novo link", +"emb.none": "Ainda não há links.", +"emb.http_warn": "Esta máquina serve a interface por HTTP; para ser incorporada numa plataforma HTTPS também precisa de HTTPS, ou o navegador bloqueia.", +"emb.col_name": "Nome", +"emb.col_ranges": "VIPs que pode mostrar", +"emb.col_origins": "Sites permitidos", +"emb.col_clients": "Redes dos visitantes", +"emb.col_key": "Chave", +"emb.col_used": "Último uso", +"emb.never": "Nunca", +"emb.any": "Qualquer", +"emb.hits": "{n} pedidos", +"emb.old_until": "A chave antiga vale até {t}", +"emb.edit": "Editar", +"emb.rotate": "Nova chave", +"emb.revoke": "Revogar", +"emb.rotate_confirm": "Dar a “{n}” uma nova chave? A antiga continua valendo por 24 horas enquanto a plataforma troca.", +"emb.revoke_confirm": "Revogar “{n}”? Seus links param de funcionar na hora.", +"emb.key_once": "A chave é mostrada só desta vez: copie agora. Coloque o endereço do VIP no lugar de {VIP}.", +"emb.link": "Link (para um botão que o abre)", +"emb.frame": "Código de incorporação (iframe)", +"emb.copy": "Copiar", +"emb.copied": "Copiado", +"emb.rate": "Cada visitante pode fazer no máximo {n} pedidos por minuto; os demais são recusados por um tempo. A página é redesenhada a cada minuto.", +"emb.f_name": "Nome (por exemplo: plataforma de ativos do cliente A)", +"emb.f_ranges": "VIPs que pode mostrar", +"emb.f_ranges_hint": "Um endereço ou faixa por linha, como 203.0.113.0/24. Qualquer outro endereço é recusado.", +"emb.f_origins": "Sites permitidos", +"emb.f_origins_hint": "Um nome de site por linha, como assets.example.com; *.example.com inclui subdomínios; * permite qualquer um. A página só aparece se aberta ou incorporada a partir desses sites.", +"emb.f_clients": "Redes dos visitantes (opcional)", +"emb.f_clients_hint": "Uma faixa por linha. Se preenchido, só visitantes dessas redes podem abri-la.", +"em.sub": "Largura de banda · entrada e saída", +"em.now": "Atual", +"em.peak": "Pico", +"em.avg": "Média", +"em.nodata": "Sem tráfego neste período.", +"sl.title": "Enviar para syslog", +"sl.sub": "As descobertas vão para o seu SIEM ou plataforma de logs assim que são geradas (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Enviando", +"sl.off": "Desligado", +"sl.locked": "O envio para syslog é para instalações registradas. As descobertas continuam sendo geradas e exibidas nesta página.", +"sl.enabled": "Enviar descobertas", +"sl.address": "Endereço do coletor (host:porta)", +"sl.network": "Transporte", +"sl.format": "Formato", +"sl.fmt_text": "Texto simples", +"sl.min_sev": "Gravidade mínima", +"sl.base_url": "Endereço deste traffic66 (para o link de evidência, opcional)", +"sl.ascii": "Somente ASCII (coletores antigos)", +"sl.save": "Salvar", +"sl.test": "Enviar um teste", +"sl.saved": "Salvo", +"sl.test_ok": "Mensagem de teste enviada", +"sl.stats": "{s} enviadas · {f} com falha · {d} descartadas acima do limite por minuto", +"sl.last_sent": "último envio {t}", +"sl.last_err": "Última falha ({t}): {e}", "fd.open": "Pendentes", "fd.all": "Todas", "fd.none": "Nada foi encontrado neste período.", @@ -543,6 +602,7 @@ "ov.bw_all": "Largura de banda (entrada + saída), por aplicação", "ov.bw_in": "Largura de banda de entrada, por aplicação", "ov.bw_out": "Largura de banda de saída, por aplicação", +"ov.dir_empty": "Sem tráfego ({d}) por {if} neste período.", "src.manual": "definido à mão", "src.templates": "Modelos recebidos (ids de campo; s = escopo):", "src.stale": "Parados (o equipamento agora envia de outra forma):", @@ -685,7 +745,9 @@ "ob.copy": "Copiar", "nav.help": "Ajuda", "rk.sub": "Onde fica cada equipamento que envia fluxos e para qual operadora e circuito sai cada porta", -"rk.hint": "Basta preencher uma vez. Equipamentos e portas vêm dos fluxos; só sala, rack, operadora e circuito são digitados, e ficam no arquivo de inventário.", +"rk.summary": "{r} salas · {k} racks · {d} equipamentos · operadora indicada em {p} de {n} portas", +"rk.room_n": "{k} racks · {d} equipamentos", +"rk.port": "Porta", "rk.empty": "Nenhum equipamento enviou fluxos ainda.", "rk.unplaced": "Ainda sem local", "rk.no_rack": "Sem rack", diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index 69a6741..ab9f25a 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -276,6 +276,65 @@ "sev.high": "Высокая", "sev.medium": "Средняя", "sev.low": "Низкая", +"emb.title": "Ссылки для других платформ", +"emb.sub": "Дайте другой платформе, например платформе учёта активов, ссылку на полосу одного VIP: кнопка рядом с каждым VIP, адрес подставляется в ссылку. Видна только входящая и исходящая полоса этого VIP.", +"emb.locked": "Ссылки для других платформ доступны зарегистрированным установкам. Существующие ссылки по-прежнему можно отозвать.", +"emb.new": "Новая ссылка", +"emb.none": "Ссылок пока нет.", +"emb.http_warn": "Эта машина отдаёт веб-интерфейс по HTTP; чтобы встроиться в платформу на HTTPS, ей тоже нужен HTTPS, иначе браузер заблокирует.", +"emb.col_name": "Название", +"emb.col_ranges": "Какие VIP показывать", +"emb.col_origins": "Разрешённые сайты", +"emb.col_clients": "Сети зрителей", +"emb.col_key": "Ключ", +"emb.col_used": "Последнее использование", +"emb.never": "Никогда", +"emb.any": "Любые", +"emb.hits": "{n} запросов", +"emb.old_until": "Старый ключ действует до {t}", +"emb.edit": "Изменить", +"emb.rotate": "Новый ключ", +"emb.revoke": "Отозвать", +"emb.rotate_confirm": "Выдать «{n}» новый ключ? Старый будет действовать ещё 24 часа, пока платформа переходит.", +"emb.revoke_confirm": "Отозвать «{n}»? Ссылки сразу перестанут работать.", +"emb.key_once": "Ключ показывается только сейчас — скопируйте его. Подставьте адрес VIP вместо {VIP}.", +"emb.link": "Ссылка (для кнопки, которая её открывает)", +"emb.frame": "Код для встраивания (iframe)", +"emb.copy": "Копировать", +"emb.copied": "Скопировано", +"emb.rate": "Каждый зритель может сделать не больше {n} запросов в минуту; лишние на время отклоняются. Страница обновляется раз в минуту.", +"emb.f_name": "Название (например: платформа активов клиента A)", +"emb.f_ranges": "Какие VIP показывать", +"emb.f_ranges_hint": "По одному адресу или диапазону в строке, например 203.0.113.0/24. Остальные адреса отклоняются.", +"emb.f_origins": "Разрешённые сайты", +"emb.f_origins_hint": "По одному имени сайта в строке, например assets.example.com; *.example.com включает поддомены; * — любые. Страница показывается, только если её открыли или встроили с этих сайтов.", +"emb.f_clients": "Сети зрителей (необязательно)", +"emb.f_clients_hint": "По одному диапазону в строке. Если задано, открыть могут только зрители из этих сетей.", +"em.sub": "Полоса · входящая и исходящая", +"em.now": "Сейчас", +"em.peak": "Пик", +"em.avg": "Среднее", +"em.nodata": "За этот период трафика нет.", +"sl.title": "Отправка в syslog", +"sl.sub": "Находки сразу уходят в ваш SIEM или платформу журналов (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Отправляется", +"sl.off": "Выключено", +"sl.locked": "Отправка в syslog доступна зарегистрированным установкам. Находки по-прежнему создаются и показываются на этой странице.", +"sl.enabled": "Отправлять находки", +"sl.address": "Адрес коллектора (хост:порт)", +"sl.network": "Транспорт", +"sl.format": "Формат", +"sl.fmt_text": "Простой текст", +"sl.min_sev": "Минимальная важность", +"sl.base_url": "Адрес этого traffic66 (для ссылки на доказательства, необязательно)", +"sl.ascii": "Только ASCII (для старых коллекторов)", +"sl.save": "Сохранить", +"sl.test": "Отправить тест", +"sl.saved": "Сохранено", +"sl.test_ok": "Тестовое сообщение отправлено", +"sl.stats": "Отправлено: {s} · ошибок: {f} · отброшено сверх лимита в минуту: {d}", +"sl.last_sent": "последняя отправка {t}", +"sl.last_err": "Последняя ошибка ({t}): {e}", "fd.open": "Открытые", "fd.all": "Все", "fd.none": "За этот период ничего не обнаружено.", @@ -543,6 +602,7 @@ "ov.bw_all": "Полоса (вход + выход), по приложениям", "ov.bw_in": "Входящая полоса, по приложениям", "ov.bw_out": "Исходящая полоса, по приложениям", +"ov.dir_empty": "За этот период через {if} нет трафика ({d}).", "src.manual": "задано вручную", "src.templates": "Полученные шаблоны (номера полей; s — область):", "src.stale": "Остановлены (устройство теперь шлёт иначе):", @@ -685,7 +745,9 @@ "ob.copy": "Копировать", "nav.help": "Справка", "rk.sub": "Где стоит каждое устройство, присылающее потоки, и к какому оператору и каналу ведёт каждый порт", -"rk.hint": "Заполняется один раз. Устройства и порты берутся из потоков; вводятся только помещение, стойка, оператор и канал, они хранятся в файле реестра.", +"rk.summary": "Залов: {r} · стоек: {k} · устройств: {d} · оператор указан для {p} из {n} портов", +"rk.room_n": "стоек: {k} · устройств: {d}", +"rk.port": "Порт", "rk.empty": "Ни одно устройство ещё не прислало потоков.", "rk.unplaced": "Место не указано", "rk.no_rack": "Без стойки", diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index 829f172..eb8e2ba 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -276,6 +276,65 @@ "sev.high": "زیادہ", "sev.medium": "درمیانہ", "sev.low": "کم", +"emb.title": "دوسرے پلیٹ فارمز کے لیے لنک", +"emb.sub": "کسی دوسرے پلیٹ فارم، جیسے اثاثہ پلیٹ فارم، کو ایک VIP کی بینڈوڈتھ کا لنک دیں: ہر VIP کے ساتھ ایک بٹن، لنک میں VIP کا پتا ڈال کر۔ اس میں صرف اسی VIP کی آنے اور جانے والی بینڈوڈتھ دکھتی ہے۔", +"emb.locked": "دوسرے پلیٹ فارمز کے لنک رجسٹرڈ تنصیبات کے لیے ہیں۔ موجودہ لنک اب بھی واپس لیے جا سکتے ہیں۔", +"emb.new": "نیا لنک", +"emb.none": "ابھی کوئی لنک نہیں۔", +"emb.http_warn": "یہ مشین ویب UI کو HTTP پر دیتی ہے؛ HTTPS پلیٹ فارم میں شامل کرنے کے لیے یہاں بھی HTTPS چاہیے، ورنہ براؤزر روک دے گا۔", +"emb.col_name": "نام", +"emb.col_ranges": "کون سے VIP دکھائے جائیں", +"emb.col_origins": "اجازت یافتہ سائٹس", +"emb.col_clients": "دیکھنے والوں کے نیٹ ورک", +"emb.col_key": "کلید", +"emb.col_used": "آخری استعمال", +"emb.never": "کبھی نہیں", +"emb.any": "کوئی بھی", +"emb.hits": "{n} درخواستیں", +"emb.old_until": "پرانی کلید {t} تک چلے گی", +"emb.edit": "ترمیم", +"emb.rotate": "نئی کلید", +"emb.revoke": "واپس لیں", +"emb.rotate_confirm": "“{n}” کو نئی کلید دیں؟ پلیٹ فارم کے بدلنے تک پرانی 24 گھنٹے مزید چلے گی۔", +"emb.revoke_confirm": "“{n}” واپس لیں؟ اس کے لنک فوراً بند ہو جائیں گے۔", +"emb.key_once": "کلید صرف اسی بار دکھائی جا رہی ہے: ابھی کاپی کریں۔ {VIP} کی جگہ VIP کا پتا ڈالیں۔", +"emb.link": "لنک (اسے کھولنے والے بٹن کے لیے)", +"emb.frame": "ایمبیڈ کوڈ (iframe)", +"emb.copy": "کاپی", +"emb.copied": "کاپی ہو گیا", +"emb.rate": "ہر دیکھنے والا فی منٹ زیادہ سے زیادہ {n} درخواستیں کر سکتا ہے؛ زیادہ ہونے پر کچھ دیر انکار ہوتا ہے۔ صفحہ ہر منٹ دوبارہ بنتا ہے۔", +"emb.f_name": "نام (مثلاً: گاہک A کا اثاثہ پلیٹ فارم)", +"emb.f_ranges": "کون سے VIP دکھائے جائیں", +"emb.f_ranges_hint": "ہر سطر میں ایک پتا یا رینج، جیسے 203.0.113.0/24۔ باقی پتوں سے انکار ہوتا ہے۔", +"emb.f_origins": "اجازت یافتہ سائٹس", +"emb.f_origins_hint": "ہر سطر میں ایک سائٹ کا نام، جیسے assets.example.com؛ *.example.com میں ذیلی ڈومین شامل؛ * یعنی کوئی بھی۔ صفحہ صرف انہی سائٹس سے کھولنے یا شامل کرنے پر دکھتا ہے۔", +"emb.f_clients": "دیکھنے والوں کے نیٹ ورک (اختیاری)", +"emb.f_clients_hint": "ہر سطر میں ایک رینج۔ بھرنے پر صرف انہی نیٹ ورکس کے دیکھنے والے اسے کھول سکیں گے۔", +"em.sub": "بینڈوڈتھ · اندر اور باہر", +"em.now": "ابھی", +"em.peak": "بلند ترین", +"em.avg": "اوسط", +"em.nodata": "اس مدت میں کوئی ٹریفک نہیں۔", +"sl.title": "syslog پر بھیجیں", +"sl.sub": "نتائج بنتے ہی آپ کے SIEM یا لاگ پلیٹ فارم پر جاتے ہیں (Splunk، QRadar، ArcSight، Graylog، Elastic)", +"sl.on": "بھیجا جا رہا ہے", +"sl.off": "بند", +"sl.locked": "syslog پر بھیجنا رجسٹرڈ تنصیبات کے لیے ہے۔ نتائج پہلے کی طرح بنتے اور اس صفحے پر دکھتے ہیں۔", +"sl.enabled": "نتائج بھیجیں", +"sl.address": "کلیکٹر کا پتا (ہوسٹ:پورٹ)", +"sl.network": "ٹرانسپورٹ", +"sl.format": "فارمیٹ", +"sl.fmt_text": "سادہ متن", +"sl.min_sev": "کم از کم شدت", +"sl.base_url": "اس traffic66 کا پتا (ثبوت کے لنک کے لیے، اختیاری)", +"sl.ascii": "صرف ASCII (پرانے کلیکٹرز کے لیے)", +"sl.save": "محفوظ کریں", +"sl.test": "ٹیسٹ بھیجیں", +"sl.saved": "محفوظ ہو گیا", +"sl.test_ok": "ٹیسٹ پیغام بھیج دیا گیا", +"sl.stats": "{s} بھیجے · {f} ناکام · فی منٹ حد سے اوپر {d} چھوڑے", +"sl.last_sent": "آخری بار بھیجا {t}", +"sl.last_err": "آخری ناکامی ({t}): {e}", "fd.open": "زیرِ التوا", "fd.all": "سب", "fd.none": "اس مدت میں کچھ نہیں ملا۔", @@ -543,6 +602,7 @@ "ov.bw_all": "بینڈوڈتھ (آنے + جانے والی)، ایپلیکیشن کے لحاظ سے", "ov.bw_in": "آنے والی بینڈوڈتھ، ایپلیکیشن کے لحاظ سے", "ov.bw_out": "جانے والی بینڈوڈتھ، ایپلیکیشن کے لحاظ سے", +"ov.dir_empty": "اس مدت میں {if} سے کوئی {d} ٹریفک نہیں۔", "src.manual": "ہاتھ سے مقرر", "src.templates": "موصول ٹیمپلیٹ (field id؛ s = scope):", "src.stale": "بند (آلہ اب دوسرے طریقے سے بھیج رہا ہے):", @@ -685,7 +745,9 @@ "ob.copy": "کاپی", "nav.help": "مدد", "rk.sub": "فلو بھیجنے والا ہر آلہ کہاں لگا ہے، اور ہر پورٹ کس کیریئر اور سرکٹ سے جڑا ہے", -"rk.hint": "اسے ایک بار بھریں۔ آلات اور پورٹس فلو سے آتے ہیں؛ صرف کمرہ، ریک، کیریئر اور سرکٹ لکھنے ہوتے ہیں، جو رجسٹر فائل میں رہتے ہیں۔", +"rk.summary": "{r} کمرے · {k} ریک · {d} آلات · {n} میں سے {p} پورٹس پر کیریئر درج", +"rk.room_n": "{k} ریک · {d} آلات", +"rk.port": "پورٹ", "rk.empty": "ابھی تک کسی آلے نے فلو نہیں بھیجا۔", "rk.unplaced": "ابھی جگہ مقرر نہیں", "rk.no_rack": "کوئی ریک نہیں", diff --git a/internal/web/static/i18n/vi.json b/internal/web/static/i18n/vi.json index 6fd1e44..70e8a62 100644 --- a/internal/web/static/i18n/vi.json +++ b/internal/web/static/i18n/vi.json @@ -276,6 +276,65 @@ "sev.high": "Cao", "sev.medium": "Trung bình", "sev.low": "Thấp", +"emb.title": "Liên kết cho nền tảng khác", +"emb.sub": "Cho một nền tảng khác, như nền tảng quản lý tài sản, một liên kết tới băng thông của một VIP: một nút cạnh mỗi VIP, với địa chỉ VIP đặt vào liên kết. Chỉ hiện băng thông vào và ra của VIP đó.", +"emb.locked": "Liên kết cho nền tảng khác là tính năng của bản đã đăng ký. Vẫn có thể thu hồi các liên kết hiện có.", +"emb.new": "Liên kết mới", +"emb.none": "Chưa có liên kết.", +"emb.http_warn": "Máy này phục vụ giao diện qua HTTP; để nhúng vào nền tảng HTTPS thì máy này cũng cần HTTPS, nếu không trình duyệt sẽ chặn.", +"emb.col_name": "Tên", +"emb.col_ranges": "VIP được hiển thị", +"emb.col_origins": "Trang được phép", +"emb.col_clients": "Mạng của người xem", +"emb.col_key": "Khóa", +"emb.col_used": "Dùng lần cuối", +"emb.never": "Chưa dùng", +"emb.any": "Bất kỳ", +"emb.hits": "{n} yêu cầu", +"emb.old_until": "Khóa cũ còn hiệu lực đến {t}", +"emb.edit": "Sửa", +"emb.rotate": "Khóa mới", +"emb.revoke": "Thu hồi", +"emb.rotate_confirm": "Cấp khóa mới cho “{n}”? Khóa cũ vẫn dùng được 24 giờ trong lúc nền tảng chuyển đổi.", +"emb.revoke_confirm": "Thu hồi “{n}”? Các liên kết của nó ngừng hoạt động ngay.", +"emb.key_once": "Khóa chỉ hiện lần này: hãy sao chép ngay. Thay {VIP} bằng địa chỉ VIP.", +"emb.link": "Liên kết (cho nút mở nó)", +"emb.frame": "Mã nhúng (iframe)", +"emb.copy": "Sao chép", +"emb.copied": "Đã sao chép", +"emb.rate": "Mỗi người xem tối đa {n} yêu cầu mỗi phút; vượt quá sẽ bị từ chối một lúc. Trang được vẽ lại mỗi phút.", +"emb.f_name": "Tên (ví dụ: nền tảng tài sản của khách hàng A)", +"emb.f_ranges": "VIP được hiển thị", +"emb.f_ranges_hint": "Mỗi dòng một địa chỉ hoặc dải, ví dụ 203.0.113.0/24. Địa chỉ khác bị từ chối.", +"emb.f_origins": "Trang được phép", +"emb.f_origins_hint": "Mỗi dòng một tên trang, ví dụ assets.example.com; *.example.com gồm cả tên miền con; * là bất kỳ. Trang chỉ hiện khi được mở hoặc nhúng từ các trang này.", +"emb.f_clients": "Mạng của người xem (tùy chọn)", +"emb.f_clients_hint": "Mỗi dòng một dải. Khi điền, chỉ người xem trong các mạng này mới mở được.", +"em.sub": "Băng thông · vào và ra", +"em.now": "Hiện tại", +"em.peak": "Đỉnh", +"em.avg": "Trung bình", +"em.nodata": "Không có lưu lượng trong khoảng này.", +"sl.title": "Gửi tới syslog", +"sl.sub": "Phát hiện được gửi tới SIEM hoặc nền tảng nhật ký của bạn ngay khi tạo ra (Splunk, QRadar, ArcSight, Graylog, Elastic)", +"sl.on": "Đang gửi", +"sl.off": "Tắt", +"sl.locked": "Gửi tới syslog là tính năng của bản đã đăng ký. Phát hiện vẫn được tạo và hiển thị trên trang này.", +"sl.enabled": "Gửi phát hiện", +"sl.address": "Địa chỉ bộ thu (máy:cổng)", +"sl.network": "Giao thức", +"sl.format": "Định dạng", +"sl.fmt_text": "Văn bản thuần", +"sl.min_sev": "Mức độ tối thiểu", +"sl.base_url": "Địa chỉ của traffic66 này (cho liên kết bằng chứng, không bắt buộc)", +"sl.ascii": "Chỉ ASCII (cho bộ thu cũ)", +"sl.save": "Lưu", +"sl.test": "Gửi thử", +"sl.saved": "Đã lưu", +"sl.test_ok": "Đã gửi tin nhắn thử", +"sl.stats": "Đã gửi {s} · lỗi {f} · bỏ {d} do vượt giới hạn mỗi phút", +"sl.last_sent": "gửi lần cuối {t}", +"sl.last_err": "Lỗi gần nhất ({t}): {e}", "fd.open": "Đang mở", "fd.all": "Tất cả", "fd.none": "Không phát hiện gì trong khoảng thời gian này.", @@ -543,6 +602,7 @@ "ov.bw_all": "Băng thông (vào + ra), theo ứng dụng", "ov.bw_in": "Băng thông vào, theo ứng dụng", "ov.bw_out": "Băng thông ra, theo ứng dụng", +"ov.dir_empty": "Không có lưu lượng {d} qua {if} trong khoảng này.", "src.manual": "đặt thủ công", "src.templates": "Template đã nhận (field id; s = scope):", "src.stale": "Đã ngừng (thiết bị giờ gửi theo cách khác):", @@ -685,7 +745,9 @@ "ob.copy": "Sao chép", "nav.help": "Trợ giúp", "rk.sub": "Mỗi thiết bị gửi luồng đặt ở đâu, và mỗi cổng đi ra nhà mạng, đường truyền nào", -"rk.hint": "Chỉ cần nhập một lần. Thiết bị và cổng lấy từ luồng; chỉ nhập phòng máy, tủ rack, nhà mạng và mã đường truyền, lưu trong tệp sổ quản lý.", +"rk.summary": "{r} phòng · {k} tủ rack · {d} thiết bị · đã điền nhà mạng cho {p}/{n} cổng", +"rk.room_n": "{k} tủ rack · {d} thiết bị", +"rk.port": "Cổng", "rk.empty": "Chưa có thiết bị nào gửi luồng.", "rk.unplaced": "Chưa xếp chỗ", "rk.no_rack": "Chưa có tủ rack", diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 02c5004..8965489 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -171,8 +171,8 @@ "if.counter": "设备计数器", "if.dev": "偏差 {v}", "if.stat": "统计误差 ±{v}", -"if.in": "入方向", -"if.out": "出方向", +"if.in": "入向", +"if.out": "出向", "if.no_ctr_short": "无计数器", "if.no_counters": "这台设备不发送接口计数器,没有可对比的数据。在「台账」里加一行 snmp 10.0.0.1 public,即可通过 SNMP 读取。", "if.no_flow": "这个接口有计数器数据,但没有收到任何 flow。请检查设备的 flow 导出(是否配置了采样但没有导出?)", @@ -276,6 +276,65 @@ "sev.high": "高", "sev.medium": "中", "sev.low": "低", +"emb.title": "嵌入链接", +"emb.sub": "给其他平台(比如资产平台)一个查看单个 VIP 带宽的链接:在平台里每个 VIP 旁加一个按钮,链接里换上 VIP 地址即可。对方只能看到这个 VIP 的入向、出向带宽。", +"emb.locked": "嵌入链接是注册版功能。已有的链接仍可作废。", +"emb.new": "新建链接", +"emb.none": "还没有链接。", +"emb.http_warn": "本机目前用 HTTP 提供网页;要嵌入到 HTTPS 的平台里,本机也需要启用 HTTPS,否则浏览器会拦截。", +"emb.col_name": "名称", +"emb.col_ranges": "可查看的 VIP", +"emb.col_origins": "允许的来源网站", +"emb.col_clients": "访问者 IP 段", +"emb.col_key": "密钥", +"emb.col_used": "最近使用", +"emb.never": "从未使用", +"emb.any": "不限", +"emb.hits": "{n} 次", +"emb.old_until": "旧密钥有效至 {t}", +"emb.edit": "修改", +"emb.rotate": "换密钥", +"emb.revoke": "作废", +"emb.rotate_confirm": "给「{n}」换一个新密钥?旧密钥还能再用 24 小时,方便平台切换。", +"emb.revoke_confirm": "作废「{n}」?它的链接会立即失效。", +"emb.key_once": "密钥只显示这一次,请现在复制。把 {VIP} 换成 VIP 地址即可。", +"emb.link": "链接(点按钮跳转打开)", +"emb.frame": "嵌入代码(iframe)", +"emb.copy": "复制", +"emb.copied": "已复制", +"emb.rate": "每个访问者每分钟最多 {n} 次请求,超出会暂时拒绝;页面每分钟刷新一次。", +"emb.f_name": "名称(例如:客户 A 资产平台)", +"emb.f_ranges": "可查看的 VIP", +"emb.f_ranges_hint": "每行一个地址或网段,例如 203.0.113.0/24。范围外的地址一律拒绝。", +"emb.f_origins": "允许的来源网站", +"emb.f_origins_hint": "每行一个域名,例如 assets.example.com;*.example.com 包括子域名;填 * 表示不限。只有从这些网站打开或嵌入才会显示。", +"emb.f_clients": "访问者 IP 段(可不填)", +"emb.f_clients_hint": "每行一个网段。填了之后,只有这些网络里的访问者能打开。", +"em.sub": "带宽 · 入向与出向", +"em.now": "当前", +"em.peak": "峰值", +"em.avg": "平均", +"em.nodata": "这段时间没有流量。", +"sl.title": "发送到 SYSLOG", +"sl.sub": "发现产生时同时发到你的 SIEM 或日志平台(Splunk、QRadar、ArcSight、Graylog、Elastic)", +"sl.on": "发送中", +"sl.off": "未启用", +"sl.locked": "发送到 SYSLOG 是注册版功能。发现照常生成,并在本页显示。", +"sl.enabled": "启用", +"sl.address": "采集器地址(主机:端口)", +"sl.network": "连接方式", +"sl.format": "格式", +"sl.fmt_text": "纯文本", +"sl.min_sev": "最低级别", +"sl.base_url": "traffic66 访问地址(用于证据链接,可不填)", +"sl.ascii": "只发 ASCII 字符(较老的采集器需要)", +"sl.save": "保存", +"sl.test": "发一条测试", +"sl.saved": "已保存", +"sl.test_ok": "测试消息已发出", +"sl.stats": "已发送 {s} 条 · 失败 {f} 条 · 超出每分钟上限丢弃 {d} 条", +"sl.last_sent": "最近发送 {t}", +"sl.last_err": "最近一次失败({t}):{e}", "fd.open": "未处理", "fd.all": "全部", "fd.none": "这个时间范围内没有发现。", @@ -541,8 +600,9 @@ "theme.next": "点击换下一个主题", "ov.dir_total": "合计", "ov.bw_all": "带宽(入 + 出),按应用", -"ov.bw_in": "入站带宽,按应用", -"ov.bw_out": "出站带宽,按应用", +"ov.bw_in": "入向带宽,按应用", +"ov.bw_out": "出向带宽,按应用", +"ov.dir_empty": "这段时间 {if} 没有{d}流量。", "src.manual": "手动指定", "src.templates": "收到的模板(字段编号,s 表示作用域):", "src.stale": "已停用(同一设备现在用别的方式在发):", @@ -685,7 +745,9 @@ "ob.copy": "复制", "nav.help": "帮助", "rk.sub": "每台发送流量的设备在哪个机房、哪个机柜,每个接口接的是哪家运营商、哪条线路", -"rk.hint": "录入一次即可。设备和接口来自流量本身,只需填写机房、机柜、运营商和线路 ID,保存在台账文件里。", +"rk.summary": "{r} 个机房 · {k} 个机柜 · {d} 台设备 · {p}/{n} 个接口已填运营商", +"rk.room_n": "{k} 个机柜 · {d} 台设备", +"rk.port": "端口", "rk.empty": "还没有设备发送流量。", "rk.unplaced": "未指定机房", "rk.no_rack": "未指定机柜", diff --git a/internal/web/static/theme.js b/internal/web/static/theme.js index fc1b204..af19dc8 100644 --- a/internal/web/static/theme.js +++ b/internal/web/static/theme.js @@ -1,13 +1,14 @@ // Applies the colour theme before the page is drawn, so it does not flash. // The choice is kept per browser; clicking the theme button goes to the next. +// Black is the default: the pages are mostly charts, often on a wall screen. (function () { var THEMES = ['light', 'black', 'teal', 'orange']; var OLD = {bright: 'light', dim: 'black', dark: 'black', auto: 'light', gray: 'light'}; function get() { - var v = 'light'; - try { v = localStorage.getItem('t66.theme') || 'light'; } catch (e) {} + var v = 'black'; + try { v = localStorage.getItem('t66.theme') || 'black'; } catch (e) {} if (OLD[v]) { v = OLD[v]; try { localStorage.setItem('t66.theme', v); } catch (e) {} } - return THEMES.indexOf(v) < 0 ? 'light' : v; + return THEMES.indexOf(v) < 0 ? 'black' : v; } function apply(x) { if (x === 'light') document.documentElement.removeAttribute('data-theme');
${t('blk.col_addr')}${t('blk.col_since')}${t('blk.col_dropped')}${t('blk.col_note')}
${esc(b.address)} ${b.since ? esc(new Intl.DateTimeFormat(LANG, {dateStyle: 'short', timeStyle: 'short'}).format(new Date(b.since))) : ''}