Intial commit

This commit is contained in:
Tom Wilkie
2015-05-19 10:02:02 +00:00
commit d50e827fd5
182 changed files with 18017 additions and 0 deletions

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
.vagrant
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
profile.cov
coverage.html
# Emacs backup files
*~
# Project specific
weaver/weaver
weavedns/weavedns
sigproxy/sigproxy
tools/bin
tools/build
releases
weave.tar
weavedns.tar
weaveexec.tar
weaveexec/weave
weaveexec/sigproxy
weaveexec/docker*.tgz
Vagrantfile.local
scope.tar
scope/probe/probe
scope/bridge/bridge
scope/app/app
scope/demoprobe/demoprobe
scope/fixprobe/fixprobe
scope/genreport/genreport
scope/graphviz/graphviz
scope/oneshot/oneshot
scope/web/assets/videos/*.mp4
scope/release/*.tar.gz
scope/web/docs/*.html
scope/*sublime-project
scope/*sublime-workspace
scope/*npm-debug.log

29
.travis.yml Normal file
View File

@@ -0,0 +1,29 @@
language: go
sudo: false
go:
- 1.4
env:
- PATH="${PATH}:${HOME}/.local/bin"
addons:
apt:
packages:
- libpcap-dev
install:
- go clean -i net
- go install -tags netgo std
- pip install --user --upgrade gcloud gsutil
- bin/setup-ci-secrets $encrypted_5ba036e89377_key $encrypted_5ba036e89377_iv
- cd ..; git clone ssh://git@github.com/weaveworks/procspy.git; cd scope
- make travis
script:
- bin/lint .
- make tests
after_success:
- go get github.com/mattn/goveralls
- $HOME/gopath/bin/goveralls -coverprofile=profile.cov -service=travis-ci
- test "${TRAVIS_PULL_REQUEST}" = "false" && site/build-and-publish.sh

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM gliderlabs/alpine
MAINTAINER Weaveworks Inc <help@weave.works>
WORKDIR /home/weave
RUN apk add --update supervisor
RUN ["sh", "-c", "rm -rf /var/cache/apk/*"]
ADD supervisord.conf /etc/
ADD ./app/app /home/weave/
ADD ./probe/probe /home/weave/
ADD ./entrypoint.sh /home/weave/
EXPOSE 4040
ENTRYPOINT ["/home/weave/entrypoint.sh"]

137
ENHANCE.md Normal file
View File

@@ -0,0 +1,137 @@
# Enhance
## 2014 10 05
Informed by a new context, we began sketching out a simplified data model.
Initial requirements:
- Must support order-of-thousands nodes in the topology.
- Must support a scrapable API.
- Packet counts, etc. are a value-add and should be composed in to the topology as needed.
Desires based on experience so far:
- Too much effort was spent wrangling deeply nested hierarchies in the report structs. They should be flattened.
- It would be nice if the probe API were directly consumable by the app.
- This enables much simpler composition of "bridge" components, including none at all.
- This composes nicely with the scrapable API requirement.
- The simplest use case of no metadata at all could (should) be satisfied by scraping the /proc FS, with no packet sniffing.
Peter's initial thoughts was to key everything based on the atom of host + socket_descriptor.
This represents the most specific (application-level) topology.
Other more general topologies (network, transport) could be derived from that.
Harmen noted that we want to capture data that may not be associated with a socket_descriptor.
Therefore we should build multiple, independent topologies: application, network, and transport.
Each one is a complete topology for that layer, with lower (OSI) layers always containing the higher ones.
We think we can get away with a report structure that looks like this
```json
{
"application": {
"adjacency": {
"host:src": ["dst1", "dst2", "dst3"]
},
"metadata": {
"host:src:dst1": {}
}
}
"network": {
"adjacency": {},
"metadata": {}
}
"transport": {
"adjacency": {},
"metadata": {}
}
}
```
Where the meaning of the keys and key-parts depends on the section of the report.
Specifically, in the application section, src is an encoding of the socket_descriptor.
Note that we still need another section to map application keys to processes, either physical (PIDs) or logical (process names, service names, etc.).
That mapping can take the form of a function:
```go
type mapper func(socket_descriptor) string
```
Where the returned string will be the aggregation key of the logical (application) topology.
At the lowest level, it could be the PID that bound the socket_descriptor.
At a middle layer, it could be the name of the process associated with the PID.
At a higher layer, the function could query a service discovery component, and return a service name.
It would probably be good to support multiple simultaneous mappers, so the application-layer topology could have multiple levels of detail.
### Example implementation
Two probes, A and B, with a single TCP connection, both agree there is a connection.
```json
{
"proc": {
"mapping": {
"hostA:192.168.1.1:12345": "curl",
"hostB:192.168.1.2:80": "apache"
},
"adjacency": {
"hostA:192.168.1.1:12345": ["192.168.1.2:80"],
"hostB:192.168.1.2:80": ["192.168.1.1:12345"]
},
"metadata": {
"hostA:192.168.1.1:12345:192.168.1.2:80": {"egress": "12bytes", "ingress": "0", "d": "15s"},
"hostB:192.168.1.2:80:192.168.1.1:12345": {"egress": "0", "ingress": "12bytes", "d": "15s"}
}
},
"transport": {
"adjacency": {
"hostA:192.168.1.1:12345": ["192.168.1.2:80"],
"hostB:192.168.1.2:80": ["192.168.1.1:12345"]
},
"metadata": {
"hostA:192.168.1.1:12345:192.168.1.2:80": {"egress": "12bytes", "ingress": "0", "d": "15s"},
"hostB:192.168.1.2:80:192.168.1.1:12345": {"egress": "0", "ingress": "12bytes", "d": "15s"}
}
},
"network": {
"adjacency": {
"hostA:192.168.1.1": ["192.168.1.2"],
"hostB:192.168.1.2": ["192.168.1.1"]
},
"metadata": {
"hostA:192.168.1.1:12345:192.168.1.2:80": {"egress": "12bytes", "ingress": "0", "d": "15s"},
"hostB:192.168.1.2:80:192.168.1.1:12345": {"egress": "0", "ingress": "12bytes", "d": "15s"}
}
},
"nodes": {
"hostA": {
"addresses": ["192.168.1.1"],
"hostname": "..."
},
"hostB": {
"addresses": ["192.168.1.2"],
"hostname": "...",
},
}
}
```
Note that the proc.mapping can only be filled by the procfs-probe.
But {proc,transport}.{adjacency,metadata} could in theory be filled both procfs-probe and sniffing-probe.
That's a consequence of the fact that, in the case of UDP and TCP, the socket_descriptor is fully reconstructable from a network packet's source and destination fields.
For simplicity, and for now, we'd like to restrict that freedom, and state that the proc section should be filled only by the procfs-probe.
This draws a clear line of ownership and demarcation.
We can revisit this decision in the future, if it proves too restrictive.
For now we will assert that the metadata type is the same structure for each section.
Adjacency list values don't necessarily need to exist as nodes. Cases are non-probe machines and broadcast addresses.
Merging reports erquires a time component because rates require a window, and we can't make assumptions about fixed window sizes (either because the edge isn't present for the entire interval, or the whole probe isn't).
Reports are by definition non-overlapping for a single e.g. socket_descriptor, so that's a safe assumption to make. Therefore, all we need are durations, which may be semantically merged with simple addition.

191
LICENSE Normal file
View File

@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2014-2015 Weaveworks Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
.PHONY: default get all build release clean
default: all
get:
cd app && go get
all:
$(MAKE) -C report
$(MAKE) -C xfer
$(MAKE) -C app
$(MAKE) -C bridge
$(MAKE) -C demoprobe
$(MAKE) -C oneshot
$(MAKE) -C fixprobe
$(MAKE) -C genreport
$(MAKE) -C probe
$(MAKE) -C integration
build:
$(MAKE) -C report build
$(MAKE) -C xfer build
$(MAKE) -C app build
$(MAKE) -C bridge build
$(MAKE) -C demoprobe build
$(MAKE) -C oneshot build
$(MAKE) -C fixprobe build
$(MAKE) -C probe build
$(MAKE) -C genreport build
$(MAKE) -C integration build
release:
$(MAKE) -C release
clean:
$(MAKE) -C report clean
$(MAKE) -C xfer clean
$(MAKE) -C app clean
$(MAKE) -C bridge clean
$(MAKE) -C demoprobe clean
$(MAKE) -C oneshot clean
$(MAKE) -C fixprobe clean
$(MAKE) -C genreport clean
$(MAKE) -C probe clean
$(MAKE) -C integration clean

3
NOTICE Normal file
View File

@@ -0,0 +1,3 @@
Weave
Copyright 2014-2015 Weaveworks Ltd.
This product includes software developed at Weaveworks Ltd.

28
README.md Normal file
View File

@@ -0,0 +1,28 @@
# Cello
Network harmony for the 21st century.
## Component diagram
```
+-----------------------------------+
| client |
+-----------------------------------+
^
| 4040, report.RenderableNode
|
+---------------------------------------------------+
| app |
+---------------------------------------------------+
^ ^ ^ ^
| | | | 4030, report.Report
| | | |
+--------+ +-------+ +----------+ +-----------+
| bridge | .-| probe | .-| fixprobe | .-| demoprobe |
+--------+ | +-------+ | +----------+ | +-----------+
^^^ | | |
||| | | |
||'-----' | |
|'------------------' |
'----------------------------------'
```

158
REST.md Normal file
View File

@@ -0,0 +1,158 @@
# Rest structure
The various REST calls to retrieve topology information.
# Topologies list
```
GET /api/topology
```
Returns a list of topology descriptions. Each description has the fields:
- `name` - string; friendly name of the topology
- `url` - string; path of the topology
- `grouped_url` - string or not existing; path to the 'grouped' version on the
topology, if there is one for this topology
- `stats` - object; basic statistics about the topology, with these fields:
- node_count - int
- nonpseudo_node_count - int
- edge_count - int
# Full topologies
```
GET /api/topology/:kind
```
Where `:kind` is one of
- `processpid` - processes by PID (most granular)
- `processname` - processes by name
- `networkip` - hosts (interfaces) by IP
- `networkhost` - hosts by hostname (least granular)
A GET on a topology URL gives:
```json
{
"nodes": {
"nodeID": {}
}
}
```
`nodeID` is an opaque string. The node object is a map with the fields:
- `id` - string; same as nodeID
- `adjacency` - array of strings; list of node IDs that this node is connected to
- `label_major` - string; primary label for this node
- `label_minor` - string; extra label information for this node
- `pseudo` - bool; true if the node is "deduced" from conditions and has no actual probe; false if this node comes from a probe
- `origin` - optional array of strings; origin IDs that the node information comes from
All node IDs listed in `adjacency` have a corresponding node entry. In other
words, the graph is complete.
If A has B in its `adjacency` list, that means A communicates to B. B may or
may not have A in its adjacency list. In other words, the graph is directed.
Pseudonodes are nodes for which we don't have direct measurements, but we know
that other nodes communicate with them. These nodes will always have empty
adjacency and origin lists.
# Websocket
There is also a websocket version of the full topology, which provides
continuous updates.
```
GET ws://hostname/api/topology/:kind/ws?t=5s
```
The URL is the same as for an HTTP topology, but with `/ws` appended. The
argument `t` can be used to specify the update interval.
The topology is send as a stream of updates, starting with the difference
between the empty topology and the current state.
```json
{
"add": [],
"update": [],
"remove": []
}
```
The object has these fields:
- add - array of objects; a list of node objects which are new since the last update
- update - array of objects; a list of node objects which have changed
- remove - array of strings; a list of node IDs which are gone
# Nodes
Given any base topology URL, there is also:
```
GET /api/topology/:kind/:id
```
This returns a single node object. It has fields in addition to those from the
corresponding entry from the full topology.
```json
{
"node": {}
}
```
Extra fields:
- aggregate - object - sum of all edge metadata, see 'metadata' below for
the fields.
# Edges
Details for an edge:
```
GET /api/topology/:kind/:id/:adjacentid
```
This returns the metadata for the undirected edge between :id and :adjacentid,
from the point of view of :id. If the edge is not found it'll still return a
statuscode 200, but without values.
```json
{
"metadata": {}
}
```
metadata can have these keys:
- max_conn_count_tcp - int; maximum number of concurrent TCP connections.
- egress_bytes - int; total number of bytes.
- ingress_bytes - int; total number of bytes.
Keys will only be set if the statistic is measured by the probes used. If a
statistic has the value 0 the key will still be present.
# Origin
Origin calls give information about the machine on which the measurements are
made.
```
GET /api/origin/:id
```
Origin is a reference to a physical machine that runs a probe. The origin ID
comes from the `origin` field in a node object. The returned object is a map
with the fields:
- `hostname` - string; fully qualified domain name of the host
- `os` - string; operating system of the host, e.g. "linux"

24
app/Makefile Normal file
View File

@@ -0,0 +1,24 @@
.PHONY: all vet lint build test clean static
all: build test vet lint
vet:
go vet ./...
lint:
golint .
build:
go build
test:
go test
clean:
go clean
static:
go get github.com/mjibson/esc
cd ../client && make build && rm -f dist/.htaccess
esc -o static.go -prefix ../client/dist ../client/dist
${MAKE} build

74
app/api_listtopology.go Normal file
View File

@@ -0,0 +1,74 @@
package main
import (
"net/http"
"strings"
"github.com/weaveworks/scope/scope/report"
)
// APITopologyDesc is returned in a list by the /api/topology handler.
type APITopologyDesc struct {
Name string `json:"name"`
URL string `json:"url"`
GroupedURL string `json:"grouped_url,omitempty"`
Type string `json:"type"`
Stats topologyStats `json:"stats"`
}
type topologyStats struct {
NodeCount int `json:"node_count"`
NonpseudoNodeCount int `json:"nonpseudo_node_count"`
EdgeCount int `json:"edge_count"`
}
// makeTopologyList returns a handler that yields an APITopologyList.
func makeTopologyList(rep Reporter) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
rpt := rep.Report()
var a []APITopologyDesc
for name, def := range topologyRegistry {
if strings.HasSuffix(name, "grouped") {
continue
}
url := "/api/topology/" + name
var groupedURL string
if def.hasGrouped {
groupedURL = url + "grouped"
}
a = append(a, APITopologyDesc{
Name: def.human,
URL: url,
GroupedURL: groupedURL,
Type: def.typ,
Stats: stats(def.topologySelecter(rpt).RenderBy(def.MapFunc, false)),
})
}
respondWith(w, http.StatusOK, a)
}
}
func stats(r map[string]report.RenderableNode) topologyStats {
var (
nodes int
realNodes int
edges int
)
for _, n := range r {
nodes++
if !n.Pseudo {
realNodes++
}
edges += len(n.Adjacency)
}
return topologyStats{
NodeCount: nodes,
NonpseudoNodeCount: realNodes,
EdgeCount: edges,
}
}

View File

@@ -0,0 +1,37 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
)
func TestAPITopology(t *testing.T) {
ts := httptest.NewServer(Router(StaticReport{}))
defer ts.Close()
body := getRawJSON(t, ts, "/api/topology")
var topos []APITopologyDesc
if err := json.Unmarshal(body, &topos); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
equals(t, 2, len(topos))
for _, topo := range topos {
is200(t, ts, topo.URL)
if topo.GroupedURL != "" {
is200(t, ts, topo.GroupedURL)
}
if have := topo.Type; have == "" {
t.Errorf("Type isn't empty: %q", have)
}
if have := topo.Stats.EdgeCount; have <= 0 {
t.Errorf("EdgeCount isn't positive: %d", have)
}
if have := topo.Stats.NodeCount; have <= 0 {
t.Errorf("NodeCount isn't positive: %d", have)
}
if have := topo.Stats.NonpseudoNodeCount; have <= 0 {
t.Errorf("NonpseudoNodeCount isn't positive: %d", have)
}
}
}

55
app/api_origin.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"net/http"
"github.com/gorilla/mux"
)
// Origin is returned by the /api/origin/* handler. It represents a machine
// that runs a probe, i.e. the origin of some data in the system.
type Origin struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Addresses []string `json:"addresses"`
LoadOne float64 `json:"load_one"`
LoadFive float64 `json:"load_five"`
LoadFifteen float64 `json:"load_fifteen"`
}
// makeOriginHandler makes the /api/origin/* handler.
func makeOriginHandler(rep Reporter) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
nodeID = vars["id"]
)
origin, ok := getOrigin(rep, nodeID)
if !ok {
http.NotFound(w, r)
return
}
respondWith(w, http.StatusOK, origin)
}
}
func getOrigin(rep Reporter, nodeID string) (Origin, bool) {
host, ok := rep.Report().HostMetadatas[nodeID]
if !ok {
return Origin{}, false
}
var addrs []string
for _, l := range host.LocalNets {
addrs = append(addrs, l.String())
}
return Origin{
Hostname: host.Hostname,
OS: host.OS,
Addresses: addrs,
LoadOne: host.LoadOne,
LoadFive: host.LoadFive,
LoadFifteen: host.LoadFifteen,
}, true
}

35
app/api_origin_test.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
)
func TestAPIOrigin(t *testing.T) {
ts := httptest.NewServer(Router(StaticReport{}))
defer ts.Close()
is404(t, ts, "/api/origin/foobar")
{
// Origin
body := getRawJSON(t, ts, "/api/origin/hostA")
var o Origin
if err := json.Unmarshal(body, &o); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
if want, have := "Linux", o.OS; want != have {
t.Errorf("Origin error. Want %v, have %v", want, have)
}
if want, have := 3.1415, o.LoadOne; want != have {
t.Errorf("Origin error. Want %v, have %v", want, have)
}
if want, have := 2.7182, o.LoadFive; want != have {
t.Errorf("Origin error. Want %v, have %v", want, have)
}
if want, have := 1.6180, o.LoadFifteen; want != have {
t.Errorf("Origin error. Want %v, have %v", want, have)
}
}
}

13
app/api_report.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"net/http"
)
// Raw report handler
func makeRawReportHandler(rep Reporter) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// r.ParseForm()
respondWith(w, http.StatusOK, rep.Report())
}
}

24
app/api_report_test.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/weaveworks/scope/scope/report"
)
func TestAPIReport(t *testing.T) {
ts := httptest.NewServer(Router(StaticReport{}))
defer ts.Close()
is404(t, ts, "/api/report/foobar")
var body = getRawJSON(t, ts, "/api/report")
// fmt.Printf("Body: %v\n", string(body))
var r report.Report
err := json.Unmarshal(body, &r)
if err != nil {
t.Fatalf("JSON parse error: %s", err)
}
}

207
app/api_topology.go Normal file
View File

@@ -0,0 +1,207 @@
package main
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/weaveworks/scope/scope/report"
)
const (
websocketLoop = 1 * time.Second
websocketTimeout = 10 * time.Second
)
// APITopology is returned by the /api/topology/{name} handler.
type APITopology struct {
Nodes map[string]report.RenderableNode `json:"nodes"`
}
// APINode is returned by the /api/topology/{name}/{id} handler.
type APINode struct {
Node report.DetailedNode `json:"node"`
}
// APIEdge is returned by the /api/topology/*/*/* handlers.
type APIEdge struct {
Metadata report.AggregateMetadata `json:"metadata"`
}
// topologySelecter selects a single topology from a report.
type topologySelecter func(r report.Report) report.Topology
func selectProcess(r report.Report) report.Topology {
return r.Process
}
func selectNetwork(r report.Report) report.Topology {
return r.Network
}
// makeTopologyHandlers make /api/topology/* handlers.
func makeTopologyHandlers(
rep Reporter,
topo topologySelecter,
mapping report.MapFunc,
grouped bool,
get *mux.Router,
base string,
) {
// Full topology.
get.HandleFunc(base, func(w http.ResponseWriter, r *http.Request) {
rpt := rep.Report()
rendered := topo(rpt).RenderBy(mapping, grouped)
t := APITopology{
Nodes: rendered,
}
respondWith(w, http.StatusOK, t)
})
// Websocket for the full topology. This route overlaps with the next.
get.HandleFunc(base+"/ws", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
loop := websocketLoop
if t := r.Form.Get("t"); t != "" {
var err error
if loop, err = time.ParseDuration(t); err != nil {
respondWith(w, http.StatusBadRequest, t)
return
}
}
handleWebsocket(w, r, rep, topo, mapping, grouped, loop)
})
// Individual nodes.
get.HandleFunc(base+"/{id}", func(w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
nodeID = vars["id"]
rpt = rep.Report()
node, ok = topo(rpt).RenderBy(mapping, grouped)[nodeID]
)
if !ok {
http.NotFound(w, r)
return
}
of := func(nodeID string) (Origin, bool) { return getOrigin(rep, nodeID) }
respondWith(w, http.StatusOK, APINode{Node: makeDetailed(node, of)})
})
// Individual edges.
get.HandleFunc(base+"/{local}/{remote}", func(w http.ResponseWriter, r *http.Request) {
var (
vars = mux.Vars(r)
localID = vars["local"]
remoteID = vars["remote"]
rpt = rep.Report()
metadata = topo(rpt).EdgeMetadata(mapping, grouped, localID, remoteID).Transform()
)
respondWith(w, http.StatusOK, APIEdge{Metadata: metadata})
})
}
// TODO(pb): temporary hack
func makeDetailed(n report.RenderableNode, of func(string) (Origin, bool)) report.DetailedNode {
// A RenderableNode may be the result of merge operation(s), and so may
// have multiple origins.
origins := []report.Table{}
for _, originID := range n.Origin {
origin, ok := of(originID)
if !ok {
origin = unknownOrigin
}
origins = append(origins, report.Table{
Title: "Origin",
Numeric: false,
Rows: []report.Row{
{"Hostname", origin.Hostname, ""},
{"Load", fmt.Sprintf("%.2f %.2f %.2f", origin.LoadOne, origin.LoadFive, origin.LoadFifteen), ""},
{"OS", origin.OS, ""},
//{"Addresses", strings.Join(origin.Addresses, ", "), ""},
{"ID", originID, ""},
},
})
}
tables := []report.Table{}
tables = append(tables, report.Table{
Title: "Connections",
Numeric: true,
Rows: []report.Row{
{"TCP (max)", strconv.FormatInt(int64(n.Metadata[report.KeyMaxConnCountTCP]), 10), ""},
},
})
tables = append(tables, origins...)
return report.DetailedNode{
ID: n.ID,
LabelMajor: n.LabelMajor,
LabelMinor: n.LabelMinor,
Pseudo: n.Pseudo,
Tables: tables,
}
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func handleWebsocket(
w http.ResponseWriter,
r *http.Request,
rep Reporter,
topo topologySelecter,
mapping report.MapFunc,
grouped bool,
loop time.Duration,
) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
// log.Println("Upgrade:", err)
return
}
defer conn.Close()
quit := make(chan struct{})
go func(c *websocket.Conn) {
// Discard all the browser sends us.
for {
if _, _, err := c.NextReader(); err != nil {
close(quit)
break
}
}
}(conn)
var previousTopo map[string]report.RenderableNode
for {
newTopo := topo(rep.Report()).RenderBy(mapping, grouped)
diff := report.TopoDiff(previousTopo, newTopo)
previousTopo = newTopo
conn.SetWriteDeadline(time.Now().Add(websocketTimeout))
if err := conn.WriteJSON(diff); err != nil {
return
}
select {
case <-quit:
return
case <-time.After(loop):
}
}
}
var unknownOrigin = Origin{
Hostname: "unknown",
OS: "unknown",
Addresses: []string{},
LoadOne: 0.0,
LoadFive: 0.0,
LoadFifteen: 0.0,
}

154
app/api_topology_test.go Normal file
View File

@@ -0,0 +1,154 @@
package main
import (
"encoding/json"
"net/http/httptest"
"reflect"
"testing"
"github.com/gorilla/websocket"
"github.com/weaveworks/scope/scope/report"
)
func TestAPITopologyApplications(t *testing.T) {
ts := httptest.NewServer(Router(StaticReport{}))
defer ts.Close()
is404(t, ts, "/api/topology/applications/foobar")
{
body := getRawJSON(t, ts, "/api/topology/applications")
var topo APITopology
if err := json.Unmarshal(body, &topo); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
equals(t, 4, len(topo.Nodes))
node, ok := topo.Nodes["proc:node-a.local:curl"]
if !ok {
t.Errorf("missing curl node")
}
equals(t, report.NewIDList("proc:node-b.local:apache"), node.Adjacency)
equals(t, report.NewIDList("hostA"), node.Origin)
equals(t, "curl", node.LabelMajor)
equals(t, "node-a.local", node.LabelMinor)
equals(t, "curl", node.Rank)
equals(t, false, node.Pseudo)
}
{
// Node detail
body := getRawJSON(t, ts, "/api/topology/applications/proc:node-a.local:curl")
var node APINode
if err := json.Unmarshal(body, &node); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
// TODO(pb): replace
}
{
// Edge detail
body := getRawJSON(t, ts, "/api/topology/applications/proc:node-a.local:curl/proc:node-b.local:apache")
var edge APIEdge
if err := json.Unmarshal(body, &edge); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
want := report.AggregateMetadata{
"egress_bytes": 24,
"ingress_bytes": 0,
"max_conn_count_tcp": 401,
}
if !reflect.DeepEqual(want, edge.Metadata) {
t.Errorf("Edge metadata error. Want %v, have %v", want, edge)
}
}
}
func TestAPITopologyHosts(t *testing.T) {
ts := httptest.NewServer(Router(StaticReport{}))
defer ts.Close()
is404(t, ts, "/api/topology/hosts/foobar")
{
body := getRawJSON(t, ts, "/api/topology/hosts")
var topo APITopology
if err := json.Unmarshal(body, &topo); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
equals(t, 3, len(topo.Nodes))
node, ok := topo.Nodes["host:host-b"]
if !ok {
t.Errorf("missing host:host-b node")
}
equals(t, report.NewIDList("host:host-a"), node.Adjacency)
equals(t, report.NewIDList("hostB"), node.Origin)
equals(t, "host-b", node.LabelMajor)
equals(t, "", node.LabelMinor)
equals(t, "host-b", node.Rank)
equals(t, false, node.Pseudo)
}
{
// Node detail
body := getRawJSON(t, ts, "/api/topology/hosts/host:host-b")
var node APINode
if err := json.Unmarshal(body, &node); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
// TODO(pb): replace
}
{
// Edge detail
body := getRawJSON(t, ts, "/api/topology/hosts/host:host-b/host:host-a")
var edge APIEdge
if err := json.Unmarshal(body, &edge); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
want := report.AggregateMetadata{
"egress_bytes": 0,
"ingress_bytes": 12,
"max_conn_count_tcp": 16,
}
if !reflect.DeepEqual(want, edge.Metadata) {
t.Errorf("Edge metadata error. Want %v, have %v", want, edge)
}
}
}
// Basic websocket test
func TestAPITopologyWebsocket(t *testing.T) {
ts := httptest.NewServer(Router(StaticReport{}))
defer ts.Close()
url := "/api/topology/applications/ws"
// Not a websocket request:
res, _ := checkGet(t, ts, url)
if have := res.StatusCode; have != 400 {
t.Fatalf("Expected status %d, got %d.", 400, have)
}
// Proper websocket request:
ts.URL = "ws" + ts.URL[len("http"):]
dialer := &websocket.Dialer{}
ws, res, err := dialer.Dial(ts.URL+url, nil)
ok(t, err)
defer ws.Close()
if have := res.StatusCode; have != 101 {
t.Fatalf("Expected status %d, got %d.", 101, have)
}
_, p, err := ws.ReadMessage()
ok(t, err)
var d report.Diff
if err := json.Unmarshal(p, &d); err != nil {
t.Fatalf("JSON parse error: %s", err)
}
equals(t, 4, len(d.Add))
equals(t, 0, len(d.Update))
equals(t, 0, len(d.Remove))
}

94
app/main.go Normal file
View File

@@ -0,0 +1,94 @@
package main
import (
"flag"
"fmt"
"io/ioutil"
"log"
"log/syslog"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/weaveworks/scope/scope/xfer"
)
func main() {
var (
defaultProbes = []string{fmt.Sprintf("localhost:%d", xfer.ProbePort), fmt.Sprintf("scope.weave.local:%d", xfer.ProbePort)}
logfile = flag.String("log", "stderr", "stderr, syslog, or filename")
probes = flag.String("probes", strings.Join(defaultProbes, ","), "list of probe endpoints, comma separated")
batch = flag.Duration("batch", 1*time.Second, "batch interval")
window = flag.Duration("window", 15*time.Second, "window")
pidfile = flag.String("pidfile", "", "write PID file")
listen = flag.String("http.address", ":"+strconv.Itoa(xfer.AppPort), "webserver listen address")
)
flag.Parse()
switch *logfile {
case "stderr":
break // by default
case "syslog":
w, err := syslog.New(syslog.LOG_INFO, "cello-app")
if err != nil {
log.Print(err)
return
}
defer w.Close()
log.SetFlags(0)
log.SetOutput(w)
default: // file
f, err := os.OpenFile(*logfile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
log.Print(err)
return
}
defer f.Close()
log.SetOutput(f)
}
if *pidfile != "" {
err := ioutil.WriteFile(*pidfile, []byte(fmt.Sprint(os.Getpid())), 0644)
if err != nil {
log.Print(err)
return
}
defer os.Remove(*pidfile)
}
log.Printf("starting")
// Collector deals with the probes, and generates merged reports.
xfer.MaxBackoff = 10 * time.Second
c := xfer.NewCollector(*batch)
defer c.Stop()
r := NewResolver(strings.Split(*probes, ","), c.AddAddress)
defer r.Stop()
lifo := NewReportLIFO(c, *window)
defer lifo.Stop()
http.Handle("/", Router(lifo))
irq := interrupt()
go func() {
log.Printf("listening on %s", *listen)
log.Print(http.ListenAndServe(*listen, nil))
irq <- syscall.SIGINT
}()
<-irq
log.Printf("shutting down")
}
func interrupt() chan os.Signal {
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
return c
}

135
app/mock_reporter_test.go Normal file
View File

@@ -0,0 +1,135 @@
package main
import (
"net"
"github.com/weaveworks/scope/scope/report"
)
// StaticReport is used as know test data in api tests.
type StaticReport struct{}
func (s StaticReport) Report() report.Report {
_, localNet, err := net.ParseCIDR("192.168.1.1/24")
if err != nil {
panic(err.Error())
}
var testReport = report.Report{
Process: report.Topology{
Adjacency: report.Adjacency{
"hostA|;192.168.1.1;12345": []string{";192.168.1.2;80"},
"hostA|;192.168.1.1;12346": []string{";192.168.1.2;80"},
"hostA|;192.168.1.1;8888": []string{";1.2.3.4;22"},
"hostB|;192.168.1.2;80": []string{";192.168.1.1;12345"},
},
EdgeMetadatas: report.EdgeMetadatas{
";192.168.1.1;12345|;192.168.1.2;80": report.EdgeMetadata{
WithBytes: true,
BytesEgress: 12,
BytesIngress: 0,
WithConnCountTCP: true,
MaxConnCountTCP: 200,
},
";192.168.1.1;12346|;192.168.1.2;80": report.EdgeMetadata{
WithBytes: true,
BytesEgress: 12,
BytesIngress: 0,
WithConnCountTCP: true,
MaxConnCountTCP: 201,
},
";192.168.1.1;8888|;1.2.3.4;80": report.EdgeMetadata{
WithBytes: true,
BytesEgress: 200,
BytesIngress: 0,
WithConnCountTCP: true,
MaxConnCountTCP: 202,
},
";192.168.1.2;80|;192.168.1.1;12345": report.EdgeMetadata{
WithBytes: true,
BytesEgress: 0,
BytesIngress: 12,
WithConnCountTCP: true,
MaxConnCountTCP: 203,
},
},
NodeMetadatas: report.NodeMetadatas{
";192.168.1.1;12345": report.NodeMetadata{
"pid": "23128",
"name": "curl",
"domain": "node-a.local",
},
";192.168.1.1;12346": report.NodeMetadata{ // <-- same as :12345
"pid": "23128",
"name": "curl",
"domain": "node-a.local",
},
";192.168.1.1;8888": report.NodeMetadata{
"pid": "55100",
"name": "ssh",
"domain": "node-a.local",
},
";192.168.1.2;80": report.NodeMetadata{
"pid": "215",
"name": "apache",
"domain": "node-b.local",
},
},
},
Network: report.Topology{
Adjacency: report.Adjacency{
"hostA|;192.168.1.1": []string{";192.168.1.2", ";1.2.3.4"},
"hostB|;192.168.1.2": []string{";192.168.1.1"},
},
EdgeMetadatas: report.EdgeMetadatas{
";192.168.1.1|;192.168.1.2": report.EdgeMetadata{
WithBytes: true,
BytesEgress: 12,
BytesIngress: 0,
WithConnCountTCP: true,
MaxConnCountTCP: 14,
},
";192.168.1.1|;1.2.3.4": report.EdgeMetadata{
WithBytes: true,
BytesEgress: 200,
BytesIngress: 0,
WithConnCountTCP: true,
MaxConnCountTCP: 15,
},
";192.168.1.2|;192.168.1.1": report.EdgeMetadata{
WithBytes: true,
BytesEgress: 0,
BytesIngress: 12,
WithConnCountTCP: true,
MaxConnCountTCP: 16,
},
},
NodeMetadatas: report.NodeMetadatas{
";192.168.1.1": report.NodeMetadata{
"name": "host-a",
},
";192.168.1.2": report.NodeMetadata{
"name": "host-b",
},
},
},
HostMetadatas: report.HostMetadatas{
"hostA": report.HostMetadata{
Hostname: "node-a.local",
LocalNets: []*net.IPNet{localNet},
OS: "Linux",
LoadOne: 3.1415,
LoadFive: 2.7182,
LoadFifteen: 1.6180,
},
"hostB": report.HostMetadata{
Hostname: "node-b.local",
LocalNets: []*net.IPNet{localNet},
OS: "Linux",
},
},
}
return testReport.SquashRemote()
}

92
app/report_lifo.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"time"
"github.com/weaveworks/scope/scope/report"
)
// Reporter is something which generates a single 'current' report over a
// stream of incoming reports.
type Reporter interface {
Report() report.Report
}
type timedReport struct {
report.Report
Timestamp time.Time
}
// ReportLIFO keeps a short-term history of reports.
type ReportLIFO struct {
reports []timedReport
requests chan chan report.Report
quit chan chan struct{}
}
type reporter interface {
Reports() <-chan report.Report
}
// NewReportLIFO collects reports up to a certain age.
func NewReportLIFO(r reporter, maxAge time.Duration) *ReportLIFO {
l := ReportLIFO{
reports: []timedReport{},
requests: make(chan chan report.Report),
quit: make(chan chan struct{}),
}
go func() {
for {
select {
case report := <-r.Reports():
// Incoming report from the collecter.
report = report.SquashRemote() // TODO?: make this a CLI argument.
tr := timedReport{
Timestamp: time.Now(),
Report: report,
}
l.reports = append(l.reports, tr)
l.reports = cleanOld(l.reports, time.Now().Add(-maxAge))
case req := <-l.requests:
// Request for the current report.
report := report.NewReport()
for _, r := range l.reports {
report.Merge(r.Report)
}
req <- report
case q := <-l.quit:
close(q)
return
}
}
}()
return &l
}
// Stop shuts down the monitor.
func (r *ReportLIFO) Stop() {
q := make(chan struct{})
r.quit <- q
<-q
}
// Report returns the latest report.
func (r *ReportLIFO) Report() report.Report {
req := make(chan report.Report)
r.requests <- req
return <-req
}
func cleanOld(reports []timedReport, threshold time.Time) []timedReport {
res := make([]timedReport, 0, len(reports))
for _, tr := range reports {
if tr.Timestamp.Before(threshold) {
continue
}
res = append(res, tr)
}
return res
}

86
app/resolver.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"log"
"net"
"time"
)
// Resolver periodically tries to resolve the IP addresses for a given
// set of hostnames.
type Resolver struct {
quit chan struct{}
add func(string)
peers []peer
}
type peer struct {
hostname string
port string
}
// NewResolver starts a new resolver that periodically
// tries to resolve peers and the calls add() with all the
// resolved IPs. It explictiy supports hostnames which
// resolve to multiple IPs; it will repeatedly call
// add with the same IP, expecting the target to dedupe.
func NewResolver(peers []string, add func(string)) Resolver {
resolver := Resolver{
quit: make(chan struct{}),
add: add,
peers: prepareNames(peers),
}
go resolver.loop()
return resolver
}
func prepareNames(peers []string) []peer {
var results []peer
for _, p := range peers {
hostname, port, err := net.SplitHostPort(p)
if err != nil {
log.Printf("invalid address %s: %v", p, err)
continue
}
results = append(results, peer{hostname, port})
}
return results
}
func (r Resolver) loop() {
r.resolveHosts()
for {
tick := time.Tick(1 * time.Minute)
select {
case <-tick:
r.resolveHosts()
case <-r.quit:
return
}
}
}
func (r Resolver) resolveHosts() {
for _, peer := range r.peers {
addrs, err := net.LookupIP(peer.hostname)
if err != nil {
log.Printf("lookup %s: %v", peer.hostname, err)
continue
}
for _, addr := range addrs {
// For now, ignore IPv6
if addr.To4() == nil {
continue
}
r.add(net.JoinHostPort(addr.String(), peer.port))
}
}
}
// Stop this resolver.
func (r Resolver) Stop() {
r.quit <- struct{}{}
}

52
app/router.go Normal file
View File

@@ -0,0 +1,52 @@
// HTTP server routing table
package main
import (
"net/http"
"github.com/gorilla/mux"
"github.com/weaveworks/scope/scope/report"
)
// Router gives of the HTTP dispatcher. It will always use the embedded HTML
// resources.
func Router(c Reporter) *mux.Router {
router := mux.NewRouter()
get := router.Methods("GET").Subrouter()
get.HandleFunc("/api/topology", makeTopologyList(c))
for name, def := range topologyRegistry {
makeTopologyHandlers(
c,
def.topologySelecter,
def.MapFunc,
false, // not grouped
get,
"/api/topology/"+name,
)
if def.hasGrouped {
makeTopologyHandlers(
c,
def.topologySelecter,
def.MapFunc,
true, // grouped
get,
"/api/topology/"+name+"grouped",
)
}
}
get.HandleFunc("/api/origin/{id}", makeOriginHandler(c))
get.HandleFunc("/api/report", makeRawReportHandler(c))
get.PathPrefix("/").Handler(http.FileServer(FS(false))) // everything else is static
return router
}
var topologyRegistry = map[string]struct {
human string
topologySelecter
report.MapFunc
hasGrouped bool
typ string
}{
"applications": {"Applications", selectProcess, report.ProcessName, true, "Process"},
"hosts": {"Hosts", selectNetwork, report.NetworkHostname, false, "Network"},
}

121
app/scope_test.go Normal file
View File

@@ -0,0 +1,121 @@
package main
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"runtime"
"testing"
)
// assert fails the test if the condition is false.
func assert(tb testing.TB, condition bool, msg string, v ...interface{}) {
if !condition {
_, file, line, _ := runtime.Caller(1)
fmt.Printf("%s:%d: "+msg+"\n", append([]interface{}{filepath.Base(file), line}, v...)...)
tb.FailNow()
}
}
// ok fails the test if an err is not nil.
func ok(tb testing.TB, err error) {
if err != nil {
_, file, line, _ := runtime.Caller(1)
fmt.Printf("%s:%d: unexpected error: %s\n", filepath.Base(file), line, err.Error())
tb.FailNow()
}
}
// equals fails the test if exp is not equal to act.
func equals(tb testing.TB, exp, act interface{}) {
if !reflect.DeepEqual(exp, act) {
_, file, line, _ := runtime.Caller(1)
fmt.Printf("%s:%d: expected: %#v got: %#v\n", filepath.Base(file), line, exp, act)
tb.FailNow()
}
}
// checkGet does a GET and returns the response and the body
func checkGet(t *testing.T, ts *httptest.Server, path string) (*http.Response, []byte) {
return checkRequest(t, ts, "GET", path, nil)
}
// checkRequest does a 'method'-request (e.g. 'GET') and returns the response and the body
func checkRequest(t *testing.T, ts *httptest.Server, method, path string, body []byte) (*http.Response, []byte) {
fullPath := ts.URL + path
var bodyReader io.Reader
if len(body) > 0 {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, fullPath, bodyReader)
if err != nil {
t.Fatalf("Error getting %s: %s", path, err)
}
client := &http.Client{}
res, err := client.Do(req)
if err != nil {
t.Fatalf("Error getting %s: %s", path, err)
}
body, err = ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatalf("%s body read error: %s", path, err)
}
return res, body
}
// getRawJSON GETs a file, checks it is JSON, and returns the non-parsed body
func getRawJSON(t *testing.T, ts *httptest.Server, path string) []byte {
res, body := checkGet(t, ts, path)
if res.StatusCode != 200 {
t.Fatalf("Expected status %d, got %d. Path: %s", 200, res.StatusCode, path)
}
foundCtype := res.Header.Get("content-type")
if foundCtype != "application/json" {
t.Errorf("Wrong Content-type for JSON: %s", foundCtype)
}
if len(body) == 0 {
t.Errorf("No response body")
}
// fmt.Printf("Body: %s", body)
return body
}
// is200 GETs path and verifies the status code. Returns the body
func is200(t *testing.T, ts *httptest.Server, path string) []byte {
res, body := checkGet(t, ts, path)
if res.StatusCode != 200 {
t.Fatalf("Expected status %d, got %d. Path: %s", 200, res.StatusCode, path)
}
return body
}
// is404 GETs path and verifies it returns a 404 status code. Returns the body
func is404(t *testing.T, ts *httptest.Server, path string) []byte {
res, body := checkGet(t, ts, path)
if res.StatusCode != 404 {
t.Fatalf("Expected status %d, got %d", 404, res.StatusCode)
}
return body
}
// is400 GETs path and verifies it returns a 400 status code. Returns the body
func is400(t *testing.T, ts *httptest.Server, path string) []byte {
res, body := checkGet(t, ts, path)
if res.StatusCode != 400 {
t.Fatalf("Expected status %d, got %d", 400, res.StatusCode)
}
return body
}

13
app/server_helpers.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"encoding/json"
"net/http"
)
func respondWith(w http.ResponseWriter, code int, response interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(code)
json.NewEncoder(w).Encode(response)
}

17
app/site_test.go Normal file
View File

@@ -0,0 +1,17 @@
// Basic site layout tests.
package main
import (
"net/http/httptest"
"testing"
)
// Test site
func TestSite(t *testing.T) {
ts := httptest.NewServer(Router(StaticReport{}))
defer ts.Close()
is200(t, ts, "/")
is200(t, ts, "/index.html")
is404(t, ts, "/index.html/foobar")
}

6113
app/static.go Normal file

File diff suppressed because it is too large Load Diff

19
bridge/Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: all vet lint build test clean
all: build test vet lint
vet:
go vet ./...
lint:
golint .
build:
go build
test:
go test
clean:
go clean

204
bridge/main.go Normal file
View File

@@ -0,0 +1,204 @@
package main
import (
"flag"
"log"
"net"
_ "net/http/pprof"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/weaveworks/scope/scope/report"
"github.com/weaveworks/scope/scope/xfer"
)
const (
trafficTimeout = 2 * time.Minute
)
func main() {
var (
listen = flag.String("listen", ":"+strconv.Itoa(xfer.ProbePort), "listen address")
probes = flag.String("probes", "", "list of all initial probes, comma separated")
batch = flag.Duration("batch", 1*time.Second, "batch interval")
version = flag.Bool("version", false, "print version number and exit")
)
flag.Parse()
if len(flag.Args()) != 0 {
flag.Usage()
os.Exit(1)
}
if *version {
//fmt.Printf("%s\n", probe.Version)
return
}
if *probes == "" {
log.Fatal("no probes given via -probes")
}
log.Printf("starting")
fixedAddresses := strings.Split(*probes, ",")
// Collector deals with the probes, and generates a single merged report
// every second.
c := xfer.NewCollector(*batch)
c.AddAddresses(fixedAddresses)
defer c.Stop()
publisher, err := xfer.NewTCPPublisher(*listen)
if err != nil {
log.Fatal(err)
}
defer publisher.Close()
log.Printf("listening on %s\n", *listen)
var fixedIPs []string
for _, a := range fixedAddresses {
if addr, _, err := net.SplitHostPort(a); err == nil {
fixedIPs = append(fixedIPs, addr)
}
}
go discover(c, publisher, fixedIPs)
<-interrupt()
log.Printf("shutting down")
}
func interrupt() chan os.Signal {
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
return c
}
type collector interface {
Reports() <-chan report.Report
RemoveAddress(string)
AddAddress(string)
}
type publisher xfer.Publisher
// makeAvoid makes a map with IPs we don't want to consider in discover(). It
// is the set of IPs which the bridge is configured to connect to, and the all
// the IPs from the local interfaces.
func makeAvoid(fixed []string) map[string]struct{} {
avoid := map[string]struct{}{}
// Don't discover fixed probes. This way we'll never remove them.
for _, a := range fixed {
avoid[a] = struct{}{}
}
// Don't go Ouroboros.
if localNets, err := net.InterfaceAddrs(); err == nil {
for _, n := range localNets {
if net, ok := n.(*net.IPNet); ok {
avoid[net.IP.String()] = struct{}{}
}
}
}
return avoid
}
// discover reads reports from a collector and republishes them on the
// publisher, while scanning the reports for IPs to connect to. Only addresses
// in the network topology of the report are considered. IPs listed in fixed
// are always skipped.
func discover(c collector, p publisher, fixed []string) {
lastSeen := map[string]time.Time{}
avoid := makeAvoid(fixed)
for r := range c.Reports() {
// log.Printf("got a report")
p.Publish(r)
var (
now = time.Now()
localNets = r.LocalNets()
)
for _, adjacent := range r.Network.Adjacency {
for _, a := range adjacent {
ip := report.AddressIP(a) // address id -> IP
if ip == nil {
continue
}
addr := ip.String()
if _, ok := avoid[addr]; ok {
continue
}
// log.Printf("potential address: %v (via %s)", addr, src)
if _, ok := lastSeen[addr]; !ok {
if interestingAddress(localNets, addr) {
log.Printf("discovery %v: potential probe address", addr)
c.AddAddress(addressToDial(addr))
} else {
log.Printf("discovery %v: non-probe address", addr)
}
}
// We always add addr to lastSeen[], even if it's a non-local
// address. This way they are part of the normal timeout logic,
// and we won't analyze the address again until it re-appears
// after a timeout.
lastSeen[addr] = now
}
}
for addr, last := range lastSeen {
if now.Sub(last) > trafficTimeout {
// Timeout can be for a non-local address, which we didn't
// connect to. In that case the RemoveAddress() call won't do
// anything.
log.Printf("discovery %v: traffic timeout", addr)
delete(lastSeen, addr)
c.RemoveAddress(addressToDial(addr))
}
}
}
}
// interestingAddress tells whether the address is a local and normal address,
// which we want to try to connect to.
func interestingAddress(localNets []*net.IPNet, addr string) bool {
if addr == "" {
return false
}
// The address is expected to be an IPv{4,6} address.
ip := net.ParseIP(addr)
if ip == nil {
return false
}
// Filter out localhost, broadcast, and other non-connectable addresses.
if !validateRemoteAddr(ip) {
return false
}
// Only connect to addresses we know are localnet.
for _, n := range localNets {
if n.Contains(ip) {
return true
}
}
return false
}
// addressToDial formats an IP address so we can pass it on to Dial().
func addressToDial(address string) string {
// return fmt.Sprintf("[%s]:%d", addr, probePort)
return net.JoinHostPort(address, strconv.Itoa(xfer.ProbePort))
}

71
bridge/net.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"net"
)
func validateRemoteAddr(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsInterfaceLocalMulticast() {
return false
}
if ip.IsLinkLocalMulticast() {
return false
}
if ip.IsLinkLocalUnicast() {
return false
}
if ip.IsLoopback() {
return false
}
if ip.IsMulticast() {
return false
}
if ip.IsUnspecified() {
return false
}
if isBroadcasty(ip) {
return false
}
return true
}
func isBroadcasty(ip net.IP) bool {
if ip4 := ip.To4(); ip4 != nil {
if ip4.Equal(net.IPv4bcast) {
return true
}
if ip4.Equal(net.IPv4allsys) {
return true
}
if ip4.Equal(net.IPv4allrouter) {
return true
}
if ip4.Equal(net.IPv4zero) {
return true
}
return false
}
if ip.Equal(net.IPv6zero) {
return true
}
if ip.Equal(net.IPv6unspecified) {
return true
}
if ip.Equal(net.IPv6loopback) {
return true
}
if ip.Equal(net.IPv6interfacelocalallnodes) {
return true
}
if ip.Equal(net.IPv6linklocalallnodes) {
return true
}
if ip.Equal(net.IPv6linklocalallrouters) {
return true
}
return false
}

41
bridge/net_test.go Normal file
View File

@@ -0,0 +1,41 @@
package main
import (
"net"
"testing"
)
func TestValidRemoteAddr(t *testing.T) {
for input, expected := range map[string]bool{
"localhost": false,
"127.0.0.1": false, // should be same as loopback
"127.1.2.3": false, // 127.0.0.0/8 is all loopback
"0.0.0.0": false,
"224.0.0.1": false, // all systems
"224.0.0.2": false, // all routers
"224.0.0.22": false,
"255.255.255.255": false, // broadcast
"1.2.3.4": true,
"::": false, // unspecified
"0:0:0:0:0:0:0:0": false, // unspecified (alternate form)
"b8:27:eb:a4:bf:6e": false,
"fe80::1240:f3ff:fe80:5474": false, // loopback
"fe80::1": false, // loopback
"::1": false, // loopback
"0:0:0:0:0:0:0:1": false, // loopback (alternate form)
"2001:db8::1:0:0:1": true, // valid
// http://www.iana.org/assignments/ipv6-multicast-addresses/ipv6-multicast-addresses.xhtml
"FF01:0:0:0:0:0:0:1": false, // Node-local all-nodes
// "FF01:0:0:0:0:0:0:2": false, // Node-local all-routers, isn't spec'd
// in package net/ip
"FF02:0:0:0:0:0:0:1": false, // Link-local all-nodes
"FF02:0:0:0:0:0:0:2": false, // Link-local all-routers
} {
if got := validateRemoteAddr(net.ParseIP(input)); expected != got {
t.Errorf("%s: expected %v, got %v", input, expected, got)
}
}
}

29
client/.editorconfig Normal file
View File

@@ -0,0 +1,29 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 4
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[package.json]
indent_style = space
indent_size = 2
[bower.json]
indent_style = space
indent_size = 2

1
client/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto

6
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.tmp
.sass-cache
bower_components
test/bower_components

21
client/.jshintrc Normal file
View File

@@ -0,0 +1,21 @@
{
"node": true,
"browser": true,
"esnext": true,
"bitwise": true,
"camelcase": true,
"curly": true,
"eqeqeq": true,
"immed": true,
"indent": 4,
"latedef": true,
"newcap": true,
"noarg": true,
"quotmark": "single",
"undef": true,
"unused": true,
"strict": true,
"trailing": true,
"smarttabs": true,
"jquery": true
}

3
client/.yo-rc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"generator-mocha": {}
}

11
client/Makefile Normal file
View File

@@ -0,0 +1,11 @@
.PHONY: run builddep build
run:
npm start
builddep:
npm install imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-pngquant
build:
rm -rf ./dist/
npm install
gulp build

32
client/README.md Normal file
View File

@@ -0,0 +1,32 @@
# Web app template
> Template based on [Yeoman](http://yeoman.io) generator [generator-gulp-webapp](https://github.com/yeoman/generator-gulp-webapp) using [gulp](http://gulpjs.com/) for the build process
## Features
Please see our [gulpfile.js](gulpfile.js) for up to date information on what we support.
* CSS Autoprefixing
* Built-in preview server with livereload
* Automagically compile Sass
* Automagically lint your scripts
* Awesome image optimization
* Automagically wire-up dependencies installed with [Bower](http://bower.io) *(when `gulp watch` or `gulp wiredep`)*
## Getting Started
- Install: `npm install`
- Run `gulp` for building to the `dist` directory and `gulp serve` for preview
#### Third-Party Dependencies
*(HTML/CSS/JS/Images/etc)*
To install dependencies, run `bower install --save package-name` to get the files, then add a `script` or `style` tag to your `index.html` or another appropriate place.
## License
[BSD license](http://opensource.org/licenses/bsd-license.php)

663
client/app/.htaccess Normal file
View File

@@ -0,0 +1,663 @@
# Apache Server Configs v2.2.0 | MIT License
# https://github.com/h5bp/server-configs-apache
# (!) Using `.htaccess` files slows down Apache, therefore, if you have access
# to the main server config file (usually called `httpd.conf`), you should add
# this logic there: http://httpd.apache.org/docs/current/howto/htaccess.html.
# ##############################################################################
# # CROSS-ORIGIN RESOURCE SHARING (CORS) #
# ##############################################################################
# ------------------------------------------------------------------------------
# | Cross-domain AJAX requests |
# ------------------------------------------------------------------------------
# Allow cross-origin AJAX requests.
# http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity
# http://enable-cors.org/
# <IfModule mod_headers.c>
# Header set Access-Control-Allow-Origin "*"
# </IfModule>
# ------------------------------------------------------------------------------
# | CORS-enabled images |
# ------------------------------------------------------------------------------
# Send the CORS header for images when browsers request it.
# https://developer.mozilla.org/en-US/docs/HTML/CORS_Enabled_Image
# http://blog.chromium.org/2011/07/using-cross-domain-images-in-webgl-and.html
# http://hacks.mozilla.org/2011/11/using-cors-to-load-webgl-textures-from-cross-domain-images/
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
<FilesMatch "\.(cur|gif|ico|jpe?g|png|svgz?|webp)$">
SetEnvIf Origin ":" IS_CORS
Header set Access-Control-Allow-Origin "*" env=IS_CORS
</FilesMatch>
</IfModule>
</IfModule>
# ------------------------------------------------------------------------------
# | Web fonts access |
# ------------------------------------------------------------------------------
# Allow access to web fonts from all domains.
<IfModule mod_headers.c>
<FilesMatch "\.(eot|otf|tt[cf]|woff)$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
</IfModule>
# ##############################################################################
# # ERRORS #
# ##############################################################################
# ------------------------------------------------------------------------------
# | 404 error prevention for non-existing redirected folders |
# ------------------------------------------------------------------------------
# Prevent Apache from returning a 404 error as the result of a rewrite
# when the directory with the same name does not exist.
# http://httpd.apache.org/docs/current/content-negotiation.html#multiviews
# http://www.webmasterworld.com/apache/3808792.htm
Options -MultiViews
# ------------------------------------------------------------------------------
# | Custom error messages / pages |
# ------------------------------------------------------------------------------
# Customize what Apache returns to the client in case of an error.
# http://httpd.apache.org/docs/current/mod/core.html#errordocument
ErrorDocument 404 /404.html
# ##############################################################################
# # INTERNET EXPLORER #
# ##############################################################################
# ------------------------------------------------------------------------------
# | Better website experience |
# ------------------------------------------------------------------------------
# Force Internet Explorer to render pages in the highest available mode
# in the various cases when it may not.
# http://hsivonen.iki.fi/doctype/ie-mode.pdf
<IfModule mod_headers.c>
Header set X-UA-Compatible "IE=edge"
# `mod_headers` cannot match based on the content-type, however, this
# header should be send only for HTML pages and not for the other resources
<FilesMatch "\.(appcache|atom|crx|css|cur|eot|f4[abpv]|flv|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|tt[cf]|vcf|vtt|webapp|web[mp]|woff|xml|xpi)$">
Header unset X-UA-Compatible
</FilesMatch>
</IfModule>
# ------------------------------------------------------------------------------
# | Cookie setting from iframes |
# ------------------------------------------------------------------------------
# Allow cookies to be set from iframes in Internet Explorer.
# http://msdn.microsoft.com/en-us/library/ms537343.aspx
# http://www.w3.org/TR/2000/CR-P3P-20001215/
# <IfModule mod_headers.c>
# Header set P3P "policyref=\"/w3c/p3p.xml\", CP=\"IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT\""
# </IfModule>
# ##############################################################################
# # MIME TYPES AND ENCODING #
# ##############################################################################
# ------------------------------------------------------------------------------
# | Proper MIME types for all files |
# ------------------------------------------------------------------------------
<IfModule mod_mime.c>
# Audio
AddType audio/mp4 m4a f4a f4b
AddType audio/ogg oga ogg opus
# Data interchange
AddType application/json json map
AddType application/ld+json jsonld
# JavaScript
# Normalize to standard type.
# http://tools.ietf.org/html/rfc4329#section-7.2
AddType application/javascript js
# Video
AddType video/mp4 f4v f4p m4v mp4
AddType video/ogg ogv
AddType video/webm webm
AddType video/x-flv flv
# Web fonts
AddType application/font-woff woff
AddType application/vnd.ms-fontobject eot
# Browsers usually ignore the font MIME types and simply sniff the bytes
# to figure out the font type.
# http://mimesniff.spec.whatwg.org/#matching-a-font-type-pattern
# Chrome however, shows a warning if any other MIME types are used for
# the following fonts.
AddType application/x-font-ttf ttc ttf
AddType font/opentype otf
# Make SVGZ fonts work on the iPad.
# https://twitter.com/FontSquirrel/status/14855840545
AddType image/svg+xml svgz
AddEncoding gzip svgz
# Other
AddType application/octet-stream safariextz
AddType application/x-chrome-extension crx
AddType application/x-opera-extension oex
AddType application/x-web-app-manifest+json webapp
AddType application/x-xpinstall xpi
AddType application/xml atom rdf rss xml
AddType image/webp webp
AddType image/x-icon cur
AddType text/cache-manifest appcache manifest
AddType text/vtt vtt
AddType text/x-component htc
AddType text/x-vcard vcf
</IfModule>
# ------------------------------------------------------------------------------
# | UTF-8 encoding |
# ------------------------------------------------------------------------------
# Use UTF-8 encoding for anything served as `text/html` or `text/plain`.
AddDefaultCharset utf-8
# Force UTF-8 for certain file formats.
<IfModule mod_mime.c>
AddCharset utf-8 .atom .css .js .json .jsonld .rss .vtt .webapp .xml
</IfModule>
# ##############################################################################
# # URL REWRITES #
# ##############################################################################
# ------------------------------------------------------------------------------
# | Rewrite engine |
# ------------------------------------------------------------------------------
# Turn on the rewrite engine and enable the `FollowSymLinks` option (this is
# necessary in order for the following directives to work).
# If your web host doesn't allow the `FollowSymlinks` option, you may need to
# comment it out and use `Options +SymLinksIfOwnerMatch`, but be aware of the
# performance impact.
# http://httpd.apache.org/docs/current/misc/perf-tuning.html#symlinks
# Also, some cloud hosting services require `RewriteBase` to be set.
# http://www.rackspace.com/knowledge_center/frequently-asked-question/why-is-mod-rewrite-not-working-on-my-site
<IfModule mod_rewrite.c>
Options +FollowSymlinks
# Options +SymLinksIfOwnerMatch
RewriteEngine On
# RewriteBase /
</IfModule>
# ------------------------------------------------------------------------------
# | Suppressing / Forcing the `www.` at the beginning of URLs |
# ------------------------------------------------------------------------------
# The same content should never be available under two different URLs,
# especially not with and without `www.` at the beginning. This can cause
# SEO problems (duplicate content), and therefore, you should choose one
# of the alternatives and redirect the other one.
# By default `Option 1` (no `www.`) is activated.
# http://no-www.org/faq.php?q=class_b
# If you would prefer to use `Option 2`, just comment out all the lines
# from `Option 1` and uncomment the ones from `Option 2`.
# IMPORTANT: NEVER USE BOTH RULES AT THE SAME TIME!
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Option 1: rewrite www.example.com → example.com
<IfModule mod_rewrite.c>
RewriteCond %{HTTPS} !=on
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L]
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Option 2: rewrite example.com → www.example.com
# Be aware that the following might not be a good idea if you use "real"
# subdomains for certain parts of your website.
# <IfModule mod_rewrite.c>
# RewriteCond %{HTTPS} !=on
# RewriteCond %{HTTP_HOST} !^www\. [NC]
# RewriteCond %{SERVER_ADDR} !=127.0.0.1
# RewriteCond %{SERVER_ADDR} !=::1
# RewriteRule ^ http://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# </IfModule>
# ##############################################################################
# # SECURITY #
# ##############################################################################
# ------------------------------------------------------------------------------
# | Clickjacking |
# ------------------------------------------------------------------------------
# Protect website against clickjacking.
# The example below sends the `X-Frame-Options` response header with the value
# `DENY`, informing browsers not to display the web page content in any frame.
# This might not be the best setting for everyone. You should read about the
# other two possible values for `X-Frame-Options`: `SAMEORIGIN` & `ALLOW-FROM`.
# http://tools.ietf.org/html/rfc7034#section-2.1
# Keep in mind that while you could send the `X-Frame-Options` header for all
# of your sites pages, this has the potential downside that it forbids even
# non-malicious framing of your content (e.g.: when users visit your site using
# a Google Image Search results page).
# Nonetheless, you should ensure that you send the `X-Frame-Options` header for
# all pages that allow a user to make a state changing operation (e.g: pages
# that contain one-click purchase links, checkout or bank-transfer confirmation
# pages, pages that make permanent configuration changes, etc.).
# Sending the `X-Frame-Options` header can also protect your website against
# more than just clickjacking attacks: https://cure53.de/xfo-clickjacking.pdf.
# http://tools.ietf.org/html/rfc7034
# http://blogs.msdn.com/b/ieinternals/archive/2010/03/30/combating-clickjacking-with-x-frame-options.aspx
# https://www.owasp.org/index.php/Clickjacking
# <IfModule mod_headers.c>
# Header set X-Frame-Options "DENY"
# <FilesMatch "\.(appcache|atom|crx|css|cur|eot|f4[abpv]|flv|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|tt[cf]|vcf|vtt|webapp|web[mp]|woff|xml|xpi)$">
# Header unset X-Frame-Options
# </FilesMatch>
# </IfModule>
# ------------------------------------------------------------------------------
# | Content Security Policy (CSP) |
# ------------------------------------------------------------------------------
# Mitigate the risk of cross-site scripting and other content-injection attacks.
# This can be done by setting a `Content Security Policy` which whitelists
# trusted sources of content for your website.
# The example header below allows ONLY scripts that are loaded from the current
# site's origin (no inline scripts, no CDN, etc). This almost certainly won't
# work as-is for your site!
# For more details on how to craft a reasonable policy for your site, read:
# http://html5rocks.com/en/tutorials/security/content-security-policy (or the
# specification: http://w3.org/TR/CSP). Also, to make things easier, you can
# use an online CSP header generator such as: http://cspisawesome.com/.
# <IfModule mod_headers.c>
# Header set Content-Security-Policy "script-src 'self'; object-src 'self'"
# <FilesMatch "\.(appcache|atom|crx|css|cur|eot|f4[abpv]|flv|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|tt[cf]|vcf|vtt|webapp|web[mp]|woff|xml|xpi)$">
# Header unset Content-Security-Policy
# </FilesMatch>
# </IfModule>
# ------------------------------------------------------------------------------
# | File access |
# ------------------------------------------------------------------------------
# Block access to directories without a default document.
# You should leave the following uncommented, as you shouldn't allow anyone to
# surf through every directory on your server (which may includes rather private
# places such as the CMS's directories).
<IfModule mod_autoindex.c>
Options -Indexes
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Block access to hidden files and directories.
# This includes directories used by version control systems such as Git and SVN.
<IfModule mod_rewrite.c>
RewriteCond %{SCRIPT_FILENAME} -d [OR]
RewriteCond %{SCRIPT_FILENAME} -f
RewriteRule "(^|/)\." - [F]
</IfModule>
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Block access to files that can expose sensitive information.
# By default, block access to backup and source files that may be left by some
# text editors and can pose a security risk when anyone has access to them.
# http://feross.org/cmsploit/
# IMPORTANT: Update the `<FilesMatch>` regular expression from below to include
# any files that might end up on your production server and can expose sensitive
# information about your website. These files may include: configuration files,
# files that contain metadata about the project (e.g.: project dependencies),
# build scripts, etc..
<FilesMatch "(^#.*#|\.(bak|config|dist|fla|in[ci]|log|psd|sh|sql|sw[op])|~)$">
# Apache < 2.3
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
Satisfy All
</IfModule>
# Apache ≥ 2.3
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
</FilesMatch>
# ------------------------------------------------------------------------------
# | Reducing MIME-type security risks |
# ------------------------------------------------------------------------------
# Prevent some browsers from MIME-sniffing the response.
# This reduces exposure to drive-by download attacks and should be enable
# especially if the web server is serving user uploaded content, content
# that could potentially be treated by the browser as executable.
# http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx
# http://msdn.microsoft.com/en-us/library/ie/gg622941.aspx
# http://mimesniff.spec.whatwg.org/
# <IfModule mod_headers.c>
# Header set X-Content-Type-Options "nosniff"
# </IfModule>
# ------------------------------------------------------------------------------
# | Reflected Cross-Site Scripting (XSS) attacks |
# ------------------------------------------------------------------------------
# (1) Try to re-enable the Cross-Site Scripting (XSS) filter built into the
# most recent web browsers.
#
# The filter is usually enabled by default, but in some cases it may be
# disabled by the user. However, in Internet Explorer for example, it can
# be re-enabled just by sending the `X-XSS-Protection` header with the
# value of `1`.
#
# (2) Prevent web browsers from rendering the web page if a potential reflected
# (a.k.a non-persistent) XSS attack is detected by the filter.
#
# By default, if the filter is enabled and browsers detect a reflected
# XSS attack, they will attempt to block the attack by making the smallest
# possible modifications to the returned web page.
#
# Unfortunately, in some browsers (e.g.: Internet Explorer), this default
# behavior may allow the XSS filter to be exploited, thereby, it's better
# to tell browsers to prevent the rendering of the page altogether, instead
# of attempting to modify it.
#
# http://hackademix.net/2009/11/21/ies-xss-filter-creates-xss-vulnerabilities
#
# IMPORTANT: Do not rely on the XSS filter to prevent XSS attacks! Ensure that
# you are taking all possible measures to prevent XSS attacks, the most obvious
# being: validating and sanitizing your site's inputs.
#
# http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-iv-the-xss-filter.aspx
# http://blogs.msdn.com/b/ieinternals/archive/2011/01/31/controlling-the-internet-explorer-xss-filter-with-the-x-xss-protection-http-header.aspx
# https://www.owasp.org/index.php/Cross-site_Scripting_%28XSS%29
# <IfModule mod_headers.c>
# # (1) (2)
# Header set X-XSS-Protection "1; mode=block"
# <FilesMatch "\.(appcache|atom|crx|css|cur|eot|f4[abpv]|flv|gif|htc|ico|jpe?g|js|json(ld)?|m4[av]|manifest|map|mp4|oex|og[agv]|opus|otf|pdf|png|rdf|rss|safariextz|svgz?|swf|tt[cf]|vcf|vtt|webapp|web[mp]|woff|xml|xpi)$">
# Header unset X-XSS-Protection
# </FilesMatch>
# </IfModule>
# ------------------------------------------------------------------------------
# | Secure Sockets Layer (SSL) |
# ------------------------------------------------------------------------------
# Rewrite secure requests properly in order to prevent SSL certificate warnings.
# E.g.: prevent `https://www.example.com` when your certificate only allows
# `https://secure.example.com`.
# <IfModule mod_rewrite.c>
# RewriteCond %{SERVER_PORT} !^443
# RewriteRule ^ https://example-domain-please-change-me.com%{REQUEST_URI} [R=301,L]
# </IfModule>
# ------------------------------------------------------------------------------
# | HTTP Strict Transport Security (HSTS) |
# ------------------------------------------------------------------------------
# Force client-side SSL redirection.
# If a user types `example.com` in his browser, the above rule will redirect
# him to the secure version of the site. That still leaves a window of
# opportunity (the initial HTTP connection) for an attacker to downgrade or
# redirect the request.
# The following header ensures that browser will ONLY connect to your server
# via HTTPS, regardless of what the users type in the address bar.
# http://tools.ietf.org/html/draft-ietf-websec-strict-transport-sec-14#section-6.1
# http://www.html5rocks.com/en/tutorials/security/transport-layer-security/
# IMPORTANT: Remove the `includeSubDomains` optional directive if the subdomains
# are not using HTTPS.
# <IfModule mod_headers.c>
# Header set Strict-Transport-Security "max-age=16070400; includeSubDomains"
# </IfModule>
# ------------------------------------------------------------------------------
# | Server software information |
# ------------------------------------------------------------------------------
# Avoid displaying the exact Apache version number, the description of the
# generic OS-type and the information about Apache's compiled-in modules.
# ADD THIS DIRECTIVE IN THE `httpd.conf` AS IT WILL NOT WORK IN THE `.htaccess`!
# ServerTokens Prod
# ##############################################################################
# # WEB PERFORMANCE #
# ##############################################################################
# ------------------------------------------------------------------------------
# | Compression |
# ------------------------------------------------------------------------------
<IfModule mod_deflate.c>
# Force compression for mangled headers.
# http://developer.yahoo.com/blogs/ydn/posts/2010/12/pushing-beyond-gzipping
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding
RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding
</IfModule>
</IfModule>
# Compress all output labeled with one of the following MIME-types
# (for Apache versions below 2.3.7, you don't need to enable `mod_filter`
# and can remove the `<IfModule mod_filter.c>` and `</IfModule>` lines
# as `AddOutputFilterByType` is still in the core directives).
<IfModule mod_filter.c>
AddOutputFilterByType DEFLATE application/atom+xml \
application/javascript \
application/json \
application/ld+json \
application/rss+xml \
application/vnd.ms-fontobject \
application/x-font-ttf \
application/x-web-app-manifest+json \
application/xhtml+xml \
application/xml \
font/opentype \
image/svg+xml \
image/x-icon \
text/css \
text/html \
text/plain \
text/x-component \
text/xml
</IfModule>
</IfModule>
# ------------------------------------------------------------------------------
# | Content transformations |
# ------------------------------------------------------------------------------
# Prevent mobile network providers from modifying the website's content.
# http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.5.
# <IfModule mod_headers.c>
# Header set Cache-Control "no-transform"
# </IfModule>
# ------------------------------------------------------------------------------
# | ETags |
# ------------------------------------------------------------------------------
# Remove `ETags` as resources are sent with far-future expires headers.
# http://developer.yahoo.com/performance/rules.html#etags.
# `FileETag None` doesn't work in all cases.
<IfModule mod_headers.c>
Header unset ETag
</IfModule>
FileETag None
# ------------------------------------------------------------------------------
# | Expires headers |
# ------------------------------------------------------------------------------
# The following expires headers are set pretty far in the future. If you
# don't control versioning with filename-based cache busting, consider
# lowering the cache time for resources such as style sheets and JavaScript
# files to something like one week.
<IfModule mod_expires.c>
ExpiresActive on
ExpiresDefault "access plus 1 month"
# CSS
ExpiresByType text/css "access plus 1 year"
# Data interchange
ExpiresByType application/json "access plus 0 seconds"
ExpiresByType application/ld+json "access plus 0 seconds"
ExpiresByType application/xml "access plus 0 seconds"
ExpiresByType text/xml "access plus 0 seconds"
# Favicon (cannot be renamed!) and cursor images
ExpiresByType image/x-icon "access plus 1 week"
# HTML components (HTCs)
ExpiresByType text/x-component "access plus 1 month"
# HTML
ExpiresByType text/html "access plus 0 seconds"
# JavaScript
ExpiresByType application/javascript "access plus 1 year"
# Manifest files
ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds"
ExpiresByType text/cache-manifest "access plus 0 seconds"
# Media
ExpiresByType audio/ogg "access plus 1 month"
ExpiresByType image/gif "access plus 1 month"
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType video/mp4 "access plus 1 month"
ExpiresByType video/ogg "access plus 1 month"
ExpiresByType video/webm "access plus 1 month"
# Web feeds
ExpiresByType application/atom+xml "access plus 1 hour"
ExpiresByType application/rss+xml "access plus 1 hour"
# Web fonts
ExpiresByType application/font-woff "access plus 1 month"
ExpiresByType application/vnd.ms-fontobject "access plus 1 month"
ExpiresByType application/x-font-ttf "access plus 1 month"
ExpiresByType font/opentype "access plus 1 month"
ExpiresByType image/svg+xml "access plus 1 month"
</IfModule>
# ------------------------------------------------------------------------------
# | Filename-based cache busting |
# ------------------------------------------------------------------------------
# If you're not using a build process to manage your filename version revving,
# you might want to consider enabling the following directives to route all
# requests such as `/css/style.12345.css` to `/css/style.css`.
# To understand why this is important and a better idea than `*.css?v231`, read:
# http://stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring
# <IfModule mod_rewrite.c>
# RewriteCond %{REQUEST_FILENAME} !-f
# RewriteRule ^(.+)\.(\d+)\.(js|css|png|jpe?g|gif)$ $1.$3 [L]
# </IfModule>
# ------------------------------------------------------------------------------
# | File concatenation |
# ------------------------------------------------------------------------------
# Allow concatenation from within specific style sheets and JavaScript files.
# e.g.:
#
# If you have the following content in a file
#
# <!--#include file="libs/jquery.js" -->
# <!--#include file="plugins/jquery.timer.js" -->
#
# Apache will replace it with the content from the specified files.
# <IfModule mod_include.c>
# <FilesMatch "\.combined\.js$">
# Options +Includes
# AddOutputFilterByType INCLUDES application/javascript application/json
# SetOutputFilter INCLUDES
# </FilesMatch>
# <FilesMatch "\.combined\.css$">
# Options +Includes
# AddOutputFilterByType INCLUDES text/css
# SetOutputFilter INCLUDES
# </FilesMatch>
# </IfModule>

157
client/app/404.html Normal file
View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Page Not Found :(</title>
<style>
::-moz-selection {
background: #b3d4fc;
text-shadow: none;
}
::selection {
background: #b3d4fc;
text-shadow: none;
}
html {
padding: 30px 10px;
font-size: 20px;
line-height: 1.4;
color: #737373;
background: #f0f0f0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
html,
input {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
body {
max-width: 500px;
_width: 500px;
padding: 30px 20px 50px;
border: 1px solid #b3b3b3;
border-radius: 4px;
margin: 0 auto;
box-shadow: 0 1px 10px #a7a7a7, inset 0 1px 0 #fff;
background: #fcfcfc;
}
h1 {
margin: 0 10px;
font-size: 50px;
text-align: center;
}
h1 span {
color: #bbb;
}
h3 {
margin: 1.5em 0 0.5em;
}
p {
margin: 1em 0;
}
ul {
padding: 0 0 0 40px;
margin: 1em 0;
}
.container {
max-width: 380px;
_width: 380px;
margin: 0 auto;
}
/* google search */
#goog-fixurl ul {
list-style: none;
padding: 0;
margin: 0;
}
#goog-fixurl form {
margin: 0;
}
#goog-wm-qt,
#goog-wm-sb {
border: 1px solid #bbb;
font-size: 16px;
line-height: normal;
vertical-align: top;
color: #444;
border-radius: 2px;
}
#goog-wm-qt {
width: 220px;
height: 20px;
padding: 5px;
margin: 5px 10px 0 0;
box-shadow: inset 0 1px 1px #ccc;
}
#goog-wm-sb {
display: inline-block;
height: 32px;
padding: 0 10px;
margin: 5px 0 0;
white-space: nowrap;
cursor: pointer;
background-color: #f5f5f5;
background-image: -webkit-linear-gradient(rgba(255,255,255,0), #f1f1f1);
background-image: -moz-linear-gradient(rgba(255,255,255,0), #f1f1f1);
background-image: -ms-linear-gradient(rgba(255,255,255,0), #f1f1f1);
background-image: -o-linear-gradient(rgba(255,255,255,0), #f1f1f1);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
*overflow: visible;
*display: inline;
*zoom: 1;
}
#goog-wm-sb:hover,
#goog-wm-sb:focus {
border-color: #aaa;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
background-color: #f8f8f8;
}
#goog-wm-qt:hover,
#goog-wm-qt:focus {
border-color: #105cb6;
outline: 0;
color: #222;
}
input::-moz-focus-inner {
padding: 0;
border: 0;
}
</style>
</head>
<body>
<div class="container">
<h1>Not found <span>:(</span></h1>
<p>Sorry, but the page you were trying to view does not exist.</p>
<p>It looks like this was the result of either:</p>
<ul>
<li>a mistyped address</li>
<li>an out-of-date link</li>
</ul>
<script>
var GOOG_FIXURL_LANG = (navigator.language || '').slice(0,2),GOOG_FIXURL_SITE = location.host;
</script>
<script src="//linkhelp.clients.google.com/tbproxy/lh/wm/fixurl.js"></script>
</div>
</body>
</html>

BIN
client/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="100%" height="100%" viewBox="0 0 256 256" xml:space="preserve">
<path fill="#D65906" d="M128,32c52.935,0,96,43.065,96,96s-43.065,96-96,96s-96-43.065-96-96S75.065,32,128,32 M128,8
C61.726,8,8,61.726,8,128s53.726,120,120,120s120-53.726,120-120S194.274,8,128,8L128,8z"/>
<path fill="#D65906" d="M128,53c-41.422,0-75,33.579-75,75s33.578,75,75,75c41.421,0,75-33.579,75-75S169.421,53,128,53z
M156.932,163.617c-1.012,1.29-2.353,2.451-4.02,3.483c-1.668,1.032-3.584,1.936-5.748,2.71s-4.398,1.42-6.7,1.936
c-2.304,0.517-4.597,0.903-6.88,1.162c-2.283,0.258-4.397,0.387-6.343,0.387c-5.876,0-11.355-0.982-16.438-2.948
c-5.083-1.966-9.491-4.845-13.222-8.636c-3.732-3.793-6.671-8.458-8.815-13.997s-3.216-11.882-3.216-19.028
c0-7.227,1.062-13.688,3.187-19.388c2.124-5.697,5.092-10.512,8.904-14.442c3.812-3.932,8.348-6.939,13.609-9.023
c5.261-2.084,11.028-3.127,17.302-3.127c4.964,0,9.28,0.308,12.954,0.924c3.672,0.615,6.721,1.459,9.143,2.531
s4.229,2.342,5.42,3.811c1.191,1.47,1.787,3.059,1.787,4.766c0,1.151-0.238,2.154-0.715,3.008s-1.102,1.559-1.877,2.113
c-0.773,0.557-1.637,0.964-2.59,1.222c-0.953,0.259-1.906,0.388-2.859,0.388c-0.557,0-1.111-0.011-1.668-0.03
c-0.557-0.021-1.093-0.109-1.607-0.269c-1.51-0.396-2.949-0.783-4.318-1.161c-1.37-0.377-2.72-0.704-4.05-0.982
c-1.331-0.278-2.661-0.507-3.991-0.685c-1.33-0.179-2.709-0.269-4.139-0.269c-2.979,0-5.787,0.446-8.428,1.341
c-2.641,0.893-4.935,2.471-6.879,4.734c-1.945,2.264-3.484,5.33-4.616,9.202c-1.132,3.871-1.698,8.805-1.698,14.8
c0,4.606,0.456,8.746,1.37,12.419c0.913,3.673,2.272,6.789,4.08,9.351s4.07,4.526,6.79,5.896c2.719,1.369,5.887,2.055,9.5,2.055
c1.469,0,2.998-0.158,4.586-0.477c1.587-0.318,3.217-0.725,4.884-1.222c1.667-0.495,3.364-1.062,5.092-1.696
c1.728-0.636,3.444-1.271,5.151-1.906c0.517-0.159,1.152-0.238,1.906-0.238c0.992,0,1.896,0.188,2.711,0.565
c0.812,0.377,1.518,0.884,2.114,1.52c0.595,0.635,1.052,1.379,1.37,2.233c0.317,0.854,0.476,1.757,0.476,2.71
C158.45,160.907,157.943,162.326,156.932,163.617z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

33
client/app/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!doctype html>
<html class="no-js">
<head>
<meta charset="utf-8">
<title>Weave Scope</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- build:css styles/vendor.css -->
<!-- endbuild -->
<!-- build:css styles/main.css -->
<link rel="stylesheet" href="styles/main.css">
<!-- endbuild -->
</head>
<body>
<!--[if lt IE 10]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<div class="wrap">
<div id="app"></div>
</div>
<!-- @ifdef DEBUG -->
<script>window.WS_URL = 'ws://localhost:4040';</script>
<!-- @endif -->
<!-- build:js scripts/bundle.js -->
<script src="scripts/bundle.js"></script>
<!-- endbuild -->
</body>
</html>

3
client/app/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# robotstxt.org/
User-agent: *

View File

@@ -0,0 +1,76 @@
var AppDispatcher = require('../dispatcher/app-dispatcher');
var ActionTypes = require('../constants/action-types');
module.exports = {
clickCloseDetails: function() {
AppDispatcher.dispatch({
type: ActionTypes.CLICK_CLOSE_DETAILS
});
RouterUtils.updateRoute();
},
clickNode: function(nodeId) {
AppDispatcher.dispatch({
type: ActionTypes.CLICK_NODE,
nodeId: nodeId
});
RouterUtils.updateRoute();
WebapiUtils.getNodeDetails(AppStore.getUrlForTopology(AppStore.getCurrentTopology()), AppStore.getSelectedNodeId());
},
clickTopology: function(topologyId) {
AppDispatcher.dispatch({
type: ActionTypes.CLICK_TOPOLOGY,
topologyId: topologyId
});
RouterUtils.updateRoute();
WebapiUtils.getNodesDelta(AppStore.getUrlForTopology(AppStore.getCurrentTopology()));
},
clickTopologyMode: function(mode) {
AppDispatcher.dispatch({
type: ActionTypes.CLICK_TOPOLOGY_MODE,
mode: mode
});
RouterUtils.updateRoute();
WebapiUtils.getNodesDelta(AppStore.getUrlForTopology(AppStore.getCurrentTopology()));
},
hitEsc: function() {
AppDispatcher.dispatch({
type: ActionTypes.HIT_ESC_KEY
});
RouterUtils.updateRoute();
},
receiveNodeDetails: function(details) {
AppDispatcher.dispatch({
type: ActionTypes.RECEIVE_NODE_DETAILS,
details: details
});
},
receiveTopologies: function(topologies) {
AppDispatcher.dispatch({
type: ActionTypes.RECEIVE_TOPOLOGIES,
topologies: topologies
});
WebapiUtils.getNodesDelta(AppStore.getUrlForTopology(AppStore.getCurrentTopology()));
WebapiUtils.getNodeDetails(AppStore.getUrlForTopology(AppStore.getCurrentTopology()), AppStore.getSelectedNodeId());
},
route: function(state) {
AppDispatcher.dispatch({
state: state,
type: ActionTypes.ROUTE_TOPOLOGY
});
WebapiUtils.getNodesDelta(AppStore.getUrlForTopology(AppStore.getCurrentTopology()));
WebapiUtils.getNodeDetails(AppStore.getUrlForTopology(AppStore.getCurrentTopology()), AppStore.getSelectedNodeId());
}
};
// breaking circular deps
var RouterUtils = require('../utils/router-utils');
var WebapiUtils = require('../utils/web-api-utils');
var AppStore = require('../stores/app-store');

View File

@@ -0,0 +1,25 @@
var AppDispatcher = require('../dispatcher/app-dispatcher');
var ActionTypes = require('../constants/action-types');
module.exports = {
enterNode: function(nodeId) {
AppDispatcher.dispatch({
type: ActionTypes.ENTER_NODE,
nodeId: nodeId
});
},
leaveNode: function(nodeId) {
AppDispatcher.dispatch({
type: ActionTypes.LEAVE_NODE,
nodeId: nodeId
});
},
receiveNodesDelta: function(delta) {
AppDispatcher.dispatch({
type: ActionTypes.RECEIVE_NODES_DELTA,
delta: delta
});
}
};

View File

@@ -0,0 +1,302 @@
var d3 = require('d3');
var _ = require('lodash');
var EventEmitter = require('events').EventEmitter;
var Node = require('./node');
function determineAllEdges(nodes, allNodes) {
var edges = [],
edgeIds = {},
nodeIds = _.pluck(nodes, 'id');
_.each(nodes, function(node) {
var nodeId = node.id;
_.each(node.adjacency, function(adjacent) {
var edge = [nodeId, adjacent],
edgeId = edge.join('-');
if (!edgeIds[edgeId] && _.contains(nodeIds, nodeId) && _.contains(nodeIds, adjacent)) {
edges.push({
id: edgeId,
value: 5,
source: allNodes[edge[0]],
target: allNodes[edge[1]]
});
edgeIds[edgeId] = true;
}
});
});
return edges;
}
function getAdjacentNodes(nodes, rootNodeIds) {
var rootNodes = _.compact(_.map(rootNodeIds, function(nodeId) {
return nodes[nodeId];
}));
var allAdjacentNodeIds = _.union(_.flatten(_.map(rootNodes, function(node) {
return node.adjacency;
})), rootNodeIds);
return _.compact(_.map(allAdjacentNodeIds, function(nodeId) {
return nodes[nodeId];
}));
}
function getAdjacentEdges(nodes, root) {
return _.map(root.adjacency, function(nodeId) {
var edge = [root.id, nodeId],
edgeId = edge.join('-');
return {
id: edgeId,
value: 10,
source: nodes[edge[0]],
target: nodes[edge[1]]
};
});
}
function id(d) {
return d.id;
}
function degree(d) {
return d.adjacency ? d.adjacency.length : 1;
}
function getChildren(node, allNodes) {
return _.map(node.adjacency, function(nodeId) {
return allNodes[nodeId];
});
}
function dblclick(d) {
d.fixed = false;
}
function nodeExplorer() {
var color = d3.scale.category20();
var pie = d3.layout.pie()
.value(degree);
var radius = d3.scale.sqrt()
.range([1, 8]);
var drag = d3.behavior.drag();
var dispatcher = new EventEmitter();
var nodeLocations = {};
var width, height;
function circleRadius() {
return width / 4;
}
function radialLayout(centerNode, nodes, radius) {
var slices = pie(_.sortBy(_.filter(nodes, function(node) {
return !node.layedout && _.contains(centerNode.adjacency, node.id);
}), 'id'));
_.each(slices, function(slice) {
var previousXY = nodeLocations[slice.data.id];
slice.data.x = centerNode.x + circleRadius() * Math.sin((slice.startAngle + slice.endAngle) / 2);
slice.data.y = centerNode.y + circleRadius() * Math.cos((slice.startAngle + slice.endAngle) / 2);
slice.data.layedout = true;
// nodeLocations[slice.data.id] = [slice.data.forceX, slice.data.forceY];
});
}
function chart(selection) {
selection.each(function(data) {
var allNodes = data.nodes;
var root = allNodes[data.root];
var expandedNodeIds = data.expandedNodes;
var centerX = width / 2;
var centerY = height / 2;
var nodes = getAdjacentNodes(allNodes, expandedNodeIds);
if (root) {
var previousXY = nodeLocations[root.id];
root.x = previousXY ? previousXY[0] : centerX;
root.y = previousXY ? previousXY[1] : centerY;
root.layedout = true;
_.each(nodes, function(node) {
if (node.id !== root.id) {
node.layedout = false;
}
});
_.each(expandedNodeIds, function(nodeId, i) {
var centerNode = allNodes[nodeId];
if (centerNode) {
radialLayout(centerNode, nodes, i ? circleRadius() / 4 : circleRadius());
}
});
if (nodes.length == 0) {
nodes.push(root);
}
}
var edges = determineAllEdges(nodes, allNodes);
// Select the svg element, if it exists.
var svg = d3.select(this).selectAll("svg").data([data]);
// Otherwise, create the skeletal chart.
var gEnter = svg.enter().append("svg")
.attr('width', "100%")
.attr('height', "100%");
gEnter.append('g')
.classed('links', true);
gEnter.append('g')
.classed('nodes', true);
var link = svg.select('.links').selectAll(".link")
.data(edges, id);
link.exit()
.remove()
link.enter().append("line")
.attr("class", "link")
.style("stroke-width", function(d) { return Math.sqrt(d.value); });
var node = svg.select('.nodes').selectAll(".node")
.data(nodes, id);
node.exit()
.remove();
gEnter = node.enter().append("g")
.attr("class", "node")
.on("dblclick", dblclick)
.on('click', function(d) {
if (d3.event.defaultPrevented) {
return; // click suppressed
}
dispatcher.emit('node.click', d);
})
.call(drag);
gEnter.append("circle")
.attr('class', 'outer')
.style("fill", function(d) {
return color(d.label_major);
})
.attr("r", function(d) {
return radius(degree(d)) + 4;
});
gEnter.append("circle")
.style("fill", function(d) {
return color(d.label_major);
})
.attr("r", function(d) {
return radius(degree(d));
});
gEnter.append("text")
.attr('class', 'label-major')
.attr("dy", "-.25em")
.text(function(d) { return d.label_major; });
gEnter.append("text")
.attr('class', 'label-minor')
.attr("dy", ".75em")
.text(function(d) { return d.label_minor; });
if (!root) {
return;
}
node
.classed('node-root', function(d) {
return d.id === root.id;
})
.classed('node-expanded', function(d) {
return _.contains(expandedNodeIds, d.id);
})
.classed('node-leaf', function(d) {
return _.size(d.adjacency) === 0;
});
function updatePositions() {
link.attr("x1", function(d) { return d.source.x; })
.attr("y1", function(d) { return d.source.y; })
.attr("x2", function(d) { return d.target.x; })
.attr("y2", function(d) { return d.target.y; });
node.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
});
node.selectAll('text')
.attr("dx", function(d) { return d.x > centerX ? radius(degree(d)) + 6 : - radius(degree(d)) - 6})
.attr("text-anchor", function(d) { return d.x > centerX ? "start" : "end"; });
}
var density = Math.sqrt(nodes.length / (width * height));
drag.on('drag', function(d) {
d.x = d3.event.x;
d.y = d3.event.y;
updatePositions();
});
updatePositions();
});
}
chart.create = function(el, state) {
d3.select(el)
.datum(state)
.call(chart);
return chart;
};
chart.update = _.throttle(function(el, state) {
d3.select(el)
.datum(state)
.call(chart);
return chart;
}, 500);
chart.on = function(event, callback) {
dispatcher.on(event, callback);
};
chart.teardown = function() {
force.on('tick', null);
};
chart.width = function(_) {
if (!arguments.length) return width;
width = _;
return chart;
};
chart.height = function(_) {
if (!arguments.length) return height;
height = _;
return chart;
};
return chart;
}
module.exports = nodeExplorer;

View File

@@ -0,0 +1,63 @@
var _ = require('lodash');
var React = require('react');
var tweenState = require('react-tween-state');
var NodeColorMixin = require('../mixins/node-color-mixin');
var Node = React.createClass({
mixins: [
NodeColorMixin,
tweenState.Mixin
],
getInitialState: function() {
return {
x: 0,
y: 0
};
},
componentWillMount: function() {
// initial node position when rendered the first time
this.setState({
x: this.props.dx,
y: this.props.dy
});
},
componentWillReceiveProps: function(nextProps) {
// animate node transition to next position
this.tweenState('x', {
easing: tweenState.easingTypes.easeInOutQuad,
duration: 500,
endValue: nextProps.dx
});
this.tweenState('y', {
easing: tweenState.easingTypes.easeInOutQuad,
duration: 500,
endValue: nextProps.dy
});
},
render: function() {
var transform = "translate(" + this.getTweeningValue('x') + "," + this.getTweeningValue('y') + ")";
var scale = this.props.scale;
var textOffsetX = 0;
var textOffsetY = scale(0.5) + 18;
var textAngle = _.isUndefined(this.props.angle) ? 0 : -1 * (this.props.angle * 180 / Math.PI - 90);
var color = this.getNodeColor(this.props.label);
var className = this.props.highlighted ? "node highlighted" : "node";
return (
<g className={className} transform={transform} onClick={this.props.onClick} id={this.props.id}>
<circle r={scale(0.5)} className="border" stroke={color}></circle>
<circle r={scale(0.45)} className="shadow"></circle>
<circle r={Math.max(2, scale(0.125))} className="node"></circle>
<text className="node-label" textAnchor="middle" x={textOffsetX} y={textOffsetY}>{this.props.label}</text>
<text className="node-sublabel" textAnchor="middle" x={textOffsetX} y={textOffsetY + 17}>{this.props.subLabel}</text>
</g>
);
}
});
module.exports = Node;

View File

@@ -0,0 +1,234 @@
var _ = require('lodash');
var d3 = require('d3');
var React = require('react');
var NodesLayout = require('./nodes-layout');
var Node = require('./node');
var MAX_NODES = 100;
var MARGINS = {
top: 120,
left: 40,
right: 40,
bottom: 0
};
var line = d3.svg.line()
.interpolate("basis")
.x(function(d) { return d.x; })
.y(function(d) { return d.y; });
var NodesChart = React.createClass({
getInitialState: function() {
return {
nodes: {},
edges: {},
nodeScale: 1,
translate: "0,0",
scale: 1,
hasZoomed: false
};
},
initNodes: function(topology, prevNodes) {
var centerX = this.props.width / 2;
var centerY = this.props.height / 2;
var nodes = {};
_.each(topology, function(node, id) {
nodes[id] = prevNodes[id] || {};
_.defaults(nodes[id], {
x: centerX,
y: centerY,
textAnchor: 'start'
});
_.assign(nodes[id], {
id: id,
label: node.label_major,
subLabel: node.label_minor,
degree: _.size(node.adjacency)
});
}, this);
return nodes;
},
initEdges: function(topology, nodes) {
var edges = {};
_.each(topology, function(node) {
_.each(node.adjacency, function(adjacent) {
var edge = [node.id, adjacent],
edgeId = edge.join('-');
if (!edges[edgeId]) {
var source = nodes[edge[0]];
var target = nodes[edge[1]];
if(!source || !target) {
console.error("Missing edge node", edge[0], source, edge[1], target);
}
edges[edgeId] = {
id: edgeId,
value: 1,
source: source,
target: target
};
}
});
}, this);
return edges;
},
getNodes: function(nodes, scale) {
return _.map(nodes, function (node) {
var highlighted = _.includes(this.props.highlightedNodes, node.id);
return (
<Node
highlighted={highlighted}
onClick={this.props.onNodeClick}
key={node.id}
id={node.id}
label={node.label}
subLabel={node.subLabel}
scale={scale}
dx={node.x}
dy={node.y}
/>
);
}, this);
},
getEdges: function(edges, scale) {
return _.map(edges, function(edge) {
return (
<path className="link" d={line(edge.points)} key={edge.id} />
);
});
},
getTopologyFingerprint: function(topology) {
var nodes = _.keys(topology).sort();
var fingerprint = [];
_.each(topology, function(node) {
fingerprint.push(node.id);
if (node.adjacency) {
fingerprint.push(node.adjacency.join(','));
}
});
return fingerprint.join(';');
},
updateGraphState: function(props) {
var nodes = this.initNodes(props.nodes, this.state.nodes);
var edges = this.initEdges(props.nodes, nodes);
var expanse = Math.min(props.height, props.width);
var nodeSize = expanse / 2;
var n = _.size(props.nodes);
var nodeScale = d3.scale.linear().range([0, nodeSize/Math.pow(n, 0.7)]);
var layoutId = 'layered node chart';
console.time(layoutId);
var graph = NodesLayout.doLayout(
nodes,
edges,
props.width,
props.height,
nodeScale,
MARGINS
);
console.timeEnd(layoutId);
// adjust layout based on viewport
var xFactor = (props.width - MARGINS.left - MARGINS.right) / graph.width;
var yFactor = props.height / graph.height;
var zoomFactor = Math.min(xFactor, yFactor);
var zoomScale = this.state.scale;
if(this.zoom && !this.state.hasZoomed && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
this.zoom.scale(zoomFactor);
}
this.setState({
nodes: nodes,
edges: edges,
nodeScale: nodeScale,
scale: zoomScale
});
},
componentWillMount: function() {
this.updateGraphState(this.props);
},
componentDidMount: function() {
this.zoom = d3.behavior.zoom()
.scaleExtent([0.1, 2])
.on('zoom', this.zoomed);
d3.select('.nodes-chart')
.call(this.zoom);
},
componentWillUnmount: function() {
// undoing .call(zoom)
d3.select('.nodes-chart')
.on("mousedown.zoom", null)
.on("onwheel", null)
.on("onmousewheel", null)
.on("dblclick.zoom", null)
.on("touchstart.zoom", null);
},
componentWillReceiveProps: function(nextProps) {
if (this.getTopologyFingerprint(nextProps.nodes) !== this.getTopologyFingerprint(this.props.nodes)) {
this.setState({
nodes: {},
edges: {}
});
}
this.updateGraphState(nextProps);
},
zoomed: function() {
this.setState({
hasZoomed: true,
translate: d3.event.translate,
scale: d3.event.scale
});
},
render: function() {
var nodeElements = this.getNodes(this.state.nodes, this.state.nodeScale);
var edgeElements = this.getEdges(this.state.edges, this.state.nodeScale);
var transform = 'translate(' + this.state.translate + ')' +
' scale(' + this.state.scale + ')';
return (
<svg width="100%" height="100%" className="nodes-chart">
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
</svg>
);
}
});
module.exports = NodesChart;

View File

@@ -0,0 +1,73 @@
var dagre = require('dagre');
var _ = require('lodash');
var MAX_NODES = 100;
var doLayout = function(nodes, edges, width, height, scale, margins) {
var offsetX = 0 + margins.left;
var offsetY = 0 + margins.top;
var g = new dagre.graphlib.Graph({});
if (_.size(nodes) > MAX_NODES) {
console.error('Too many nodes for graph layout engine. Limit: ' + MAX_NODES);
return;
}
// configure node margins
g.setGraph({
nodesep: scale(2.5),
ranksep: scale(2.5)
});
// add nodes and edges to layout engine
_.each(nodes, function(node) {
g.setNode(node.id, {id: node.id, width: scale(0.75), height: scale(0.75)});
});
_.each(edges, function(edge) {
var virtualNodes = edge.source.id === edge.target.id ? 1 : 0;
g.setEdge(edge.source.id, edge.target.id, {id: edge.id, minlen: virtualNodes});
});
dagre.layout(g);
var graph = g.graph();
// shifting graph coordinates to center
if (graph.width < width) {
offsetX = (width - graph.width) / 2 + margins.left;
}
if (graph.height < height) {
offsetY = (height - graph.height) / 2 + margins.top;
}
// apply coordinates to nodes and edges
g.nodes().forEach(function(id) {
var node = nodes[id];
var graphNode = g.node(id);
node.x = graphNode.x + offsetX;
node.y = graphNode.y + offsetY;
});
g.edges().forEach(function(id) {
var graphEdge = g.edge(id);
var edge = edges[graphEdge.id];
_.each(graphEdge.points, function(point) {
point.x += offsetX;
point.y += offsetY;
});
edge.points = graphEdge.points;
});
// return object with width and height of layout
return graph;
};
module.exports = {
doLayout: doLayout
};

View File

@@ -0,0 +1,82 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var Logo = require('./logo');
var SearchBar = require('./search-bar.js');
var AppStore = require('../stores/app-store');
var Topologies = require('./topologies.js');
var TopologyStore = require('../stores/topology-store');
var WebapiUtils = require('../utils/web-api-utils');
var AppActions = require('../actions/app-actions');
var Details = require('./details');
var Nodes = require('./nodes');
var ViewOptions = require('./view-options');
var RouterUtils = require('../utils/router-utils');
var ESC_KEY_CODE = 27;
function getStateFromStores() {
return {
selectedNodeId: AppStore.getSelectedNodeId(),
nodeDetails: AppStore.getNodeDetails(),
nodes: TopologyStore.getNodes(),
topologies: AppStore.getTopologies(),
activeTopology: AppStore.getCurrentTopology(),
activeTopologyMode: AppStore.getCurrentTopologyMode()
}
}
var App = React.createClass({
getInitialState: function() {
return getStateFromStores();
},
componentDidMount: function() {
TopologyStore.on(TopologyStore.CHANGE_EVENT, this.onChange);
AppStore.on(AppStore.CHANGE_EVENT, this.onChange);
window.addEventListener('keyup', this.onKeyPress);
RouterUtils.getRouter().start({hashbang: true});
WebapiUtils.getTopologies();
},
onChange: function() {
this.setState(getStateFromStores());
},
onKeyPress: function(ev) {
if (ev.keyCode === ESC_KEY_CODE) {
AppActions.hitEsc();
}
},
render: function() {
var showingDetails = this.state.selectedNodeId;
return (
<div>
{showingDetails && <Details nodes={this.state.nodes}
nodeId={this.state.selectedNodeId}
details={this.state.nodeDetails}
topology={this.state.activeTopology} /> }
<div className="header">
<div id="logo">
<Logo />
</div>
<Topologies topologies={this.state.topologies} active={this.state.activeTopology} />
</div>
<Nodes nodes={this.state.nodes} />
</div>
);
}
});
module.exports = App;

View File

@@ -0,0 +1,35 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var mui = require('material-ui');
var Paper = mui.Paper;
var IconButton = mui.IconButton;
var AppActions = require('../actions/app-actions');
var NodeDetails = require('./node-details');
var WebapiUtils = require('../utils/web-api-utils');
var Details = React.createClass({
handleClickClose: function(ev) {
ev.preventDefault();
AppActions.clickCloseDetails();
},
render: function() {
return (
<div id="details">
<Paper zDepth={3}>
<div className="details-tools">
<span className="fa fa-close" onClick={this.handleClickClose} />
</div>
<NodeDetails details={this.props.details} />
</Paper>
</div>
);
}
});
module.exports = Details;

View File

@@ -0,0 +1,95 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var NodesChart = require('../charts/nodes-chart');
var NodeDetails = require('./node-details');
var marginBottom = 64;
var marginTop = 64;
var marginLeft = 36;
var marginRight = 36;
var Explorer = React.createClass({
getInitialState: function() {
return {
layout: 'solar',
width: window.innerWidth - marginLeft - marginRight,
height: window.innerHeight - marginBottom - marginTop
};
},
componentDidMount: function() {
window.addEventListener('resize', this.handleResize);
},
componentWillUnmount: function() {
window.removeEventListener('resize', this.handleResize);
},
setDimensions: function() {
this.setState({
height: window.innerHeight - marginBottom - marginTop,
width: window.innerWidth - marginLeft - marginRight
});
},
handleResize: function() {
this.setDimensions();
},
getSubTopology: function(topology) {
var subTopology = {};
var nodeSet = [];
_.each(this.props.expandedNodes, function(nodeId) {
if (topology[nodeId]) {
subTopology[nodeId] = topology[nodeId];
nodeSet = _.union(subTopology[nodeId].adjacency, nodeSet);
_.each(subTopology[nodeId].adjacency, function(adjacentId) {
var node = _.assign({}, topology[adjacentId]);
subTopology[adjacentId] = node;
});
}
});
// weed out edges
_.each(subTopology, function(node) {
node.adjacency = _.intersection(node.adjacency, nodeSet);
});
return subTopology;
},
onNodeClick: function(ev) {
var nodeId = ev.currentTarget.id;
AppActions.clickNode(nodeId);
},
render: function() {
var subTopology = this.getSubTopology(this.props.nodes);
return (
<div id="explorer">
<NodeDetails details={this.props.details} />
<div className="graph">
<NodesChart
onNodeClick={this.onNodeClick}
layout={this.state.layout}
nodes={subTopology}
highlightedNodes={this.props.expandedNodes}
width={this.state.width}
height={this.state.height}
context="explorer"
/>
</div>
</div>
);
}
});
module.exports = Explorer;

View File

@@ -0,0 +1,24 @@
/** @jsx React.DOM */
var React = require('react');
var Logo = React.createClass({
render: function() {
return (
<div className="logo">
<svg width="100%" height="100%" viewBox="0 0 145.85 148">
<path d="M145.142,63.868l-62.7,56.006V98.518l58.14-51.933c-2.77-6.938-6.551-13.359-11.175-19.076L82.442,69.46
V47.132L60.771,66.49v22.327l-18.668,16.676V83.165l75.418-67.367c-5.989-4.706-12.71-8.519-19.981-11.211L82.442,18.074V0.767
C78.981,0.27,75.448,0,71.85,0c-3.766,0-7.465,0.286-11.079,0.828v36.604L42.103,54.107V6.242
c-8.086,3.555-15.409,8.513-21.672,14.568v52.656L0,91.715c1.86,7.57,4.88,14.683,8.87,21.135l11.561-10.326v24.667
c4.885,4.724,10.409,8.787,16.444,12.03l23.896-21.345v29.296C64.385,147.715,68.084,148,71.85,148c4.41,0,8.722-0.407,12.921-1.147
l58.033-51.838c1.971-6.664,3.046-13.712,3.046-21.015C145.85,70.561,145.596,67.183,145.142,63.868z"/>
</svg>
</div>
);
}
});
module.exports = Logo;

View File

@@ -0,0 +1,34 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var NodeDetailsTable = React.createClass({
render: function() {
var isNumeric = this.props.isNumeric;
return (
<div className="node-details-table">
<h4 className="node-details-table-title">
{this.props.title}
</h4>
{this.props.rows.map(function(row) {
return (
<div className="node-details-table-row">
<div className="node-details-table-row-key">{row.key}</div>
{isNumeric && <div className="node-details-table-row-value-scalar">{row.value_major}</div>}
{isNumeric && <div className="node-details-table-row-value-unit">{row.value_minor}</div>}
{!isNumeric && <div className="node-details-table-row-value-major">{row.value_major}</div>}
{!isNumeric && row.value_minor && <div className="node-details-table-row-value-minor">{row.value_minor}</div>}
</div>
);
})}
</div>
);
}
});
module.exports = NodeDetailsTable;

View File

@@ -0,0 +1,46 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var NodeDetailsTable = require('./node-details-table');
var NodeColorMixin = require('../mixins/node-color-mixin');
var NodeDetails = React.createClass({
mixins: [
NodeColorMixin
],
render: function() {
var node = this.props.details;
if (!node) {
return <div className="node-details" />;
}
var style = {
"background-color": this.getNodeColorDark(node.label_major)
};
return (
<div className="node-details">
<div className="node-details-header" style={style}>
<h2 className="node-details-header-label">
{node.label_major}
</h2>
<div className="node-details-header-label-minor">{node.label_minor}</div>
</div>
<div className="node-details-content">
{this.props.details.tables.map(function(table) {
return <NodeDetailsTable title={table.title} rows={table.rows} isNumeric={table.numeric} />;
})}
</div>
</div>
);
}
});
module.exports = NodeDetails;

View File

@@ -0,0 +1,60 @@
/** @jsx React.DOM */
var React = require('react');
var NodesChart = require('../charts/nodes-chart');
var AppActions = require('../actions/app-actions');
var navbarHeight = 160;
var marginTop = 0;
var marginLeft = 0;
var Nodes = React.createClass({
getInitialState: function() {
return {
width: window.innerWidth,
height: window.innerHeight - navbarHeight - marginTop
};
},
onNodeClick: function(ev) {
AppActions.clickNode(ev.currentTarget.id);
},
componentDidMount: function() {
window.addEventListener('resize', this.handleResize);
},
componentWillUnmount: function() {
window.removeEventListener('resize', this.handleResize);
},
setDimensions: function() {
this.setState({
height: window.innerHeight - navbarHeight - marginTop,
width: window.innerWidth
});
},
handleResize: function() {
this.setDimensions();
},
render: function() {
return (
<div id="nodes">
<NodesChart
onNodeClick={this.onNodeClick}
nodes={this.props.nodes}
width={this.state.width}
height={this.state.height}
context="view"
/>
</div>
);
}
});
module.exports = Nodes;

View File

@@ -0,0 +1,89 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var Stats = require('./stats.js');
var TopologyActions = require('../actions/topology-actions');
var SearchBar = React.createClass({
getDefaultProps: function() {
return {
initialFilterText: ''
};
},
getInitialState: function() {
return {
filterAdjacent: false,
filterText: this.props.filterText
};
},
handleChange: function() {
TopologyActions.inputFilterText(this.state.filterText);
},
onVolatileChange: function(event) {
this.setState({
filterText: event.target.value
});
this.scheduleChange();
},
scheduleChange: _.debounce(function() {
this.handleChange();
}, 300),
onFilterAjacent: function(event) {
this.setState({
filterAdjacent: event.target.checked
});
TopologyActions.checkFilterAdjacent(event.target.checked);
},
componentWillMount: function() {
this.setState({
filterText: this.props.filterText
});
},
componentDidMount: function() {
this.refs.filterTextInput.getDOMNode().focus();
},
render: function() {
return (
<div className="row" id="search-bar">
<div className="form-group col-md-9">
<div className="form-control-wrapper">
<input className="form-control input-lg" type="text"
id="filterTextInput"
placeholder="Filter for nodes"
value={this.state.filterText}
ref="filterTextInput"
onChange={this.onVolatileChange} />
<span className="material-input"></span>
</div>
</div>
<div className="form-group col-md-3">
<div className="checkbox">
<label>
<input type="checkbox"
ref="filterAdjacent"
value={this.state.filterAdjacent}
onChange={this.onFilterAjacent} />
<span className="check"></span>
Include connected
</label>
</div>
</div>
</div>
);
}
});
module.exports = SearchBar;

View File

@@ -0,0 +1,18 @@
/** @jsx React.DOM */
var React = require('react');
var StatValue = React.createClass({
render: function() {
return (
<div className="stat-value">
<span className="value">{this.props.value}</span>
<span className="stat-label">{this.props.label}</span>
</div>
);
}
});
module.exports = StatValue;

View File

@@ -0,0 +1,30 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var StatValue = require('./stat-value.js');
var Stats = React.createClass({
render: function() {
var nodeCount = _.size(this.props.nodes),
edgeCount = _.reduce(this.props.nodes, function(result, node) {
return result + _.size(node.adjacency);
}, 0);
return (
<div id="stats">
<div className="col-xs-6">
<StatValue value={nodeCount} label="Nodes" />
</div>
<div className="col-xs-6">
<StatValue value={edgeCount} label="Connections" />
</div>
</div>
);
}
});
module.exports = Stats;

View File

@@ -0,0 +1,52 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var AppActions = require('../actions/app-actions');
var AppStore = require('../stores/app-store');
var Topologies = React.createClass({
onTopologyClick: function(ev) {
ev.preventDefault();
AppActions.clickTopology(ev.currentTarget.getAttribute('rel'));
},
renderTopology: function(topology, active) {
var className = AppStore.isUrlForTopology(topology.url, active) ? "topologies-item topologies-item-active" : "topologies-item",
topologyId = AppStore.getTopologyForUrl(topology.url),
title = ['Topology: ' + topology.name,
'Nodes: ' + topology.stats.node_count,
'Connections: ' + topology.stats.node_count].join('\n');
return (
<div className={className} key={topologyId} rel={topologyId} onClick={this.onTopologyClick}>
<div title={title}>
<div className="topologies-item-label">
{topology.name}
</div>
</div>
</div>
);
},
render: function() {
var activeTopologyId = this.props.active,
topologies = _.sortBy(this.props.topologies, function(topology) {
return topology.name;
});
return (
<div className="topologies">
<span className="topologies-icon fa fa-sitemap" />
{topologies.map(function(topology) {
return this.renderTopology(topology, activeTopologyId);
}, this)}
</div>
);
}
});
module.exports = Topologies;

View File

@@ -0,0 +1,93 @@
/** @jsx React.DOM */
var React = require('react');
var _ = require('lodash');
var AppActions = require('../actions/app-actions');
var ViewOptions = React.createClass({
getInitialState: function() {
return {
menuVisible: false
};
},
onClick: function(ev) {
ev.preventDefault();
AppActions.clickTopologyMode(ev.currentTarget.rel);
},
onMouseOver: function(ev) {
this.handleMouseDebounced(true);
},
onMouseOut: function(ev) {
this.handleMouseDebounced(false);
},
componentWillMount: function() {
this.handleMouseDebounced = _.debounce(function(isOver) {
this.setState({
menuVisible: isOver
});
}, 200);
},
getActiveViewMode: function() {
var className = this.props.active === 'class' ? "glyphicon glyphicon-th-large" : "glyphicon glyphicon-th";
return (
<div className="nav-preview" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<div rel={this.props.active} onClick={this.props.onClick}>
<div className="nav-icon">
<span className={className}></span>
</div>
<div className="nav-label">
View Options
</div>
</div>
</div>
);
},
render: function() {
var activeMode = this.props.active,
activeOptions = this.getActiveViewMode(),
baseClass = "",
individualClass = activeMode == 'individual' ? baseClass + ' active' : baseClass,
classClass = activeMode == 'class' ? baseClass + ' active' : baseClass,
navClassName = "nav navbar-nav";
return (
<div className="navbar-view-options">
{activeOptions}
{this.state.menuVisible && <ul className={navClassName} onMouseOut={this.onMouseOut} onMouseOver={this.onMouseOver}>
<li className={individualClass}>
<a href="#" className="row" rel="individual" onClick={this.onClick}>
<div className="col-xs-5 nav-item-preview">
<span className="glyphicon glyphicon-th"></span>
</div>
<div className="col-xs-7 nav-item-label">
Standard View
</div>
</a>
</li>
<li className={classClass}>
<a href="#" className="row" rel="class" onClick={this.onClick}>
<div className="col-xs-5 nav-item-preview">
<span className="glyphicon glyphicon-th-large"></span>
</div>
<div className="col-xs-7 nav-item-label">
Group View
</div>
</a>
</li>
</ul>}
</div>
);
}
});
module.exports = ViewOptions;

View File

@@ -0,0 +1,16 @@
var keymirror = require('keymirror');
module.exports = keymirror({
CLICK_CLOSE_DETAILS: null,
CLICK_NODE: null,
CLICK_TOPOLOGY: null,
CLICK_TOPOLOGY_MODE: null,
ENTER_NODE: null,
HIT_ESC_KEY: null,
LEAVE_NODE: null,
RECEIVE_NODE_DETAILS: null,
RECEIVE_NODES: null,
RECEIVE_NODES_DELTA: null,
RECEIVE_TOPOLOGIES: null,
ROUTE_TOPOLOGY: null
});

View File

@@ -0,0 +1,13 @@
module.exports = [
{
url: '/api/topology/applications',
name: 'Applications',
stats: {}
},
{
url: '/api/topology/hosts',
name: 'Hosts',
stats: {}
}
];

View File

@@ -0,0 +1,12 @@
var flux = require('flux');
var _ = require('lodash');
var AppDispatcher = new flux.Dispatcher();
AppDispatcher.dispatch = _.wrap(flux.Dispatcher.prototype.dispatch, function(func) {
var args = Array.prototype.slice.call(arguments, 1);
// console.log(args[0]);
func.apply(this, args);
});
module.exports = AppDispatcher;

View File

@@ -0,0 +1,11 @@
/**
* @jsx React.DOM
*/
var React = require('react');
var App = require('./components/app.js');
React.render(
<App/>,
document.getElementById('app'));

View File

@@ -0,0 +1,27 @@
var d3 = require('d3');
var colors = d3.scale.category20();
// make sure the internet always gets the same color
var internetLabel = "the Internet";
colors(internetLabel);
var NodeColorMixin = {
getNodeColor: function(text) {
return colors(text);
},
getNodeColorDark: function(text) {
var color = d3.rgb(colors(text));
var hsl = color.hsl();
// ensure darkness
// if (hsl.l > 0.5) {
hsl = hsl.darker();
// }
return hsl.toString();
}
};
module.exports = NodeColorMixin;

View File

@@ -0,0 +1,131 @@
var EventEmitter = require('events').EventEmitter;
var _ = require('lodash');
var assign = require('object-assign');
var AppDispatcher = require('../dispatcher/app-dispatcher');
var ActionTypes = require('../constants/action-types');
var TopologyStore = require('./topology-store');
// var topologies = require('../constants/topologies');
// Initial values
var currentTopology = 'applications';
var currentTopologyMode = 'individual';
var nodeDetails = null;
var selectedNodeId = null;
var topologies = [];
// Store API
var AppStore = assign({}, EventEmitter.prototype, {
CHANGE_EVENT: 'change',
getAppState: function() {
return {
currentTopology: this.getCurrentTopology(),
currentTopologyMode: this.getCurrentTopologyMode(),
selectedNodeId: this.getSelectedNodeId()
};
},
getCurrentTopology: function() {
return currentTopology;
},
getCurrentTopologyMode: function() {
return currentTopologyMode;
},
getNodeDetails: function() {
return nodeDetails;
},
getSelectedNodeId: function() {
return selectedNodeId;
},
getTopologies: function() {
return topologies;
},
getTopologyForUrl: function(url) {
return url.split('/').pop();
},
getUrlForTopology: function(topologyId) {
var topology = _.find(topologies, function(topology) {
return this.isUrlForTopology(topology.url, topologyId);
}, this);
if (topology) {
return topology.grouped_url && currentTopologyMode == 'class' ? topology.grouped_url : topology.url;
}
},
isUrlForTopology: function(url, topologyId) {
return _.endsWith(url, topologyId);
}
});
// Store Dispatch Hooks
AppStore.dispatchToken = AppDispatcher.register(function(payload) {
switch (payload.type) {
case ActionTypes.CLICK_CLOSE_DETAILS:
selectedNodeId = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.CLICK_NODE:
selectedNodeId = payload.nodeId;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.CLICK_TOPOLOGY:
currentTopology = payload.topologyId;
AppDispatcher.waitFor([TopologyStore.dispatchToken]);
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.CLICK_TOPOLOGY_MODE:
currentTopologyMode = payload.mode;
AppDispatcher.waitFor([TopologyStore.dispatchToken]);
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.HIT_ESC_KEY:
nodeDetails = null;
selectedNodeId = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.RECEIVE_NODE_DETAILS:
nodeDetails = payload.details;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.RECEIVE_TOPOLOGIES:
topologies = payload.topologies;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.ROUTE_TOPOLOGY:
currentTopology = payload.state.currentTopology;
currentTopologyMode = payload.state.currentTopologyMode;
selectedNodeId = payload.state.selectedNodeId;
AppDispatcher.waitFor([TopologyStore.dispatchToken]);
AppStore.emit(AppStore.CHANGE_EVENT);
break;
default:
break;
}
});
module.exports = AppStore;

View File

@@ -0,0 +1,86 @@
var EventEmitter = require('events').EventEmitter;
var _ = require('lodash');
var assign = require('object-assign');
var AppDispatcher = require('../dispatcher/app-dispatcher');
var ActionTypes = require('../constants/action-types');
// Initial values
var nodes = {};
var mouseOverNode = null;
// Store API
var TopologyStore = assign({}, EventEmitter.prototype, {
CHANGE_EVENT: 'change',
getNodes: function() {
return nodes;
}
});
// Store Dispatch Hooks
TopologyStore.dispatchToken = AppDispatcher.register(function(payload) {
switch (payload.type) {
case ActionTypes.CLICK_TOPOLOGY:
nodes = {};
TopologyStore.emit(TopologyStore.CHANGE_EVENT);
break;
case ActionTypes.CLICK_TOPOLOGY_MODE:
nodes = {};
TopologyStore.emit(TopologyStore.CHANGE_EVENT);
break;
case ActionTypes.ENTER_NODE:
mouseOverNode = payload.nodeId;
TopologyStore.emit(TopologyStore.CHANGE_EVENT);
break;
case ActionTypes.LEAVE_NODE:
mouseOverNode = null;
TopologyStore.emit(TopologyStore.CHANGE_EVENT);
break;
case ActionTypes.RECEIVE_NODES_DELTA:
// nodes that no longer exist
_.each(payload.delta.remove, function(nodeId) {
// in case node disappears before mouseleave event
if (mouseOverNode === nodeId) {
mouseOverNode = null;
}
delete nodes[nodeId];
});
// update existing nodes
_.each(payload.delta.update, function(node) {
nodes[node.id] = node;
});
// add new nodes
_.each(payload.delta.add, function(node) {
nodes[node.id] = node;
});
TopologyStore.emit(TopologyStore.CHANGE_EVENT);
break;
case ActionTypes.ROUTE_TOPOLOGY:
nodes = {};
TopologyStore.emit(TopologyStore.CHANGE_EVENT);
break;
default:
break;
}
});
module.exports = TopologyStore;

View File

@@ -0,0 +1,31 @@
var page = require('page');
var AppActions = require('../actions/app-actions');
var AppStore = require('../stores/app-store');
page('/', function(ctx) {
updateRoute();
});
page('/state/:state', function(ctx) {
var state = JSON.parse(ctx.params.state);
AppActions.route(state);
});
function updateRoute() {
var state = AppStore.getAppState();
var stateUrl = JSON.stringify(state);
var dispatch = false;
page.show('/state/' + stateUrl, state, dispatch);
}
module.exports = {
getRouter: function() {
return page;
},
updateRoute: updateRoute
};

View File

@@ -0,0 +1,68 @@
var reqwest = require('reqwest');
var TopologyActions = require('../actions/topology-actions');
var AppActions = require('../actions/app-actions');
var AppStore = require('../stores/app-store');
var WS_URL = window.WS_URL || 'ws://' + location.host;
var socket;
var reconnectTimer = 0;
var currentUrl = null;
var updateFrequency = '5s';
var topologyTimer = 0;
function createWebsocket(topologyUrl) {
if (socket) {
socket.onclose = null;
socket.close();
}
socket = new WebSocket(WS_URL + topologyUrl + '/ws?t=' + updateFrequency);
socket.onclose = function() {
clearTimeout(reconnectTimer);
socket = null;
reconnectTimer = setTimeout(function() {
createWebsocket(topologyUrl);
}, 5000);
}
socket.onmessage = function(event) {
var msg = JSON.parse(event.data);
if (msg.add || msg.remove || msg.update) {
TopologyActions.receiveNodesDelta(msg);
}
};
currentUrl = topologyUrl;
}
function getTopologies() {
clearTimeout(topologyTimer);
reqwest('/api/topology', function(res) {
AppActions.receiveTopologies(res);
topologyTimer = setTimeout(getTopologies, 10000);
});
}
function getNodeDetails(topology, nodeId) {
var url = [AppStore.getUrlForTopology(topology), nodeId].join('/');
reqwest(url, function(res) {
AppActions.receiveNodeDetails(res.node);
});
}
module.exports = {
getNodeDetails: getNodeDetails,
getTopologies: getTopologies,
getNodesDelta: function(topologyUrl) {
if (topologyUrl) {
createWebsocket(topologyUrl);
}
}
}

289
client/app/styles/main.less Normal file
View File

@@ -0,0 +1,289 @@
@import "../../node_modules/material-ui/src/less/scaffolding";
@import "../../node_modules/material-ui/src/less/components";
@import "../../node_modules/font-awesome/less/font-awesome.less";
@import url(http://fonts.googleapis.com/css?family=Roboto:300,400);
.browsehappy {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}
@primary-color: #445566;
@background-color: white;
@background-secondary-color: lighten(@primary-color, 60%);
@text-color: @primary-color;
@text-secondary-color: lighten(@primary-color, 33%);
@text-pale-color: lighten(@primary-color, 50%);
@text-darker-color: darken(@primary-color, 33%);
@lighter-color: lighten(@primary-color, 10%);
@cello-orange: #D65906;
@white: #E7E7E7;
html, body {
}
.wrap {
}
/* Space out content a bit */
body {
background: linear-gradient(30deg, #DDD 0%, #F2F2F2 100%);
color: @text-color;
line-height: 150%;
}
#logo {
margin: 0 64px 0 64px;
height: 64px;
width: 64px;
float: left;
.logo {
path {
stroke-width: 2px;
stroke: @text-secondary-color;
fill: none;
}
}
}
#app {
}
.header {
position: absolute;
top: 32px;
// border-bottom: 1px solid @text-pale-color;
width: 100%;
height: 80px;
z-index: 20;
}
.topologies {
float: left;
position: relative;
margin-top: 4px;
&-icon {
font-size: 12px;
color: @text-secondary-color;
margin-right: 16px;
position: relative;
top: -1px;
}
.topologies-item {
margin-top: 16px;
margin-bottom: 16px;
margin-left: 16px;
margin-right: 16px;
cursor: pointer;
display: inline-block;
&-frame {
display: inline-block;
width: 64px;
height: 64px;
padding-top: 4px;
}
&-nodes,
&-edges,
&-divider {
display: block;
line-height: 28px;
font-size: 24px;
color: @text-secondary-color;
}
&-label {
color: @text-secondary-color;
font-size: 16px;
text-transform: uppercase;
}
&-active, &:hover {
.topologies-item-label {
color: @text-color;
//border-bottom: 2px solid @primary-color;
}
}
}
}
#stats {
.stat-value {
text-align: center;
padding-top: 15px;
}
.value {
font-size: 36px;
display: block;
}
.stat-label {
position: relative;
top: 4px;
color: @text-secondary-color;
text-transform: uppercase;
font-weight: normal;
}
}
#nodes {
svg {
position: absolute;
top: 0px;
}
text {
font-size: 14px;
font-family: Roboto;
fill: @text-secondary-color;
text-shadow: 0 1px 0 @white, 1px 0 0 @white, 0 -1px 0 @white, -1px 0 0 @white;
&.node-label {
fill: @text-color;
}
&.node-sublabel {
font-size: 12px;
fill: @text-secondary-color;
}
}
g.node {
cursor: pointer;
}
.link {
stroke: @text-secondary-color;
stroke-width: 1.5px;
fill: none;
opacity: 0.5;
}
circle.border {
stroke-width: 3px;
fill: none;
}
circle.shadow {
stroke: none;
fill: @background-secondary-color;
}
circle.node {
fill: @text-color;
}
g.node.highlighted {
circle.border {
stroke: @cello-orange;
}
}
}
#details {
position: absolute;
z-index: 1024;
display: block;
right: 36px;
top: 24px;
bottom: 48px;
width: 32em;
.details-tools {
float: right;
padding: 16px 24px;
color: @white;
span {
cursor: pointer;
&:hover {
color: white;
}
}
}
}
.mui-paper, .mui-paper-container {
height: 100%;
}
.node-details {
height: 100%;
background-color: rgba(255, 255, 255, 0.86);
&-header {
padding: 24px 36px 24px 36px;
&-label {
color: white;
margin-bottom: 0;
&-minor {
font-size: 120%;
color: @white;
}
}
}
&-content {
padding: 0 36px 0 36px;
}
&-table {
margin-bottom: 8px;
&-title {
text-transform: uppercase;
margin-bottom: 0;
color: @text-secondary-color;
font-size: 100%;
}
&-row {
> div {
display: inline-block;
}
&-key {
width: 9em;
}
&-value-major {
margin-right: 0.5em;
}
&-value-scalar {
width: 2em;
text-align: right;
margin-right: 0.5em;
}
&-value-minor,
&-value-unit {
font-size: 95%;
color: @text-secondary-color;
}
}
}
}

119
client/gulpfile.js Normal file
View File

@@ -0,0 +1,119 @@
'use strict';
var gulp = require('gulp');
var connect = require('gulp-connect');
var livereload = require('gulp-livereload');
var browserify = require('browserify');
var del = require('del');
var source = require('vinyl-source-stream');
var buffer = require('vinyl-buffer');
var reactify = require('reactify');
// load plugins
var $ = require('gulp-load-plugins')();
var isDev = true;
var isProd = false;
gulp.task('styles', function () {
return gulp.src('app/styles/main.less')
.pipe($.if(isDev, $.sourcemaps.init()))
.pipe($.less())
.pipe($.autoprefixer('last 1 version'))
.pipe($.if(isDev, $.sourcemaps.write()))
.pipe($.if(isDev, gulp.dest('.tmp/styles')))
.pipe($.if(isProd, $.csso()))
.pipe($.if(isProd, gulp.dest('dist/styles')))
.pipe($.size())
.pipe(livereload());
});
gulp.task('scripts', function() {
var bundler = browserify('./app/scripts/main.js', {debug: isDev});
bundler.transform(reactify);
var stream = bundler.bundle();
return stream
.pipe(source('bundle.js'))
.pipe($.if(isDev, gulp.dest('.tmp/scripts')))
.pipe($.if(isProd, buffer()))
.pipe($.if(isProd, $.uglify()))
.pipe($.if(isProd, gulp.dest('dist/scripts')))
.pipe(livereload())
.on('error', $.util.log);
});
gulp.task('html', ['styles', 'scripts'], function () {
return gulp.src('app/*.html')
.pipe($.preprocess())
//.pipe($.useref.assets({searchPath: 'dist'}))
//.pipe($.useref())
.pipe(gulp.dest('dist'))
.pipe($.size())
.pipe(livereload());
});
gulp.task('images', function () {
return gulp.src('app/images/**/*')
.pipe(gulp.dest('dist/images'))
.pipe($.size());
});
gulp.task('fonts', function () {
return gulp.src('node_modules/font-awesome/fonts/*')
.pipe($.filter('**/*.{eot,svg,ttf,woff}'))
.pipe($.flatten())
.pipe($.if(isDev, gulp.dest('.tmp/fonts')))
.pipe($.if(isProd, gulp.dest('dist/fonts')))
.pipe($.size());
});
gulp.task('extras', function () {
return gulp.src(['app/*.*', '!app/*.html'], { dot: true })
.pipe(gulp.dest('dist'));
});
gulp.task('clean', function () {
return del(['.tmp', 'dist']);
});
gulp.task('production', ['html', 'images', 'fonts', 'extras']);
gulp.task('build', function () {
isDev = false;
isProd = true;
gulp.start('production');
});
gulp.task('default', ['clean'], function () {
gulp.start('build');
});
gulp.task('connect', function () {
connect.server({
root: ['.tmp', 'app'],
port: 4041,
middleware: function(connect, o) {
return [(function() {
var url = require('url');
var proxy = require('proxy-middleware');
var options = url.parse('http://localhost:4040/api');
options.route = '/api';
return proxy(options);
})()];
},
livereload: false
});
});
gulp.task('serve', ['connect', 'styles', 'scripts', 'fonts'], function () {
//require('opn')('http://localhost:9000');
});
gulp.task('watch', ['serve'], function () {
livereload.listen();
gulp.watch('app/styles/**/*.less', ['styles']);
gulp.watch('app/scripts/**/*.js', ['scripts']);
gulp.watch('app/images/**/*', ['images']);
});

55
client/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "scope-webapp",
"version": "0.2.0",
"dependencies": {
"d3": "^3.5.3",
"dagre": "^0.7.1",
"flux": "^2.0.1",
"font-awesome": "^4.3.0",
"keymirror": "^0.1.1",
"lodash": "~3.0.1",
"material-ui": "^0.7.5",
"object-assign": "^2.0.0",
"page": "^1.6.0",
"react": "^0.13.2",
"react-tween-state": "0.0.5",
"reqwest": "~1.1.5"
},
"devDependencies": {
"browserify": "^8.1.3",
"connect": "^3.3.4",
"del": "^1.1.1",
"gulp": "^3.8.10",
"gulp-autoprefixer": "^2.1.0",
"gulp-cache": "^0.2.4",
"gulp-clean": "^0.3.1",
"gulp-connect": "^2.2.0",
"gulp-csso": "^1.0.0",
"gulp-filter": "^2.0.0",
"gulp-flatten": "^0.0.4",
"gulp-if": "^1.2.5",
"gulp-imagemin": "^2.1.0",
"gulp-jshint": "^1.9.2",
"gulp-less": "^3.0.3",
"gulp-livereload": "^3.8.0",
"gulp-load-plugins": "^0.8.0",
"gulp-preprocess": "^1.2.0",
"gulp-size": "^1.2.0",
"gulp-sourcemaps": "^1.3.0",
"gulp-uglify": "^1.1.0",
"gulp-useref": "^1.1.1",
"gulp-util": "^3.0.3",
"jshint-stylish": "^1.0.0",
"opn": "^1.0.1",
"proxy-middleware": "^0.11.0",
"reactify": "^1.1.0",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.0.0"
},
"scripts": {
"start": "npm install && gulp serve"
},
"engines": {
"node": ">=0.10.0"
}
}

3
client/test/.bowerrc Normal file
View File

@@ -0,0 +1,3 @@
{
"directory": "bower_components"
}

9
client/test/bower.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "gulp-webapp",
"private": true,
"dependencies": {
"chai": "~1.8.0",
"mocha": "~1.14.0"
},
"devDependencies": {}
}

26
client/test/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mocha Spec Runner</title>
<link rel="stylesheet" href="bower_components/mocha/mocha.css">
</head>
<body>
<div id="mocha"></div>
<script src="bower_components/mocha/mocha.js"></script>
<script>mocha.setup('bdd')</script>
<script src="bower_components/chai/chai.js"></script>
<script>
var assert = chai.assert;
var expect = chai.expect;
var should = chai.should();
</script>
<!-- include source files here... -->
<!-- include spec files here... -->
<script src="spec/test.js"></script>
<script>mocha.run()</script>
</body>
</html>

13
client/test/spec/test.js Normal file
View File

@@ -0,0 +1,13 @@
/* global describe, it */
(function () {
'use strict';
describe('Give it some context', function () {
describe('maybe a bit more context here', function () {
it('should run here few assertions', function () {
});
});
});
})();

19
demoprobe/Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: all vet lint build test clean
all: build test vet lint
vet:
go vet ./...
lint:
golint .
build:
go build
test:
go test
clean:
go clean

123
demoprobe/generate.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"fmt"
"math/rand"
"net"
"time"
"github.com/weaveworks/scope/scope/report"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
// DemoReport makes up a report.
func DemoReport(nodeCount int) report.Report {
r := report.NewReport()
// Make up some plausible IPv4 numbers
hosts := []string{}
ip := [4]int{192, 168, 1, 1}
for _ = range make([]struct{}, nodeCount) {
hosts = append(hosts, fmt.Sprintf("%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]))
ip[3]++
if ip[3] > 200 {
ip[2]++
ip[3] = 1
}
}
// Some non-local ones.
hosts = append(hosts, []string{"1.2.3.4", "2.3.4.5"}...)
_, localNet, err := net.ParseCIDR("192.168.0.0/16")
if err != nil {
panic(err)
}
type conn struct {
srcProc, dstProc string
dstPort int
}
procPool := []conn{
{srcProc: "curl", dstPort: 80, dstProc: "apache"},
{srcProc: "wget", dstPort: 80, dstProc: "apache"},
{srcProc: "curl", dstPort: 80, dstProc: "nginx"},
{srcProc: "curl", dstPort: 8080, dstProc: "app1"},
{srcProc: "nginx", dstPort: 8080, dstProc: "app1"},
{srcProc: "nginx", dstPort: 8080, dstProc: "app2"},
{srcProc: "nginx", dstPort: 8080, dstProc: "app3"},
}
connectionCount := nodeCount * 2
for i := 0; i < connectionCount; i++ {
var (
c = procPool[rand.Intn(len(procPool))]
src = hosts[rand.Intn(len(hosts))]
dst = hosts[rand.Intn(len(hosts))]
srcPort = rand.Intn(50000) + 10000
srcPortID = fmt.Sprintf("%s%s%s%d", report.ScopeDelim, src, report.ScopeDelim, srcPort)
dstPortID = fmt.Sprintf("%s%s%s%d", report.ScopeDelim, dst, report.ScopeDelim, c.dstPort)
srcID = "hostX" + report.IDDelim + srcPortID
dstID = "hostX" + report.IDDelim + dstPortID
srcAddressID = fmt.Sprintf("%s%s", report.ScopeDelim, src)
dstAddressID = fmt.Sprintf("%s%s", report.ScopeDelim, dst)
nodeSrcAddressID = "hostX" + report.IDDelim + srcAddressID
nodeDstAddressID = "hostX" + report.IDDelim + dstAddressID
)
// Process topology
if _, ok := r.Process.NodeMetadatas[srcPortID]; !ok {
r.Process.NodeMetadatas[srcPortID] = report.NodeMetadata{
"pid": "4000",
"name": c.srcProc,
"domain": "node-" + src,
}
}
r.Process.Adjacency[srcID] = r.Process.Adjacency[srcID].Add(dstPortID)
if _, ok := r.Process.NodeMetadatas[dstPortID]; !ok {
r.Process.NodeMetadatas[dstPortID] = report.NodeMetadata{
"pid": "4000",
"name": c.dstProc,
"domain": "node-" + dst,
}
}
r.Process.Adjacency[dstID] = r.Process.Adjacency[dstID].Add(srcPortID)
var (
edgeKeyEgress = srcPortID + report.IDDelim + dstPortID
edgeKeyIngress = dstPortID + report.IDDelim + srcPortID
)
r.Process.EdgeMetadatas[edgeKeyEgress] = report.EdgeMetadata{
WithConnCountTCP: true,
MaxConnCountTCP: uint(rand.Intn(100) + 10),
}
r.Process.EdgeMetadatas[edgeKeyIngress] = report.EdgeMetadata{
WithConnCountTCP: true,
MaxConnCountTCP: uint(rand.Intn(100) + 10),
}
// Network topology
if _, ok := r.Network.NodeMetadatas[srcAddressID]; !ok {
r.Network.NodeMetadatas[srcAddressID] = report.NodeMetadata{
"name": src,
}
}
r.Network.Adjacency[nodeSrcAddressID] = r.Network.Adjacency[nodeSrcAddressID].Add(dstAddressID)
if _, ok := r.Network.NodeMetadatas[dstAddressID]; !ok {
r.Network.NodeMetadatas[dstAddressID] = report.NodeMetadata{
"name": dst,
}
}
r.Network.Adjacency[nodeDstAddressID] = r.Network.Adjacency[nodeDstAddressID].Add(srcAddressID)
// Host data
r.HostMetadatas["hostX"] = report.HostMetadata{
Timestamp: time.Now().UTC(),
Hostname: "host-x",
LocalNets: []*net.IPNet{localNet},
OS: "linux",
}
}
return r
}

56
demoprobe/main.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/weaveworks/scope/scope/xfer"
)
func main() {
var (
version = flag.Bool("version", false, "print version number and exit")
publishInterval = flag.Duration("publish.interval", 1*time.Second, "publish (output) interval")
listen = flag.String("listen", ":"+strconv.Itoa(xfer.ProbePort), "listen address")
hostCount = flag.Int("hostcount", 10, "Number of demo hosts to generate")
)
flag.Parse()
if len(flag.Args()) != 0 {
flag.Usage()
os.Exit(1)
}
// -version flag:
if *version {
fmt.Printf("unstable\n")
return
}
publisher, err := xfer.NewTCPPublisher(*listen)
if err != nil {
log.Fatal(err)
}
defer publisher.Close()
go func() {
for {
publisher.Publish(DemoReport(*hostCount))
time.Sleep(*publishInterval)
}
}()
log.Printf("%s", <-interrupt())
log.Printf("Shutting down...")
}
func interrupt() chan os.Signal {
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
return c
}

44
entrypoint.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/sh
usage() {
echo "$0 --dns <IP> --hostname <NAME> --searchpath <SEARCHPATH>"
exit 1
}
# This script exists to modify the network settings in the scope containers
# as docker doesn't allow it when started with --net=host
while true; do
case "$1" in
--dns)
[ $# -gt 1 ] || usage
DNS_SERVER="$2"
shift 2
;;
--hostname)
[ $# -gt 1 ] || usage
HOSTNAME="$2"
shift 2
;;
--searchpath)
[ $# -gt 1 ] || usage
SEARCHPATH="$2"
shift 2
;;
*)
break
;;
esac
done
if [ -n "$DNS_SERVER" -a -n "$SEARCHPATH" ]; then
echo "domain $SEARCHPATH" >/etc/resolv.conf
echo "search $SEARCHPATH" >>/etc/resolv.conf
echo "nameserver $DNS_SERVER" >>/etc/resolv.conf
fi
if [ -n "$HOSTNAME" ]; then
echo "$HOSTNAME" >/etc/hostname
hostname -F /etc/hostname
fi
/usr/bin/supervisord -c /etc/supervisord.conf

19
fixprobe/Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: all vet lint build test clean
all: build test vet lint
vet:
go vet ./...
lint:
golint .
build:
go build
test:
go test
clean:
go clean

52
fixprobe/main.go Normal file
View File

@@ -0,0 +1,52 @@
// Publish a fixed report.
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/weaveworks/scope/scope/report"
"github.com/weaveworks/scope/scope/xfer"
)
func main() {
var (
publishInterval = flag.Duration("publish.interval", 1*time.Second, "publish (output) interval")
listenAddress = flag.String("listen", ":"+strconv.Itoa(xfer.ProbePort), "listen address")
)
flag.Parse()
if len(flag.Args()) != 1 {
fmt.Printf("usage: fixprobe [--args] report.json\n")
return
}
fixture := flag.Arg(0)
f, err := os.Open(fixture)
if err != nil {
fmt.Printf("json error: %v\n", err)
return
}
var fixedReport report.Report
if err := json.NewDecoder(f).Decode(&fixedReport); err != nil {
fmt.Printf("json error: %v\n", err)
return
}
publisher, err := xfer.NewTCPPublisher(*listenAddress)
if err != nil {
log.Fatal(err)
}
defer publisher.Close()
log.Printf("listening on %s", *listenAddress)
for _ = range time.Tick(*publishInterval) {
publisher.Publish(fixedReport)
}
}

19
genreport/Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: all vet lint build test clean
all: build test vet lint
vet:
go vet ./...
lint:
golint .
build:
go build
test:
go test
clean:
go clean

123
genreport/generate.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"fmt"
"math/rand"
"net"
"time"
"github.com/weaveworks/scope/scope/report"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
// DemoReport makes up a report.
func DemoReport(nodeCount int) report.Report {
r := report.NewReport()
// Make up some plausible IPv4 numbers
hosts := []string{}
ip := [4]int{192, 168, 1, 1}
for _ = range make([]struct{}, nodeCount) {
hosts = append(hosts, fmt.Sprintf("%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]))
ip[3]++
if ip[3] > 200 {
ip[2]++
ip[3] = 1
}
}
// Some non-local ones.
hosts = append(hosts, []string{"1.2.3.4", "2.3.4.5"}...)
_, localNet, err := net.ParseCIDR("192.168.0.0/16")
if err != nil {
panic(err)
}
type conn struct {
srcProc, dstProc string
dstPort int
}
procPool := []conn{
{srcProc: "curl", dstPort: 80, dstProc: "apache"},
{srcProc: "wget", dstPort: 80, dstProc: "apache"},
{srcProc: "curl", dstPort: 80, dstProc: "nginx"},
{srcProc: "curl", dstPort: 8080, dstProc: "app1"},
{srcProc: "nginx", dstPort: 8080, dstProc: "app1"},
{srcProc: "nginx", dstPort: 8080, dstProc: "app2"},
{srcProc: "nginx", dstPort: 8080, dstProc: "app3"},
}
connectionCount := nodeCount * 8
for i := 0; i < connectionCount; i++ {
var (
c = procPool[rand.Intn(len(procPool))]
src = hosts[rand.Intn(len(hosts))]
dst = hosts[rand.Intn(len(hosts))]
srcPort = rand.Intn(50000) + 10000
srcPortID = fmt.Sprintf("%s%s%s%d", report.ScopeDelim, src, report.ScopeDelim, srcPort)
dstPortID = fmt.Sprintf("%s%s%s%d", report.ScopeDelim, dst, report.ScopeDelim, c.dstPort)
srcID = "hostX" + report.IDDelim + srcPortID
dstID = "hostX" + report.IDDelim + dstPortID
srcAddressID = fmt.Sprintf("%s%s", report.ScopeDelim, src)
dstAddressID = fmt.Sprintf("%s%s", report.ScopeDelim, dst)
nodeSrcAddressID = "hostX" + report.IDDelim + srcAddressID
nodeDstAddressID = "hostX" + report.IDDelim + dstAddressID
)
// Process topology
if _, ok := r.Process.NodeMetadatas[srcPortID]; !ok {
r.Process.NodeMetadatas[srcPortID] = report.NodeMetadata{
"pid": "4000",
"name": c.srcProc,
"domain": "node-" + src,
}
}
r.Process.Adjacency[srcID] = r.Process.Adjacency[srcID].Add(dstPortID)
if _, ok := r.Process.NodeMetadatas[dstPortID]; !ok {
r.Process.NodeMetadatas[dstPortID] = report.NodeMetadata{
"pid": "4000",
"name": c.dstProc,
"domain": "node-" + dst,
}
}
r.Process.Adjacency[dstID] = r.Process.Adjacency[dstID].Add(srcPortID)
var (
edgeKeyEgress = srcPortID + report.IDDelim + dstPortID
edgeKeyIngress = dstPortID + report.IDDelim + srcPortID
)
r.Process.EdgeMetadatas[edgeKeyEgress] = report.EdgeMetadata{
WithConnCountTCP: true,
MaxConnCountTCP: uint(rand.Intn(100) + 10),
}
r.Process.EdgeMetadatas[edgeKeyIngress] = report.EdgeMetadata{
WithConnCountTCP: true,
MaxConnCountTCP: uint(rand.Intn(100) + 10),
}
// Network topology
if _, ok := r.Network.NodeMetadatas[srcAddressID]; !ok {
r.Network.NodeMetadatas[srcAddressID] = report.NodeMetadata{
"name": src,
}
}
r.Network.Adjacency[nodeSrcAddressID] = r.Network.Adjacency[nodeSrcAddressID].Add(dstAddressID)
if _, ok := r.Network.NodeMetadatas[dstAddressID]; !ok {
r.Network.NodeMetadatas[dstAddressID] = report.NodeMetadata{
"name": dst,
}
}
r.Network.Adjacency[nodeDstAddressID] = r.Network.Adjacency[nodeDstAddressID].Add(srcAddressID)
// Host data
r.HostMetadatas["hostX"] = report.HostMetadata{
Timestamp: time.Now().UTC(),
Hostname: "host-x",
LocalNets: []*net.IPNet{localNet},
OS: "linux",
}
}
return r
}

13
genreport/main.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"encoding/json"
"flag"
"os"
)
func main() {
nodes := flag.Int("nodes", 10, "node count")
flag.Parse()
json.NewEncoder(os.Stdout).Encode(DemoReport(*nodes))
}

109
graphviz/handle.go Normal file
View File

@@ -0,0 +1,109 @@
package main
import (
"fmt"
"io"
"net/http"
"os/exec"
"sort"
"strings"
"github.com/weaveworks/scope/scope/report"
)
func handleTXT(r Reporter) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain")
dot(w, r.Report().Process.RenderBy(mapFunc(req), classView(req)))
}
}
func handleSVG(r Reporter) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
cmd := exec.Command(engine(req), "-Tsvg")
wc, err := cmd.StdinPipe()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cmd.Stdout = w
dot(wc, r.Report().Process.RenderBy(mapFunc(req), classView(req)))
wc.Close()
w.Header().Set("Content-Type", "image/svg+xml")
if err := cmd.Run(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func handleHTML(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, "<html><head>\n")
fmt.Fprintf(w, `<meta http-equiv="refresh" content="3">`+"\n")
fmt.Fprintf(w, "</head><body>\n")
fmt.Fprintf(w, `<center><img src="/svg?%s" width="100%%" height="95%%"></center>`+"\n", r.URL.Query().Encode())
fmt.Fprintf(w, "</body></html>\n")
}
func dot(w io.Writer, m map[string]report.RenderableNode) {
fmt.Fprintf(w, "digraph G {\n")
fmt.Fprintf(w, "\tgraph [ overlap=false ];\n")
fmt.Fprintf(w, "\tnode [ shape=circle, style=filled ];\n")
fmt.Fprintf(w, "\toutputorder=edgesfirst;\n")
fmt.Fprintf(w, "\n")
// Sorting the nodes seems to stop jumpiness.
nodes := make(sort.StringSlice, 0, len(m))
for _, node := range m {
nodes = append(nodes, fmt.Sprintf("\t\"%s\" [label=\"%s\n%s\"];\n", node.ID, node.LabelMajor, node.LabelMinor))
}
sort.Sort(nodes)
for _, s := range nodes {
fmt.Fprint(w, s)
}
fmt.Fprintf(w, "\n")
// Add ranking information by default.
// Non-dot engines don't seem to be harmed by it.
same := map[string][]string{}
for _, node := range m {
k, v := node.LabelMajor, fmt.Sprintf(`"%s"`, node.ID)
same[k] = append(same[k], v)
}
for _, ids := range same {
fmt.Fprintf(w, "\t{ rank=same; %s }\n", strings.Join(ids, " "))
}
fmt.Fprintf(w, "\n")
for _, src := range m {
for _, dstID := range src.Adjacency {
fmt.Fprintf(w, "\t\"%s\" -> \"%s\";\n", src.ID, dstID)
}
}
fmt.Fprintf(w, "}\n")
}
func engine(r *http.Request) string {
engine := r.FormValue("engine")
if engine == "" {
engine = "dot"
}
return engine
}
func mapFunc(r *http.Request) report.MapFunc {
f, ok := report.MapFuncRegistry[strings.ToLower(r.FormValue("map_func"))]
if !ok {
f = report.ProcessName
}
return f
}
func classView(r *http.Request) bool {
return r.FormValue("class_view") == "true"
}

53
graphviz/main.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/weaveworks/scope/scope/xfer"
)
func main() {
var (
defaultProbes = fmt.Sprintf("localhost:%d", xfer.ProbePort)
probes = flag.String("probes", defaultProbes, "list of probe endpoints, comma separated")
batch = flag.Duration("batch", 1*time.Second, "batch interval")
window = flag.Duration("window", 15*time.Second, "window")
listen = flag.String("http.address", ":"+strconv.Itoa(xfer.AppPort), "webserver listen address")
)
flag.Parse()
xfer.MaxBackoff = 10 * time.Second
c := xfer.NewCollector(*batch)
c.AddAddresses(strings.Split(*probes, ","))
defer c.Stop()
lifo := NewReportLIFO(c, *window)
defer lifo.Stop()
http.Handle("/svg", handleSVG(lifo))
http.Handle("/txt", handleTXT(lifo))
http.Handle("/", http.HandlerFunc(handleHTML))
irq := interrupt()
go func() {
log.Printf("listening on %s", *listen)
log.Print(http.ListenAndServe(*listen, nil))
irq <- syscall.SIGINT
}()
<-irq
log.Printf("shutting down")
}
func interrupt() chan os.Signal {
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
return c
}

91
graphviz/report_lifo.go Normal file
View File

@@ -0,0 +1,91 @@
package main
import (
"time"
"github.com/weaveworks/scope/scope/report"
)
// Copy/paste from app/report_lifo.go
// Reporter XXX
type Reporter interface {
Report() report.Report
}
type timedReport struct {
report.Report
Timestamp time.Time
}
// ReportLIFO XXX
type ReportLIFO struct {
reports []timedReport
requests chan chan report.Report
quit chan chan struct{}
}
type reporter interface {
Reports() <-chan report.Report
}
// NewReportLIFO XXX
func NewReportLIFO(r reporter, maxAge time.Duration) *ReportLIFO {
l := ReportLIFO{
reports: []timedReport{},
requests: make(chan chan report.Report),
quit: make(chan chan struct{}),
}
go func() {
for {
select {
case report := <-r.Reports():
report = report.SquashRemote()
tr := timedReport{
Timestamp: time.Now(),
Report: report,
}
l.reports = append(l.reports, tr)
l.reports = cleanOld(l.reports, time.Now().Add(-maxAge))
case req := <-l.requests:
report := report.NewReport()
for _, r := range l.reports {
report.Merge(r.Report)
}
req <- report
case q := <-l.quit:
close(q)
return
}
}
}()
return &l
}
// Stop XXX
func (r *ReportLIFO) Stop() {
q := make(chan struct{})
r.quit <- q
<-q
}
// Report XXX
func (r *ReportLIFO) Report() report.Report {
req := make(chan report.Report)
r.requests <- req
return <-req
}
func cleanOld(reports []timedReport, threshold time.Time) []timedReport {
res := make([]timedReport, 0, len(reports))
for _, tr := range reports {
if tr.Timestamp.Before(threshold) {
continue
}
res = append(res, tr)
}
return res
}

19
integration/Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: all vet lint build test clean
all: build test vet lint
vet:
go vet ./...
lint:
golint .
build:
go build
test:
go test
clean:
go clean

81
integration/cmd.go Normal file
View File

@@ -0,0 +1,81 @@
package integration
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// cmdline is e.g. `fixprobe -publish.interval=10ms fixture.json`
func start(t *testing.T, cmdline string) *exec.Cmd {
toks := strings.Split(cmdline, " ")
if len(toks) <= 0 {
t.Fatalf("bad cmdline %q", cmdline)
}
component, args := toks[0], toks[1:]
relpath := fmt.Sprintf("../%s/%s", component, component)
if _, err := os.Stat(relpath); err != nil {
t.Fatalf("%s: %s", component, err)
}
cmd := &exec.Cmd{
Dir: filepath.Dir(relpath),
Path: filepath.Base(relpath),
Args: append([]string{filepath.Base(relpath)}, args...),
Stdout: testWriter{t, component},
Stderr: testWriter{t, component},
}
if err := cmd.Start(); err != nil {
t.Fatalf("%s: Start: %s", component, err)
}
return cmd
}
func stop(t *testing.T, c *exec.Cmd) {
done := make(chan struct{})
go func() {
defer close(done)
if err := c.Process.Kill(); err != nil {
t.Fatalf("%s: Kill: %s", filepath.Base(c.Path), err)
}
if _, err := c.Process.Wait(); err != nil {
t.Fatalf("%s: Wait: %s", filepath.Base(c.Path), err)
}
}()
select {
case <-done:
case <-time.After(250 * time.Millisecond):
t.Fatalf("timeout when trying to stop %s", filepath.Base(c.Path))
}
}
type testWriter struct {
*testing.T
component string
}
func (w testWriter) Write(p []byte) (int, error) {
w.T.Logf("<%10s> %s", w.component, p)
return len(p), nil
}
func cwd() string {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
return cwd
}

79
integration/contexts.go Normal file
View File

@@ -0,0 +1,79 @@
package integration
import (
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
)
type context int
const (
oneProbe context = iota
twoProbes
)
var (
appPort = 14030
bridgePort = 14020
probePort1 = 14010
probePort2 = 14011
)
func withContext(t *testing.T, c context, tests ...func()) {
var (
publish = 10 * time.Millisecond
batch = 10 * publish
wait = 2 * batch
)
switch c {
case oneProbe:
probe := start(t, fmt.Sprintf(`fixprobe -listen=:%d -publish.interval=%s %s/test_single_report.json`, probePort1, publish, cwd()))
defer stop(t, probe)
time.Sleep(10 * time.Millisecond)
app := start(t, fmt.Sprintf(`app -http.address=:%d -probes=localhost:%d -batch=%s`, appPort, probePort1, batch))
defer stop(t, app)
case twoProbes:
probe1 := start(t, fmt.Sprintf(`fixprobe -listen=:%d -publish.interval=%s %s/test_single_report.json`, probePort1, publish, cwd()))
defer stop(t, probe1)
probe2 := start(t, fmt.Sprintf(`fixprobe -listen=:%d -publish.interval=%s %s/test_extra_report.json`, probePort2, publish, cwd()))
defer stop(t, probe2)
time.Sleep(10 * time.Millisecond)
app := start(t, fmt.Sprintf(`app -http.address=:%d -probes=localhost:%d,localhost:%d -batch=%s`, appPort, probePort1, probePort2, batch))
defer stop(t, app)
default:
t.Fatalf("bad context %v", c)
}
time.Sleep(wait)
for _, f := range tests {
f()
}
}
func httpGet(t *testing.T, url string) []byte {
resp, err := http.Get(url)
if err != nil {
t.Fatalf("httpGet: %s", err)
}
defer resp.Body.Close()
if status := resp.StatusCode; status != 200 {
t.Fatalf("httpGet got status %d, expected 200", status)
}
buf, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatalf("httpGet: %s", err)
}
return buf
}

2
integration/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package integration implements integration tests between components.
package integration

98
integration/easy_test.go Normal file
View File

@@ -0,0 +1,98 @@
package integration
import (
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/weaveworks/scope/scope/report"
)
func TestComponentsAreAvailable(t *testing.T) {
var pause = 1 * time.Millisecond
for _, c := range []string{
fmt.Sprintf(`app -http.address=:%d`, appPort),
fmt.Sprintf(`bridge -listen=:%d`, bridgePort),
fmt.Sprintf(`fixprobe -listen=:%d`, probePort1),
fmt.Sprintf(`demoprobe -listen=:%d`, probePort1),
} {
cmd := start(t, c)
time.Sleep(pause)
stop(t, cmd)
t.Logf("%s: OK", filepath.Base(cmd.Path))
}
}
func TestApplications(t *testing.T) {
withContext(t, oneProbe, func() {
topo := parseTopology(t, httpGet(t, fmt.Sprintf("http://localhost:%d/api/topology/applications", appPort)))
assertAdjacent(t, topo["proc:node-1.2.3.4:apache"], "theinternet", "proc:node-192.168.1.1:wget")
have := parseEdge(t, httpGet(t, fmt.Sprintf("http://localhost:%d/api/topology/applications/%s/%s", appPort, "proc:node-192.168.1.1:wget", "theinternet")))
want := map[string]interface{}{
"max_conn_count_tcp": float64(19),
}
if !reflect.DeepEqual(have, want) {
t.Errorf("have: %#v, want %#v", have, want)
}
})
}
func TestHosts(t *testing.T) {
withContext(t, oneProbe, func() {
topo := parseTopology(t, httpGet(t, fmt.Sprintf("http://localhost:%d/api/topology/hosts", appPort)))
assertAdjacent(t, topo["host:1_2_3_4"], "theinternet", "host:192_168_1_1")
have := parseEdge(t, httpGet(t, fmt.Sprintf("http://localhost:%d/api/topology/hosts/%s/%s", appPort, "host:192_168_1_1", "theinternet")))
want := map[string]interface{}{
// "window": "15s",
"max_conn_count_tcp": float64(12),
}
if !reflect.DeepEqual(have, want) {
t.Errorf("have: %#v, want %#v", have, want)
}
})
}
func TestMultipleProbes(t *testing.T) {
withContext(t, twoProbes, func() {
topo := parseTopology(t, httpGet(t, fmt.Sprintf("http://localhost:%d/api/topology/applications", appPort)))
assertAdjacent(t, topo["proc:node-1.2.3.4:apache"], "theinternet", "proc:node-192.168.1.1:wget", "proc:node-192.168.1.1:curl")
})
}
func parseTopology(t *testing.T, p []byte) map[string]report.RenderableNode {
var r struct {
Nodes map[string]report.RenderableNode `json:"nodes"`
}
if err := json.Unmarshal(p, &r); err != nil {
t.Fatalf("parseTopology: %s", err)
}
return r.Nodes
}
func parseEdge(t *testing.T, p []byte) map[string]interface{} {
var edge struct {
Metadata map[string]interface{} `json:"metadata"`
}
if err := json.Unmarshal(p, &edge); err != nil {
t.Fatalf("Err: %v", err)
}
return edge.Metadata
}
func assertAdjacent(t *testing.T, n report.RenderableNode, ids ...string) {
want := report.NewIDList(ids...)
if have := n.Adjacency; !reflect.DeepEqual(want, have) {
t.Fatalf("want adjacency list %v, have %v", want, have)
}
}

0
integration/ignore_tests Normal file
View File

View File

@@ -0,0 +1,63 @@
{
"Process": {
"Adjacency": {
"hostX|;1.2.3.4;80": [
";192.168.1.1;10747",
"theinternet"
],
"hostX|;192.168.1.1;10747": [
"theinternet"
]
},
"EdgeMetadatas": {},
"NodeMetadatas": {
";1.2.3.4;80": {
"domain": "node-1.2.3.4",
"name": "apache",
"pid": "4000"
},
";192.168.1.1;10747": {
"domain": "node-192.168.1.1",
"name": "curl",
"pid": "4001"
}
}
},
"Network": {
"Adjacency": {
"hostX|;1.2.3.4": [
";192.168.1.1",
"theinternet"
],
"hostX|;192.168.1.1": [
"theinternet",
";192.168.1.1"
]
},
"EdgeMetadatas": {},
"NodeMetadatas": {
";1.2.3.4": {
"name": "1.2.3.4"
},
";192.168.1.1": {
"name": "192.168.1.1"
}
}
},
"HostMetadatas": {
"hostX": {
"Timestamp": "2014-10-28T23:12:23.451728464Z",
"Hostname": "host-x",
"Addresses": null,
"LocalNets": [
{
"IP": "192.168.0.0",
"Mask": "//8AAA=="
}
],
"OS": "linux"
}
}
}

View File

@@ -0,0 +1,73 @@
{
"Process": {
"Adjacency": {
"hostX|;1.2.3.4;80": [
";192.168.1.1;10746",
"theinternet"
],
"hostX|;192.168.1.1;10746": [
"theinternet"
]
},
"EdgeMetadatas": {
";192.168.1.1;10746|theinternet": {
"WithConnCountTCP": true,
"MaxConnCountTCP": 19
}
},
"NodeMetadatas": {
";1.2.3.4;80": {
"domain": "node-1.2.3.4",
"name": "apache",
"pid": "4000"
},
";192.168.1.1;10746": {
"domain": "node-192.168.1.1",
"name": "wget",
"pid": "4000"
}
}
},
"Network": {
"Adjacency": {
"hostX|;1.2.3.4": [
";192.168.1.1",
"theinternet"
],
"hostX|;192.168.1.1": [
"theinternet",
";192.168.1.1"
]
},
"EdgeMetadatas": {
";192.168.1.1|theinternet": {
"WithConnCountTCP": true,
"MaxConnCountTCP": 12
}
},
"NodeMetadatas": {
";1.2.3.4": {
"name": "1_2_3_4"
},
";192.168.1.1": {
"name": "192_168_1_1"
}
}
},
"HostMetadatas": {
"hostX": {
"Timestamp": "2014-10-28T23:12:23.451728464Z",
"Hostname": "host-x",
"Addresses": null,
"LocalNets": [
{
"IP": "192.168.0.0",
"Mask": "//8AAA=="
}
],
"OS": "linux"
}
}
}

19
oneshot/Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: all vet lint build test clean
all: build test vet lint
vet:
go vet ./...
lint:
golint .
build:
go build
test:
go test
clean:
go clean

58
oneshot/main.go Normal file
View File

@@ -0,0 +1,58 @@
package main
import (
"encoding/json"
"flag"
"fmt"
_ "net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/weaveworks/scope/scope/report"
"github.com/weaveworks/scope/scope/xfer"
)
func main() {
var (
defaultProbes = fmt.Sprintf("localhost:%d", xfer.ProbePort)
probes = flag.String("probes", defaultProbes, "list of probe endpoints, comma separated")
)
flag.Parse()
if len(flag.Args()) != 0 {
flag.Usage()
os.Exit(1)
}
// Collector deals with the probes, and generates merged reports.
xfer.MaxBackoff = 1 * time.Second
c := xfer.NewCollector(1 * time.Second)
c.AddAddresses(strings.Split(*probes, ","))
defer c.Stop()
report := report.NewReport()
irq := interrupt()
OUTER:
for {
select {
case r := <-c.Reports():
report.Merge(r)
case <-irq:
break OUTER
}
}
b, err := json.Marshal(report)
if err != nil {
panic(err)
}
fmt.Print(string(b))
}
func interrupt() chan os.Signal {
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
return c
}

Some files were not shown because too many files have changed in this diff Show More