├── .gitignore
├── LICENSE
├── README.md
├── TODO.txt
├── chart
├── chart.go
└── chart_test.go
├── clients
├── bolt.go
├── bolt_test.go
└── http.go
├── cmd
└── cmd.go
├── loader
├── csv.go
├── csv_test.go
├── loader.go
└── xls.go
├── main.go
├── parser
└── parser.go
├── sample_data
├── GlobalTemperatures.csv
└── LakeHuron.csv
├── server
├── handler.go
├── handler_test.go
└── server.go
├── types
├── functions.go
├── functions_test.go
├── grouping.go
├── grouping_test.go
├── query.go
├── query_test.go
├── types.go
└── types_test.go
└── www
├── css
├── app.css
├── bootstrap.min.css
└── font-awesome.min.css
├── fonts
├── FontAwesome.otf
├── fontawesome-webfont.eot
├── fontawesome-webfont.svg
├── fontawesome-webfont.ttf
├── fontawesome-webfont.woff
├── fontawesome-webfont.woff2
├── glyphicons-halflings-regular.eot
├── glyphicons-halflings-regular.svg
├── glyphicons-halflings-regular.ttf
├── glyphicons-halflings-regular.woff
└── glyphicons-halflings-regular.woff2
├── html
├── base.html
├── browse.html
├── explore.html
└── panel.html
├── images
├── gopher-fit.ico
├── gopher-fit.png
├── gopher-fit.xcf
├── tcx.png
└── temperatures.png
└── js
├── app.js
├── bootstrap.min.js
└── jquery.min.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 |
6 | # Folders
7 | _obj
8 | _test
9 |
10 | # Architecture specific extensions/prefixes
11 | *.[568vq]
12 | [568vq].out
13 |
14 | *.cgo1.go
15 | *.cgo2.c
16 | _cgo_defun.c
17 | _cgo_gotypes.go
18 | _cgo_export.*
19 |
20 | _testmain.go
21 |
22 | *.exe
23 | *.test
24 | *.prof
25 |
26 | *.swp
27 |
28 | Takeout/*
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 Kevin Schoon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ### Fit
4 |
5 | Fit is a toolkit for exploring and manipulating datasets. Go has [many](https://github.com/gonum) [great](https://github.com/montanaflynn/stats)
6 | statistical libraries but is largely unrepresented in the data science world.
7 | Fit aims to be a general purpose tool for handling data [ETL](https://en.wikipedia.org/wiki/Extract,_transform,_load),
8 | analytics, and visualization.
9 |
10 | Note that this project is **largely unfinished** and unsuitable for any practical purpose.
11 |
12 | #### Components
13 |
14 | Below is a rough outline of the different commonents that currently exist in Fit.
15 |
16 | ##### Dataset
17 |
18 | All numerical values are internally represented as `float64` and are logically groupped into a
19 | `Dataset` object. Datasets use the excellent [Gonum Matrix](https://github.com/gonum/matrix)
20 | underneath which are simmilar to Numpy's multi-demensional arrays.
21 |
22 | ##### Loaders
23 |
24 | Loaders perform iterative scanning of a file path and emit an `[]string` array for each row of data
25 | until EOF is reached. Currently only `csv` and `xls` loaders exist.
26 |
27 | ##### Parsers
28 |
29 | Parsers perform pre-processing on string data prior to being loaded into a dataset.
30 |
31 | ###### TimeParser
32 |
33 | TimeParser accepts a [formatted](https://golang.org/pkg/time/#Parse) string and stores the result
34 | as Unix epoch time.
35 |
36 | #### Usage
37 |
38 | fit --help
39 | Usage: fit [OPTIONS] COMMAND [arg...]
40 |
41 | Fit is a toolkit for exploring, extracting, and transforming datasets
42 |
43 | Options:
44 | -d, --db="" Path to a BoltDB database, default: /tmp/fit.db
45 | -s, --server="" Fit API server, default: http://127.0.0.1:8000
46 | -h, --human=true output data as human readable text
47 | -j, --json=false output data in JSON format
48 | -v, --version Show the version and exit
49 |
50 | Commands:
51 | server Run the Fit web server
52 | load load a dataset into BoltDB
53 | ls list datasets loaded into the database with their columns
54 | rm Delete a dataset
55 | query Query values from one or more datasets
56 |
57 |
58 | #### Examples
59 |
60 |
61 | # Start the Fit HTTP server
62 | fit server
63 | # Load sample data
64 | fit load --name LakeHuron sample_data/LakeHuron.csv
65 | # List datasets
66 | fit ls
67 |
68 | NAME ROWS COLS COLUMNS
69 | LakeHuron 98 3 [ time LakeHuron]
70 |
71 | # Query
72 | fit query -n "LakeHuron,time,LakeHuron"
73 |
74 | [time LakeHuron]
75 |
76 | Dims(98, 2)
77 | ⎡ 1875 580.38⎤
78 | ⎢ 1876 581.86⎥
79 | ⎢ 1877 580.97⎥
80 | ⎢ 1878 580.8⎥
81 | ⎢ 1879 579.79⎥
82 | .
83 | .
84 | .
85 | ⎢ 1968 578.52⎥
86 | ⎢ 1969 579.74⎥
87 | ⎢ 1970 579.31⎥
88 | ⎢ 1971 579.89⎥
89 | ⎣ 1972 579.96⎦
90 |
91 |
92 | fit --json query -n "LakeHuron,time,LakeHuron" |jq .
93 |
94 | {
95 | "Name": "QueryResult",
96 | "Columns": [
97 | "time",
98 | "LakeHuron"
99 | ],
100 | "Stats": {
101 | "Rows": 98,
102 | "Columns": 2
103 | },
104 | "Mtx": [
105 | 1875,
106 | 580.38 ....
107 |
108 | # Open your web browser and perform the same query:
109 | # http://localhost:8000/explore?q=LakeHuron,time&q=LakeHuron,LakeHuron
110 |
111 |
112 | #### Web Interface
113 |
114 | Fit provides a web interface for interactively exploring queries.
115 |
116 |
117 |
118 |
119 |
120 |
--------------------------------------------------------------------------------
/TODO.txt:
--------------------------------------------------------------------------------
1 | Truncate column names
2 | Remove extension from default name
3 | Handle missing query values (no panic)
4 | Add ability to rename columns
5 | Fix errors with large datasets
6 | Support arbitrary JSON
7 | Catch matrix panic
8 | Add ability to "discover" time fields
9 | Add new chart types
10 | Add SQL backend
11 |
--------------------------------------------------------------------------------
/chart/chart.go:
--------------------------------------------------------------------------------
1 | package chart
2 |
3 | import (
4 | mtx "github.com/gonum/matrix/mat64"
5 | "github.com/gonum/plot"
6 | "github.com/gonum/plot/plotter"
7 | "github.com/gonum/plot/plotutil"
8 | "github.com/gonum/plot/vg"
9 | "github.com/gonum/plot/vg/draw"
10 | "image/color"
11 | )
12 |
13 | type Config struct {
14 | PrimaryColor color.Color
15 | SecondaryColor color.Color
16 | XLabel string
17 | YLabel string
18 | Title string
19 | Type string
20 | Columns []string
21 | Width vg.Length
22 | Height vg.Length
23 | PlotTime bool
24 | }
25 |
26 | type Vector struct {
27 | Name string
28 | Position int
29 | }
30 |
31 | func getPlot(cfg Config) (*plot.Plot, error) {
32 | plt, err := plot.New()
33 | if err != nil {
34 | return nil, err
35 | }
36 | plt.Legend.Color = cfg.PrimaryColor
37 | plt.Legend.Top = true
38 | plt.BackgroundColor = cfg.SecondaryColor
39 |
40 | //plt.Title.Text = cfg.Title
41 | //plt.Title.Color = cfg.PrimaryColor
42 | //plt.Title.Font.Size = 0.5 * vg.Inch
43 |
44 | //plt.Y.Label.Text = cfg.YLabel
45 | plt.Y.Color = cfg.PrimaryColor
46 | plt.Y.Label.Color = cfg.PrimaryColor
47 | plt.Y.Label.Font.Size = 0.3 * vg.Inch
48 | plt.Y.Tick.Color = cfg.PrimaryColor
49 | plt.Y.Tick.Label.Font.Size = 0.2 * vg.Inch
50 | plt.Y.Tick.Label.Color = cfg.PrimaryColor
51 |
52 | //plt.X.Label.Text = cfg.XLabel
53 | plt.X.Color = cfg.PrimaryColor
54 | plt.X.Label.Color = cfg.PrimaryColor
55 | plt.X.Label.Font.Size = 0.3 * vg.Inch
56 | plt.X.Tick.Color = cfg.PrimaryColor
57 | plt.X.Tick.Label.Color = cfg.PrimaryColor
58 | plt.X.Tick.Label.Font.Size = 0.2 * vg.Inch
59 |
60 | if cfg.PlotTime {
61 | plt.X.Tick.Marker = plot.UnixTimeTicks{Format: "2006-01-02"}
62 | }
63 | return plt, nil
64 | }
65 |
66 | // GetLines returns variadic arguments for plotter.AddLines
67 | // The chart will always plot the first column vector as
68 | // the X axis and remaining columns on the Y axis.
69 | //
70 | // x,y,z
71 | // 1,1,2
72 | // 2,3,4
73 | // 3,5,6
74 | //
75 | // Line Y: 1,1 2,3 3,5
76 | // Line Z: 1,2 2,4 3,6
77 |
78 | func GetLines(mx *mtx.Dense, columns []string) []interface{} {
79 | data := make([]interface{}, 0)
80 | r, c := mx.Dims()
81 | for i := 1; i < c; i++ {
82 | xys := make(plotter.XYs, r)
83 | for j := 0; j < r; j++ {
84 | xys[j].X = mx.At(j, 0)
85 | xys[j].Y = mx.At(j, i)
86 | }
87 | data = append(data, columns[i])
88 | data = append(data, xys)
89 | }
90 | return data
91 | }
92 |
93 | // GetValues returns an array of plotter.Values
94 | // where each entry is a column vector.
95 | func GetValues(mx *mtx.Dense) []plotter.Values {
96 | _, c := mx.Dims()
97 | values := make([]plotter.Values, c)
98 | for i := 0; i < c; i++ {
99 | values[i] = plotter.Values(mtx.Col(nil, i, mx))
100 | }
101 | return values
102 | }
103 |
104 | func New(cfg Config, mx *mtx.Dense) (vg.CanvasWriterTo, error) {
105 | plt, err := getPlot(cfg)
106 | if err != nil {
107 | return nil, err
108 | }
109 | switch cfg.Type {
110 | case "box":
111 | values := GetValues(mx)
112 | for i, vals := range values {
113 | box, err := plotter.NewBoxPlot(vg.Points(20), float64(i), vals)
114 | if err != nil {
115 | return nil, err
116 | }
117 | box.WhiskerStyle.Color = cfg.PrimaryColor
118 | box.BoxStyle.Color = cfg.PrimaryColor
119 | box.MedianStyle.Color = cfg.PrimaryColor
120 | plt.Add(box)
121 | }
122 | default: // Default to line chart
123 | if err := plotutil.AddLines(plt, GetLines(mx, cfg.Columns)...); err != nil {
124 | return nil, err
125 | }
126 | }
127 | plt.Add(plotter.NewGrid())
128 | canvas, err := draw.NewFormattedCanvas(cfg.Width, cfg.Height, "png")
129 | if err != nil {
130 | return nil, err
131 | }
132 | plt.Draw(draw.New(canvas))
133 | return canvas, nil
134 | }
135 |
--------------------------------------------------------------------------------
/chart/chart_test.go:
--------------------------------------------------------------------------------
1 | package chart
2 |
3 | import (
4 | mtx "github.com/gonum/matrix/mat64"
5 | "github.com/gonum/plot/plotter"
6 | "github.com/stretchr/testify/assert"
7 | "testing"
8 | )
9 |
10 | func TestGetLines(t *testing.T) {
11 | mx := mtx.NewDense(3, 3, []float64{
12 | 1.0, 1.0, 2.0,
13 | 2.0, 3.0, 4.0,
14 | 3.0, 5.0, 6.0,
15 | })
16 | data := GetLines(mx, []string{"x", "y", "z"})
17 | assert.Len(t, data, 4)
18 | assert.IsType(t, "", data[0])
19 | name := data[0].(string)
20 | assert.Equal(t, "y", name)
21 | assert.IsType(t, plotter.XYs{}, data[1])
22 | xys := data[1].(plotter.XYs)
23 | assert.Equal(t, 3, xys.Len())
24 | x, y := xys.XY(0)
25 | assert.Equal(t, 1.0, x)
26 | assert.Equal(t, 1.0, y)
27 | x, y = xys.XY(1)
28 | assert.Equal(t, 2.0, x)
29 | assert.Equal(t, 3.0, y)
30 | x, y = xys.XY(2)
31 | assert.Equal(t, 3.0, x)
32 | assert.Equal(t, 5.0, y)
33 | assert.IsType(t, "", data[2])
34 | name = data[2].(string)
35 | assert.Equal(t, "z", name)
36 | assert.IsType(t, plotter.XYs{}, data[3])
37 | xys = data[3].(plotter.XYs)
38 | assert.Equal(t, 3, xys.Len())
39 | x, y = xys.XY(0)
40 | assert.Equal(t, 1.0, x)
41 | assert.Equal(t, 2.0, y)
42 | x, y = xys.XY(1)
43 | assert.Equal(t, 2.0, x)
44 | assert.Equal(t, 4.0, y)
45 | x, y = xys.XY(2)
46 | assert.Equal(t, 3.0, x)
47 | assert.Equal(t, 6.0, y)
48 | }
49 |
--------------------------------------------------------------------------------
/clients/bolt.go:
--------------------------------------------------------------------------------
1 | package clients
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/boltdb/bolt"
6 | mtx "github.com/gonum/matrix/mat64"
7 | "github.com/kevinschoon/fit/types"
8 | "time"
9 | )
10 |
11 | // BoltClient implements the types.Client
12 | // interface with a BoltDB backend.
13 | // BoltClient is the primary database
14 | // backend for Fit. It can also be used
15 | // directly from the command line.
16 | type BoltClient struct {
17 | bolt *bolt.DB
18 | }
19 |
20 | var (
21 | dsBucket = []byte("datasets")
22 | mxBucket = []byte("matricies")
23 | )
24 |
25 | func (c *BoltClient) Datasets() (datasets []*types.Dataset, err error) {
26 | err = c.bolt.View(func(tx *bolt.Tx) error {
27 | b := tx.Bucket(dsBucket)
28 | cursor := b.Cursor()
29 | for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
30 | ds := &types.Dataset{}
31 | if err := json.Unmarshal(v, ds); err != nil {
32 | return err
33 | }
34 | datasets = append(datasets, ds)
35 | }
36 | return nil
37 | })
38 | return datasets, err
39 | }
40 |
41 | func (c *BoltClient) Write(ds *types.Dataset) (err error) {
42 | return c.bolt.Update(func(tx *bolt.Tx) error {
43 | b := tx.Bucket(dsBucket)
44 | raw, err := json.Marshal(ds)
45 | if err != nil {
46 | return err
47 | }
48 | if err = b.Put([]byte(ds.Name), raw); err != nil {
49 | return err
50 | }
51 | if ds.Mtx == nil { // No matricies attached to this dataset
52 | return nil
53 | }
54 | b = tx.Bucket(mxBucket)
55 | raw, err = ds.Mtx.MarshalBinary()
56 | if err != nil {
57 | return err
58 | }
59 | return b.Put([]byte(ds.Name), raw)
60 | })
61 | }
62 |
63 | func (c *BoltClient) read(name string) (ds *types.Dataset, err error) {
64 | if err = c.bolt.View(func(tx *bolt.Tx) error {
65 | b := tx.Bucket(dsBucket)
66 | raw := b.Get([]byte(name))
67 | if raw == nil {
68 | return types.ErrNotFound
69 | }
70 | ds = &types.Dataset{}
71 | if err = json.Unmarshal(raw, ds); err != nil {
72 | return err
73 | }
74 | b = tx.Bucket(mxBucket)
75 | raw = b.Get([]byte(name))
76 | if raw == nil {
77 | return nil // No matricies attached to the dataset
78 | }
79 | ds.Mtx = mtx.NewDense(0, 0, nil)
80 | return ds.Mtx.UnmarshalBinary(raw)
81 | }); err != nil {
82 | return nil, err
83 | }
84 | return ds, nil
85 | }
86 |
87 | func (c *BoltClient) Delete(name string) error {
88 | return c.bolt.Update(func(tx *bolt.Tx) error {
89 | b := tx.Bucket(dsBucket)
90 | if err := b.Delete([]byte(name)); err != nil {
91 | return err
92 | }
93 | b = tx.Bucket(mxBucket)
94 | return b.Delete([]byte(name))
95 | })
96 | }
97 |
98 | // Query finds all of the datasets contained
99 | // in Queries and returns a combined dataset
100 | // for each column in the search. The values
101 | // from each dataset are stored entirely in
102 | // memory until the query is complete. This
103 | // means that the total size of all datasets
104 | // queried cannot exceed the total system
105 | // memory. The resulting dataset columns
106 | // will be ordered in the same order they
107 | // were queried for.
108 | func (c *BoltClient) Query(query *types.Query) (*types.Dataset, error) {
109 | var (
110 | rows int // Row count for new dataset
111 | cols int // Col count for new dataset
112 | other *types.Dataset // Dataset currently being processed
113 | )
114 | // The new resulting dataset
115 | ds := &types.Dataset{
116 | Name: "QueryResult",
117 | Columns: make([]string, 0),
118 | }
119 | // Empty array of Vectors where each
120 | // is a column from the queries
121 | vectors := make([]*mtx.Vector, 0)
122 | // Map of datasets already processed
123 | processed := make(map[string]*types.Dataset)
124 | // Range each dataset in the query
125 | for _, dataset := range query.Datasets {
126 | columns := dataset.Columns
127 | //columns := query.Columns(name)
128 | // Check to see if a query for this dataset
129 | // has already been executed
130 | if _, ok := processed[dataset.Name]; !ok {
131 | // Query for the other dataset
132 | other, err := c.read(dataset.Name)
133 | if err != nil {
134 | return nil, err
135 | }
136 | // Resulting matrix should have the sum of
137 | // the number of rows from each unique
138 | // dataset matrix that is queried
139 | r, _ := other.Mtx.Dims()
140 | rows += r
141 | // Add this dataset to the map
142 | // so it is not queried again
143 | processed[dataset.Name] = other
144 | }
145 | // The other dataset we are querying
146 | other = processed[dataset.Name]
147 | // If this is a wild card search
148 | // set columns to equal all available
149 | // columns in the dataset
150 | if len(columns) == 1 {
151 | if columns[0] == "*" {
152 | columns = other.Columns
153 | }
154 | }
155 | // Range each column in the query
156 | for _, name := range columns {
157 | // Get the position (index) of the column
158 | pos := other.CPos(name)
159 | // If the returned position is a negative
160 | // number the column does not exist
161 | if pos < 0 {
162 | return nil, types.ErrNotFound
163 | }
164 | // Append the column to vectors array
165 | vectors = append(vectors, other.Mtx.ColView(pos))
166 | // Add the column name to the resulting dataset
167 | ds.Columns = append(ds.Columns, name)
168 | }
169 | }
170 | // Resulting number of columns is equal to
171 | // the amount that were queried for
172 | cols = len(vectors)
173 | // Create a new matrix zeroed Matrix
174 | ds.Mtx = mtx.NewDense(rows, cols, nil)
175 | // Fill the matrix with values from each column vector
176 | for i := 0; i < rows; i++ {
177 | for j := 0; j < cols; j++ {
178 | if vectors[j].Len() > i {
179 | ds.Mtx.Set(i, j, vectors[j].At(i, 0))
180 | } // Zeros are left for missing data
181 | }
182 | }
183 | // Apply any other query options to the resulting dataset
184 | ds.Mtx = query.Apply(ds.Mtx)
185 | return ds, nil
186 | }
187 |
188 | func (c *BoltClient) Close() {
189 | c.bolt.Close()
190 | }
191 |
192 | func NewBoltClient(path string) (types.Client, error) {
193 | b, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second})
194 | if err != nil {
195 | return nil, err
196 | }
197 | c := &BoltClient{
198 | bolt: b,
199 | }
200 | // Initialize buckets
201 | err = c.bolt.Update(func(tx *bolt.Tx) error {
202 | if _, err = tx.CreateBucketIfNotExists(dsBucket); err != nil {
203 | return err
204 | }
205 | if _, err = tx.CreateBucketIfNotExists(mxBucket); err != nil {
206 | return err
207 | }
208 | return nil
209 | })
210 | return types.Client(c), err
211 | }
212 |
--------------------------------------------------------------------------------
/clients/bolt_test.go:
--------------------------------------------------------------------------------
1 | package clients
2 |
3 | import (
4 | mtx "github.com/gonum/matrix/mat64"
5 | "github.com/kevinschoon/fit/types"
6 | "github.com/stretchr/testify/assert"
7 | "io/ioutil"
8 | "math/rand"
9 | "os"
10 | "testing"
11 | "time"
12 | )
13 |
14 | var cleanup bool = false
15 |
16 | func NewTestMatrix(r, c int) *mtx.Dense {
17 | values := make([]float64, r*c)
18 | for i := range values {
19 | values[i] = rand.NormFloat64()
20 | }
21 | return mtx.NewDense(r, c, values)
22 | }
23 |
24 | func NewTestDB(t *testing.T) (types.Client, func()) {
25 | f, err := ioutil.TempFile("/tmp", "fit-test")
26 | if err != nil {
27 | t.Error(err)
28 | }
29 | db, err := NewBoltClient(f.Name())
30 | assert.NoError(t, err)
31 | return db, func() {
32 | if cleanup {
33 | os.Remove(f.Name())
34 | }
35 | }
36 | }
37 |
38 | func TestDatasets(t *testing.T) {
39 | db, cleanup := NewTestDB(t)
40 | defer cleanup()
41 | datasets, err := db.Datasets()
42 | assert.NoError(t, err)
43 | assert.Equal(t, 0, len(datasets))
44 | }
45 |
46 | func TestReadWrite(t *testing.T) {
47 | d, cleanup := NewTestDB(t)
48 | defer cleanup()
49 | db := d.(*BoltClient)
50 | dsA := &types.Dataset{
51 | Name: "TestReadWrite",
52 | Columns: []string{
53 | "V1", "V2", "V3", "V4",
54 | "V5", "V6", "V7", "V8",
55 | },
56 | Mtx: NewTestMatrix(128, 8),
57 | }
58 | assert.NoError(t, db.Write(dsA))
59 | dsB, err := db.read(dsA.Name)
60 | assert.NoError(t, err)
61 | assert.True(t, mtx.Equal(dsA.Mtx, dsB.Mtx))
62 | assert.Equal(t, 8, len(dsB.Columns))
63 | assert.Equal(t, "TestReadWrite", dsB.Name)
64 | }
65 |
66 | func TestQuery(t *testing.T) {
67 | db, cleanup := NewTestDB(t)
68 | defer cleanup()
69 | mx1 := mtx.NewDense(2, 4, []float64{
70 | 1.0, 1.0, 1.0, 1.0,
71 | 2.0, 2.0, 2.0, 2.0,
72 | })
73 | mx2 := mtx.NewDense(3, 4, []float64{
74 | 3.0, 3.0, 3.0, 3.0,
75 | 2.0, 2.0, 2.0, 2.0,
76 | 1.0, 1.0, 1.0, 1.0,
77 | })
78 | assert.NoError(t, db.Write(&types.Dataset{
79 | Name: "mx1",
80 | Mtx: mx1,
81 | Columns: []string{"A", "B", "C", "D"}}),
82 | )
83 | assert.NoError(t, db.Write(&types.Dataset{
84 | Name: "mx2",
85 | Mtx: mx2,
86 | Columns: []string{"E", "F", "G", "H"}}),
87 | )
88 | // Ensure multiple queries for the same dataset do not
89 | // return multiple rows
90 | ds, err := db.Query(types.NewQuery([]string{"mx1,A,B,B", "mx1,B,C,D"}, "", ""))
91 | assert.NoError(t, err)
92 | mx := ds.Mtx
93 | r, c := mx.Dims()
94 | assert.Equal(t, 2, r)
95 | assert.Equal(t, 6, c)
96 | ds, err = db.Query(types.NewQuery([]string{"mx1,A,B,C", "mx2,E,F,G"}, "", ""))
97 | assert.NoError(t, err)
98 | mx = ds.Mtx
99 | r, c = mx.Dims()
100 | assert.Equal(t, 5, r)
101 | assert.Equal(t, 6, c)
102 | assert.Equal(t, 6, len(ds.Columns))
103 | assert.Equal(t, 1.0, mx.At(0, ds.CPos("A")))
104 | assert.Equal(t, 2.0, mx.At(1, ds.CPos("A")))
105 | assert.Equal(t, 1.0, mx.At(0, ds.CPos("B")))
106 | assert.Equal(t, 2.0, mx.At(1, ds.CPos("B")))
107 | assert.Equal(t, 1.0, mx.At(0, ds.CPos("C")))
108 | assert.Equal(t, 2.0, mx.At(1, ds.CPos("C")))
109 | assert.Equal(t, 3.0, mx.At(0, ds.CPos("E")))
110 | assert.Equal(t, 2.0, mx.At(1, ds.CPos("E")))
111 | assert.Equal(t, 1.0, mx.At(2, ds.CPos("E")))
112 | assert.Equal(t, 3.0, mx.At(0, ds.CPos("F")))
113 | assert.Equal(t, 2.0, mx.At(1, ds.CPos("F")))
114 | assert.Equal(t, 1.0, mx.At(2, ds.CPos("F")))
115 | assert.Equal(t, 3.0, mx.At(0, ds.CPos("G")))
116 | assert.Equal(t, 2.0, mx.At(1, ds.CPos("G")))
117 | assert.Equal(t, 1.0, mx.At(2, ds.CPos("G")))
118 | _, err = db.Query(types.NewQuery([]string{"mx3"}, "", ""))
119 | assert.Error(t, err, "not found")
120 | _, err = db.Query(types.NewQuery([]string{"mx1,H"}, "", ""))
121 | assert.Error(t, err, "not found")
122 | // Wildcard query
123 | ds, err = db.Query(types.NewQuery([]string{"mx1,*"}, "", ""))
124 | assert.NoError(t, err)
125 | r, c = ds.Mtx.Dims()
126 | assert.Equal(t, 2, r)
127 | assert.Equal(t, 4, c)
128 | }
129 |
130 | func init() {
131 | rand.Seed(time.Now().Unix())
132 | }
133 |
--------------------------------------------------------------------------------
/clients/http.go:
--------------------------------------------------------------------------------
1 | package clients
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "github.com/kevinschoon/fit/types"
8 | "io"
9 | "io/ioutil"
10 | "log"
11 | "net/http"
12 | "net/url"
13 | "time"
14 | )
15 |
16 | const timeout time.Duration = 10 * time.Second
17 |
18 | // HTTPClient implements the types.Client
19 | // interface over HTTP
20 | type HTTPClient struct {
21 | baseURL *url.URL
22 | client *http.Client
23 | }
24 |
25 | func (c *HTTPClient) url() *url.URL {
26 | return &url.URL{
27 | Scheme: c.baseURL.Scheme,
28 | Host: c.baseURL.Host,
29 | Path: "/1/dataset",
30 | }
31 | }
32 |
33 | func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
34 | res, err := c.client.Do(req)
35 | if err != nil {
36 | return nil, err
37 | }
38 | data, err := ioutil.ReadAll(res.Body)
39 | defer res.Body.Close()
40 | if err != nil {
41 | return nil, err
42 | }
43 | if res.StatusCode != http.StatusOK {
44 | log.Printf("API Error: %d - %s\n", res.StatusCode, data)
45 | switch res.StatusCode {
46 | case 404:
47 | return nil, types.ErrNotFound
48 | default:
49 | return nil, types.ErrAPI
50 | }
51 | }
52 | return data, nil
53 | }
54 |
55 | func (c *HTTPClient) Datasets() (datasets []*types.Dataset, err error) {
56 | raw, err := c.do(&http.Request{
57 | URL: c.url(),
58 | Method: "GET",
59 | })
60 | if err != nil {
61 | return nil, err
62 | }
63 | err = json.Unmarshal(raw, &datasets)
64 | return datasets, err
65 | }
66 |
67 | func (c *HTTPClient) Write(ds *types.Dataset) (err error) {
68 | ds.WithValues = true
69 | raw, err := json.Marshal(ds)
70 | if err != nil {
71 | return err
72 | }
73 | req, err := http.NewRequest("POST", c.url().String(), io.Reader(bytes.NewReader(raw)))
74 | if err != nil {
75 | return err
76 | }
77 | _, err = c.do(req)
78 | return err
79 | }
80 |
81 | func (c *HTTPClient) Delete(name string) (err error) {
82 | u := c.url()
83 | u.RawQuery = fmt.Sprintf("name=%s", name)
84 | _, err = c.do(&http.Request{
85 | URL: u,
86 | Method: "DELETE",
87 | })
88 | return err
89 | }
90 |
91 | func (c *HTTPClient) Query(query *types.Query) (ds *types.Dataset, err error) {
92 | u := c.url()
93 | u.RawQuery = query.String()
94 | raw, err := c.do(&http.Request{
95 | URL: u,
96 | Method: "GET",
97 | })
98 | if err != nil {
99 | return nil, err
100 | }
101 | ds = &types.Dataset{
102 | WithValues: true,
103 | }
104 | err = json.Unmarshal(raw, ds)
105 | return ds, err
106 | }
107 |
108 | func NewHTTPClient(endpoint string) (types.Client, error) {
109 | u, err := url.Parse(endpoint)
110 | if err != nil {
111 | return nil, err
112 | }
113 | c := &HTTPClient{
114 | baseURL: u,
115 | client: &http.Client{
116 | Timeout: timeout,
117 | },
118 | }
119 | return types.Client(c), nil
120 | }
121 |
--------------------------------------------------------------------------------
/cmd/cmd.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | mtx "github.com/gonum/matrix/mat64"
7 | "github.com/gosuri/uitable"
8 | "github.com/jawher/mow.cli"
9 |
10 | "github.com/kevinschoon/fit/clients"
11 | "github.com/kevinschoon/fit/loader"
12 | "github.com/kevinschoon/fit/parser"
13 | "github.com/kevinschoon/fit/server"
14 | "github.com/kevinschoon/fit/types"
15 | "os"
16 | )
17 |
18 | const FitVersion string = "0.0.1"
19 |
20 | var (
21 | app = cli.App("fit", "Fit is a toolkit for exploring, extracting, and transforming datasets")
22 |
23 | dbPath = app.StringOpt("d db", "", "Path to a BoltDB database, default: /tmp/fit.db")
24 | apiURL = app.StringOpt("s server", "", "Fit API server, default: http://127.0.0.1:8000")
25 | asHuman = app.BoolOpt("h human", true, "output data as human readable text")
26 | asJSON = app.BoolOpt("j json", false, "output data in JSON format")
27 | )
28 |
29 | func FailOnErr(err error) {
30 | if err != nil {
31 | fmt.Println("ERROR:", err.Error())
32 | os.Exit(1)
33 | }
34 | }
35 |
36 | func GetClient(pref string) types.Client {
37 | switch {
38 | case *dbPath != "":
39 | client, err := clients.NewBoltClient(*dbPath)
40 | FailOnErr(err)
41 | return client
42 | case *apiURL != "":
43 | client, err := clients.NewHTTPClient(*apiURL)
44 | FailOnErr(err)
45 | return client
46 | }
47 | if pref == "db" {
48 | client, err := clients.NewBoltClient("/tmp/fit.db")
49 | FailOnErr(err)
50 | return client
51 | }
52 | client, err := clients.NewHTTPClient("http://127.0.0.1:8000")
53 | FailOnErr(err)
54 | return client
55 | }
56 |
57 | func Run() {
58 | app.Version("v version", FitVersion)
59 |
60 | app.Command("server", "Run the Fit web server", func(cmd *cli.Cmd) {
61 | var (
62 | pattern = cmd.StringOpt("pattern", "127.0.0.1:8000", "IP and port pattern to listen on")
63 | static = cmd.StringOpt("static", "./www", "Path to static assets")
64 | demo = cmd.BoolOpt("demo", false, "Run in Demo Mode")
65 | )
66 | cmd.Action = func() {
67 | server.RunServer(GetClient("db"), *pattern, *static, FitVersion, *demo)
68 | }
69 | })
70 |
71 | app.Command("load", "load a dataset into BoltDB", func(cmd *cli.Cmd) {
72 | cmd.Spec = "[[-n] [-s]][-p...][-c...] PATH"
73 | var (
74 | name = cmd.StringOpt("n name", "", "name of this dataset")
75 | path = cmd.StringArg("PATH", "", "File path")
76 | parserArgs = cmd.StringsOpt("p parser", []string{}, "parsers to apply")
77 | sheet = cmd.StringOpt("s sheet", "", "name of the sheet to load with XLS file")
78 | columns = cmd.StringsOpt("c column", []string{}, "column names")
79 | )
80 | cmd.Action = func() {
81 | parsers, err := parser.ParsersFromArgs(*parserArgs)
82 | FailOnErr(err)
83 | opts := loader.Options{
84 | Name: *name,
85 | Path: *path,
86 | Parsers: parsers,
87 | Columns: *columns,
88 | Sheet: *sheet,
89 | }
90 | ds, err := loader.ReadPath(opts)
91 | FailOnErr(err)
92 | FailOnErr(GetClient("").Write(ds))
93 | }
94 | })
95 |
96 | app.Command("ls", "list datasets loaded into the database with their columns", func(cmd *cli.Cmd) {
97 | cmd.Action = func() {
98 | datasets, err := GetClient("").Datasets()
99 | FailOnErr(err)
100 | switch {
101 | case *asJSON:
102 | raw, err := json.Marshal(datasets)
103 | FailOnErr(err)
104 | fmt.Println(string(raw))
105 | default:
106 | tbl := uitable.New()
107 | tbl.AddRow("NAME", "ROWS", "COLS", "COLUMNS")
108 | for _, dataset := range datasets {
109 | tbl.AddRow(dataset.Name, fmt.Sprintf("%d", dataset.Stats.Rows), fmt.Sprintf("%d", dataset.Stats.Columns), dataset.Columns)
110 | }
111 | fmt.Println(tbl)
112 | }
113 | }
114 | })
115 |
116 | app.Command("rm", "Delete a dataset", func(cmd *cli.Cmd) {
117 | var name = cmd.StringArg("NAME", "", "Name of the dataset to delete")
118 | cmd.Action = func() {
119 | if *name == "" {
120 | cmd.PrintLongHelp()
121 | os.Exit(1)
122 | }
123 | FailOnErr(GetClient("").Delete(*name))
124 | }
125 | })
126 |
127 | app.Command("query", "Query values from one or more datasets", func(cmd *cli.Cmd) {
128 | var (
129 | queryArgs = cmd.StringsArg("QUERY", []string{}, "Query parameters")
130 | lines = cmd.IntOpt("n lines", 10, "number of rows to output")
131 | grouping = cmd.StringOpt("g grouping", "", "grouping to apply to the resulting matrix")
132 | function = cmd.StringOpt("f function", "avg", "function to apply when grouping")
133 | )
134 | cmd.LongDesc = `Query values from one or more stored datasets. Values from different
135 | datasets can be joined together by specifying multiple query parameters.
136 |
137 | Example:
138 |
139 | fit query -n 10 -g Duration,0,1m -f avg "Dataset1,fuu" "Dataset2,bar,baz"
140 | `
141 | cmd.Spec = "[OPTIONS] QUERY..."
142 | cmd.Action = func() {
143 | if len(*queryArgs) == 0 {
144 | cmd.PrintLongHelp()
145 | os.Exit(1)
146 | }
147 | ds, err := GetClient("").Query(types.NewQuery(*queryArgs, *function, *grouping))
148 | FailOnErr(err)
149 | if ds.Len() > 0 {
150 | switch {
151 | case *asJSON:
152 | ds.WithValues = true
153 | raw, err := json.Marshal(ds)
154 | FailOnErr(err)
155 | fmt.Println(string(raw))
156 | default:
157 | fmt.Printf("\n%s\n", ds.Columns)
158 | if *lines > 0 {
159 | fmt.Printf("\n%v\n\n", mtx.Formatted(ds.Mtx, mtx.Prefix(" "), mtx.Excerpt(*lines)))
160 | } else {
161 | fmt.Printf("\n%v\n\n", mtx.Formatted(ds.Mtx, mtx.Prefix(" ")))
162 | }
163 | }
164 | }
165 | }
166 | })
167 | FailOnErr(app.Run(os.Args))
168 | }
169 |
--------------------------------------------------------------------------------
/loader/csv.go:
--------------------------------------------------------------------------------
1 | package loader
2 |
3 | import (
4 | "encoding/csv"
5 | "io"
6 | )
7 |
8 | type CSV struct {
9 | Columns []string
10 | reader *csv.Reader
11 | rows [][]string
12 | index int
13 | }
14 |
15 | func (c *CSV) Row() ([]string, error) {
16 | if c.index == len(c.rows) {
17 | return nil, io.EOF
18 | }
19 | row := c.rows[c.index]
20 | c.index++
21 | return row, nil
22 | }
23 |
24 | func (c CSV) Dims() (int, int) {
25 | return len(c.rows), len(c.Columns)
26 | }
27 |
28 | func NewCSV(reader io.Reader) (*CSV, error) {
29 | c := &CSV{
30 | reader: csv.NewReader(reader),
31 | }
32 | // Read the first record in the CSV to load column names
33 | row, err := c.reader.Read()
34 | if err != nil {
35 | return c, err
36 | }
37 | c.Columns = make([]string, len(row))
38 | for i, name := range row {
39 | c.Columns[i] = name
40 | }
41 | // Load the entire CSV into memory so
42 | // we can get it's demensions
43 | for {
44 | row, err := c.reader.Read()
45 | if err == io.EOF {
46 | break
47 | }
48 | if err != nil {
49 | return nil, err
50 | }
51 | c.rows = append(c.rows, row)
52 | }
53 | return c, nil
54 | }
55 |
--------------------------------------------------------------------------------
/loader/csv_test.go:
--------------------------------------------------------------------------------
1 | package loader
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "io"
6 | "strings"
7 | "testing"
8 | )
9 |
10 | var Simple string = `
11 | "","time","LakeHuron"
12 | "1",1875,580.38
13 | "2",1876,581.86
14 | "3",1877,580.97
15 | "4",1878,580.8
16 | "5",1879,579.79
17 | `
18 |
19 | func TestCSVLoader(t *testing.T) {
20 | c, err := NewCSV(strings.NewReader(Simple))
21 | assert.NoError(t, err)
22 | assert.Len(t, c.Columns, 3)
23 | rows, cols := c.Dims()
24 | assert.Equal(t, 5, rows)
25 | assert.Equal(t, 3, cols)
26 | values, err := c.Row()
27 | assert.NoError(t, err)
28 | assert.Len(t, values, 3)
29 | assert.Equal(t, "1", values[0])
30 | assert.Equal(t, "1875", values[1])
31 | assert.Equal(t, "580.38", values[2])
32 | values, err = c.Row()
33 | assert.NoError(t, err)
34 | assert.Len(t, values, 3)
35 | assert.Equal(t, "2", values[0])
36 | assert.Equal(t, "1876", values[1])
37 | assert.Equal(t, "581.86", values[2])
38 | values, err = c.Row()
39 | assert.NoError(t, err)
40 | assert.Equal(t, "3", values[0])
41 | assert.Equal(t, "1877", values[1])
42 | assert.Equal(t, "580.97", values[2])
43 | values, err = c.Row()
44 | assert.NoError(t, err)
45 | assert.Equal(t, "4", values[0])
46 | assert.Equal(t, "1878", values[1])
47 | assert.Equal(t, "580.8", values[2])
48 | values, err = c.Row()
49 | assert.NoError(t, err)
50 | assert.Equal(t, "5", values[0])
51 | assert.Equal(t, "1879", values[1])
52 | assert.Equal(t, "579.79", values[2])
53 | _, err = c.Row()
54 | assert.Error(t, err)
55 | assert.Equal(t, err, io.EOF)
56 | }
57 |
--------------------------------------------------------------------------------
/loader/loader.go:
--------------------------------------------------------------------------------
1 | package loader
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | mtx "github.com/gonum/matrix/mat64"
7 | "github.com/kevinschoon/fit/parser"
8 | "github.com/kevinschoon/fit/types"
9 | "io"
10 | "math"
11 | "os"
12 | "strconv"
13 | "strings"
14 | )
15 |
16 | var ErrUnequalValues = errors.New("unequal value size")
17 |
18 | type Rower interface {
19 | Row() ([]string, error)
20 | Dims() (int, int)
21 | }
22 |
23 | type Options struct {
24 | Name string
25 | Path string
26 | Enc string
27 | Columns []string
28 | Sheet string // Sheet name (XLS)
29 | Size int64 // File Size (XLS)
30 | Parsers map[int]parser.Parser
31 | }
32 |
33 | // Rower returns a Rower based on the configured options
34 | func (opts *Options) Rower(fp *os.File) (Rower, error) {
35 | var split []string
36 | if opts.Name == "" {
37 | split = strings.Split(opts.Path, "/")
38 | opts.Name = split[len(split)-1]
39 | if strings.Contains(opts.Name, ".") {
40 | opts.Name = strings.Split(opts.Name, ".")[0]
41 | }
42 | }
43 | if opts.Enc == "" {
44 | split = strings.Split(opts.Path, "/")
45 | split = strings.Split(split[len(split)-1], ".")
46 | opts.Enc = split[len(split)-1]
47 | }
48 | switch {
49 | case opts.Enc == "csv":
50 | csv, err := NewCSV(fp)
51 | if err != nil {
52 | return nil, err
53 | }
54 | if len(opts.Columns) == 0 {
55 | opts.Columns = csv.Columns
56 | }
57 | return csv, nil
58 | case opts.Enc == "xls" || opts.Enc == "xlsx":
59 | xls, err := NewXLS(fp, *opts)
60 | if err != nil {
61 | return nil, err
62 | }
63 | if len(opts.Columns) == 0 {
64 | return nil, fmt.Errorf("Specify at least one column")
65 | }
66 | return xls, nil
67 | }
68 | panic(fmt.Sprintf("unknown encoding: %d", opts.Enc))
69 | }
70 |
71 | func Matrix(rower Rower, parsers map[int]parser.Parser) (*mtx.Dense, error) {
72 | r, c := rower.Dims()
73 | mx := mtx.NewDense(r, c, nil)
74 | for j := 0; j < r; j++ {
75 | strs, err := rower.Row()
76 | if err == io.EOF {
77 | break
78 | }
79 | if err != nil {
80 | return nil, err
81 | }
82 | row := make([]float64, c)
83 | for i, str := range strs {
84 | if parser, ok := parsers[i]; ok {
85 | if value, err := parser.Parse(str); err == nil {
86 | row[i] = value
87 | continue
88 | }
89 | }
90 | if value, err := strconv.ParseFloat(str, 64); err == nil {
91 | row[i] = value
92 | continue
93 | }
94 | row[i] = math.NaN()
95 | }
96 | mx.SetRow(j, row)
97 | }
98 | return mx, nil
99 | }
100 |
101 | func ReadPath(opts Options) (*types.Dataset, error) {
102 | var (
103 | rower Rower
104 | mx *mtx.Dense
105 | )
106 | fp, err := os.Open(opts.Path)
107 | if err != nil {
108 | return nil, err
109 | }
110 | stats, err := fp.Stat()
111 | if err != nil {
112 | return nil, err
113 | }
114 | defer fp.Close()
115 | opts.Size = stats.Size()
116 | rower, err = opts.Rower(fp)
117 | if err != nil {
118 | return nil, err
119 | }
120 | mx, err = Matrix(rower, opts.Parsers)
121 | if err != nil {
122 | return nil, err
123 | }
124 | return &types.Dataset{
125 | Name: opts.Name,
126 | Columns: opts.Columns,
127 | Mtx: mx,
128 | }, nil
129 | }
130 |
--------------------------------------------------------------------------------
/loader/xls.go:
--------------------------------------------------------------------------------
1 | package loader
2 |
3 | import (
4 | "errors"
5 | "github.com/tealeg/xlsx"
6 | "io"
7 | )
8 |
9 | var InvalidXLS = errors.New("Invalid XLS")
10 |
11 | type XLS struct {
12 | file *xlsx.File
13 | sheet *xlsx.Sheet
14 | index int
15 | columns []string
16 | }
17 |
18 | func (x *XLS) Row() ([]string, error) {
19 | if x.index > x.sheet.MaxRow {
20 | return nil, io.EOF
21 | }
22 | values := make([]string, len(x.columns))
23 | for i := 0; i < len(values); i++ {
24 | if str, err := x.sheet.Cell(x.index, i).String(); err == nil {
25 | values[i] = str
26 | }
27 | }
28 | x.index++
29 | return values, nil
30 | }
31 |
32 | func (x XLS) Dims() (int, int) {
33 | return x.sheet.MaxRow, len(x.columns)
34 | }
35 |
36 | func NewXLS(reader io.ReaderAt, opts Options) (*XLS, error) {
37 | f, err := xlsx.OpenReaderAt(reader, opts.Size)
38 | if err != nil {
39 | return nil, err
40 | }
41 | xls := &XLS{
42 | columns: opts.Columns,
43 | }
44 | if opts.Sheet != "" {
45 | if sheet, ok := f.Sheet[opts.Sheet]; ok {
46 | xls.sheet = sheet
47 | } else {
48 | return nil, InvalidXLS
49 | }
50 | } else {
51 | if len(f.Sheets) == 0 {
52 | return nil, InvalidXLS
53 | }
54 | xls.sheet = f.Sheets[0]
55 | }
56 | return xls, nil
57 | }
58 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/kevinschoon/fit/cmd"
5 | )
6 |
7 | func main() {
8 | cmd.Run()
9 | }
10 |
--------------------------------------------------------------------------------
/parser/parser.go:
--------------------------------------------------------------------------------
1 | package parser
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "strings"
7 | "time"
8 | )
9 |
10 | type Parser interface {
11 | Parse(string) (float64, error)
12 | }
13 |
14 | // Parsers loads Parser types from the array of strings
15 | // Example:
16 | // 1,Time,2006-01-02
17 | // ^-^----^----index(int),name(string),format(string)
18 | func ParsersFromArgs(args []string) (map[int]Parser, error) {
19 | parsers := make(map[int]Parser)
20 | for _, arg := range args {
21 | split := strings.Split(arg, ",")
22 | if len(split) < 3 {
23 | return nil, fmt.Errorf("Bad parser opts: %s", arg)
24 | }
25 | index, err := strconv.ParseInt(split[0], 0, 64)
26 | if err != nil {
27 | return nil, fmt.Errorf("Bad parser opts: %s", arg)
28 | }
29 | switch split[1] {
30 | case "Time":
31 | if len(split) != 3 {
32 | return nil, fmt.Errorf("Bad parser opts: %s", arg)
33 | }
34 | parsers[int(index)] = TimeParser{Format: split[2]}
35 | default:
36 | return nil, fmt.Errorf("Unknown parser: %s", split[1])
37 | }
38 | }
39 | return parsers, nil
40 | }
41 |
42 | type TimeParser struct {
43 | Format string
44 | }
45 |
46 | func (t TimeParser) Parse(v string) (float64, error) {
47 | parsed, err := time.Parse(t.Format, v)
48 | if err != nil {
49 | return 0.0, err
50 | }
51 | return float64(parsed.Unix()), nil
52 | }
53 |
--------------------------------------------------------------------------------
/sample_data/LakeHuron.csv:
--------------------------------------------------------------------------------
1 | "","time","LakeHuron"
2 | "1",1875,580.38
3 | "2",1876,581.86
4 | "3",1877,580.97
5 | "4",1878,580.8
6 | "5",1879,579.79
7 | "6",1880,580.39
8 | "7",1881,580.42
9 | "8",1882,580.82
10 | "9",1883,581.4
11 | "10",1884,581.32
12 | "11",1885,581.44
13 | "12",1886,581.68
14 | "13",1887,581.17
15 | "14",1888,580.53
16 | "15",1889,580.01
17 | "16",1890,579.91
18 | "17",1891,579.14
19 | "18",1892,579.16
20 | "19",1893,579.55
21 | "20",1894,579.67
22 | "21",1895,578.44
23 | "22",1896,578.24
24 | "23",1897,579.1
25 | "24",1898,579.09
26 | "25",1899,579.35
27 | "26",1900,578.82
28 | "27",1901,579.32
29 | "28",1902,579.01
30 | "29",1903,579
31 | "30",1904,579.8
32 | "31",1905,579.83
33 | "32",1906,579.72
34 | "33",1907,579.89
35 | "34",1908,580.01
36 | "35",1909,579.37
37 | "36",1910,578.69
38 | "37",1911,578.19
39 | "38",1912,578.67
40 | "39",1913,579.55
41 | "40",1914,578.92
42 | "41",1915,578.09
43 | "42",1916,579.37
44 | "43",1917,580.13
45 | "44",1918,580.14
46 | "45",1919,579.51
47 | "46",1920,579.24
48 | "47",1921,578.66
49 | "48",1922,578.86
50 | "49",1923,578.05
51 | "50",1924,577.79
52 | "51",1925,576.75
53 | "52",1926,576.75
54 | "53",1927,577.82
55 | "54",1928,578.64
56 | "55",1929,580.58
57 | "56",1930,579.48
58 | "57",1931,577.38
59 | "58",1932,576.9
60 | "59",1933,576.94
61 | "60",1934,576.24
62 | "61",1935,576.84
63 | "62",1936,576.85
64 | "63",1937,576.9
65 | "64",1938,577.79
66 | "65",1939,578.18
67 | "66",1940,577.51
68 | "67",1941,577.23
69 | "68",1942,578.42
70 | "69",1943,579.61
71 | "70",1944,579.05
72 | "71",1945,579.26
73 | "72",1946,579.22
74 | "73",1947,579.38
75 | "74",1948,579.1
76 | "75",1949,577.95
77 | "76",1950,578.12
78 | "77",1951,579.75
79 | "78",1952,580.85
80 | "79",1953,580.41
81 | "80",1954,579.96
82 | "81",1955,579.61
83 | "82",1956,578.76
84 | "83",1957,578.18
85 | "84",1958,577.21
86 | "85",1959,577.13
87 | "86",1960,579.1
88 | "87",1961,578.25
89 | "88",1962,577.91
90 | "89",1963,576.89
91 | "90",1964,575.96
92 | "91",1965,576.8
93 | "92",1966,577.68
94 | "93",1967,578.38
95 | "94",1968,578.52
96 | "95",1969,579.74
97 | "96",1970,579.31
98 | "97",1971,579.89
99 | "98",1972,579.96
100 |
--------------------------------------------------------------------------------
/server/handler.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "github.com/gonum/plot/vg"
7 | "github.com/kevinschoon/fit/chart"
8 | "github.com/kevinschoon/fit/types"
9 | "image/color"
10 | "net/http"
11 | "net/url"
12 | "strconv"
13 | "text/template"
14 | )
15 |
16 | type Response struct {
17 | Title string
18 | Explore bool // Display Data Explorer
19 | Browse bool // Display Datasets Listing
20 | Keys []string // Series Keys to Display
21 | ChartURL string // URL for rendering the chart
22 | Datasets []*types.Dataset
23 | Dataset *types.Dataset
24 | Query url.Values
25 | DemoMode bool
26 | Version string
27 | }
28 |
29 | type Handler struct {
30 | db types.Client
31 | version string
32 | templates []string
33 | defaults Response
34 | }
35 |
36 | func (handler Handler) response() *Response {
37 | return &Response{
38 | DemoMode: handler.defaults.DemoMode,
39 | Version: handler.defaults.Version,
40 | }
41 | }
42 |
43 | func (handler Handler) Chart(w http.ResponseWriter, r *http.Request) error {
44 | ds, err := handler.db.Query(types.NewQueryQS(r.URL))
45 | if err != nil {
46 | return err
47 | }
48 | cfg := chart.Config{
49 | Title: ds.Name,
50 | PrimaryColor: color.White,
51 | SecondaryColor: color.Black,
52 | Width: 18 * vg.Inch,
53 | Height: 5 * vg.Inch,
54 | Type: r.URL.Query().Get("type"),
55 | Columns: types.NewQueryQS(r.URL).Columns(),
56 | }
57 | if w, err := strconv.ParseInt(r.URL.Query().Get("width"), 0, 64); err == nil {
58 | if w < 20 { // Prevent potentially horrible DOS
59 | cfg.Width = vg.Length(w) * vg.Inch
60 | }
61 | }
62 | if h, err := strconv.ParseInt(r.URL.Query().Get("height"), 0, 64); err == nil {
63 | if h < 20 {
64 | cfg.Height = vg.Length(h) * vg.Inch
65 | }
66 | }
67 | canvas, err := chart.New(cfg, ds.Mtx)
68 | if err != nil {
69 | return err
70 | }
71 | _, err = canvas.WriteTo(w)
72 | return err
73 | }
74 |
75 | func (handler Handler) DatasetAPI(w http.ResponseWriter, r *http.Request) error {
76 | switch r.Method {
77 | case "GET":
78 | query := types.NewQueryQS(r.URL)
79 | if query.Len() > 0 { // If URL contains a query return the query result
80 | ds, err := handler.db.Query(query)
81 | if err != nil {
82 | return err
83 | }
84 | ds.WithValues = true // Encode values in the dataset response
85 | if err := json.NewEncoder(w).Encode(ds); err != nil {
86 | return err
87 | }
88 | } else { // Otherwise return an array of all datasets
89 | datasets, err := handler.db.Datasets()
90 | if err != nil {
91 | return err
92 | }
93 | if err = json.NewEncoder(w).Encode(datasets); err != nil {
94 | return err
95 | }
96 | }
97 | case "POST":
98 | ds := &types.Dataset{WithValues: true}
99 | if err := json.NewDecoder(r.Body).Decode(ds); err != nil {
100 | return err
101 | }
102 | if err := handler.db.Write(ds); err != nil {
103 | return err
104 | }
105 | case "DELETE":
106 | if name := r.URL.Query().Get("name"); name != "" {
107 | if err := handler.db.Delete(name); err != nil {
108 | return err
109 | }
110 | } else {
111 | return fmt.Errorf("specify name")
112 | }
113 | }
114 | return nil
115 | }
116 |
117 | func (handler Handler) Home(w http.ResponseWriter, r *http.Request) error {
118 | tmpl, err := template.ParseFiles(handler.templates...)
119 | if err != nil {
120 | return err
121 | }
122 | response := handler.response()
123 | datasets, err := handler.db.Datasets()
124 | if err != nil {
125 | return err
126 | }
127 | response.Datasets = datasets
128 | response.Query = r.URL.Query()
129 | response.Title = "Browse"
130 | response.Browse = true
131 | return tmpl.Execute(w, response)
132 | }
133 |
134 | func (handler Handler) Explore(w http.ResponseWriter, r *http.Request) error {
135 | tmpl, err := template.ParseFiles(handler.templates...)
136 | if err != nil {
137 | return err
138 | }
139 | response := handler.response()
140 | datasets, err := handler.db.Datasets()
141 | if err != nil {
142 | return err
143 | }
144 | response.Datasets = datasets
145 | response.Query = r.URL.Query()
146 | response.Explore = true
147 | chartURL := &url.URL{
148 | Scheme: r.URL.Scheme,
149 | Host: r.URL.Host,
150 | Path: "/chart",
151 | RawQuery: r.URL.Query().Encode(),
152 | }
153 | response.ChartURL = chartURL.String()
154 | ds, err := handler.db.Query(types.NewQueryQS(r.URL))
155 | if err != nil {
156 | return err
157 | }
158 | response.Dataset = ds
159 | return tmpl.Execute(w, response)
160 | }
161 |
--------------------------------------------------------------------------------
/server/handler_test.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "github.com/kevinschoon/fit/clients"
7 | "github.com/kevinschoon/fit/types"
8 | "github.com/stretchr/testify/assert"
9 | "io"
10 | "io/ioutil"
11 | "net/http"
12 | "net/url"
13 | "os"
14 | "testing"
15 | )
16 |
17 | var cleanup bool = false
18 |
19 | var (
20 | datasetRaw []byte = []byte(`{"Name": "TestDataset", "Columns": ["V1", "V2"]}`)
21 | valuesRaw []byte = []byte(`[1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 5.0, 5.0]`)
22 | )
23 |
24 | type MockWriter struct {
25 | fp *os.File
26 | }
27 |
28 | func (writer MockWriter) Header() http.Header { return http.Header{} }
29 |
30 | func (writer MockWriter) Write(data []byte) (int, error) {
31 | return writer.fp.Write(data)
32 | }
33 | func (writer MockWriter) WriteHeader(int) {}
34 |
35 | type MockReader struct {
36 | *bytes.Reader
37 | }
38 |
39 | func (reader MockReader) Close() error { return nil }
40 |
41 | func NewTestDB(t *testing.T) (types.Client, func()) {
42 | f, err := ioutil.TempFile("/tmp", "fit-test-")
43 | assert.NoError(t, err)
44 | db, err := clients.NewBoltClient(f.Name())
45 | assert.NoError(t, err)
46 | return db, func() {
47 | if cleanup {
48 | os.Remove(f.Name())
49 | }
50 | }
51 | }
52 |
53 | func NewMockWriter(t *testing.T) (MockWriter, func()) {
54 | fp, err := ioutil.TempFile("/tmp", "fit-test-writer-")
55 | assert.NoError(t, err)
56 | return MockWriter{fp: fp}, func() {
57 | fp.Close()
58 | if cleanup {
59 | os.Remove(fp.Name())
60 | }
61 | }
62 | }
63 |
64 | func TestDatasetAPI(t *testing.T) {
65 | db, cleanup := NewTestDB(t)
66 | defer cleanup()
67 | handler := Handler{db: db}
68 | reader := MockReader{bytes.NewReader(datasetRaw)}
69 | assert.NoError(t, handler.DatasetAPI(MockWriter{fp: nil}, &http.Request{
70 | Method: "POST",
71 | Body: io.ReadCloser(reader),
72 | }))
73 | writer, cleanup := NewMockWriter(t)
74 | defer cleanup()
75 | assert.NoError(t, handler.DatasetAPI(writer, &http.Request{
76 | URL: &url.URL{},
77 | Method: "GET",
78 | }))
79 | raw, err := ioutil.ReadFile(writer.fp.Name())
80 | assert.NoError(t, err)
81 | ds := []*types.Dataset{}
82 | assert.NoError(t, json.Unmarshal(raw, &ds))
83 | assert.Len(t, ds, 1)
84 | assert.Equal(t, "V1", ds[0].Columns[0])
85 | assert.Equal(t, "V2", ds[0].Columns[1])
86 | assert.Equal(t, 0, ds[0].Stats.Rows)
87 | assert.Equal(t, 0, ds[0].Stats.Columns)
88 | assert.NoError(t, handler.DatasetAPI(MockWriter{fp: nil}, &http.Request{
89 | Method: "DELETE",
90 | URL: &url.URL{
91 | RawQuery: "name=TestDataset",
92 | },
93 | }))
94 | }
95 |
--------------------------------------------------------------------------------
/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "io/ioutil"
6 | "log"
7 | "net/http"
8 | "os"
9 | "text/template"
10 |
11 | "github.com/gorilla/handlers"
12 | "github.com/gorilla/mux"
13 | "github.com/kevinschoon/fit/types"
14 | )
15 |
16 | type ErrorHandler func(http.ResponseWriter, *http.Request) error
17 |
18 | func (fn ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
19 | HandleError(fn(w, r), w, r)
20 | }
21 |
22 | func HandleError(err error, w http.ResponseWriter, r *http.Request) {
23 | if err != nil {
24 | fmt.Println("ERROR: ", err.Error())
25 | switch err.(type) {
26 | case template.ExecError:
27 | default:
28 | switch err {
29 | case types.ErrNotFound:
30 | http.NotFound(w, r)
31 | default:
32 | http.Error(w, err.Error(), http.StatusInternalServerError)
33 | }
34 | }
35 | }
36 | }
37 |
38 | type StaticHandler struct {
39 | Path string // Path to static assets directory
40 | Allowed []string // Array of allowed directories
41 | }
42 |
43 | func (handler StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
44 | for _, directory := range handler.Allowed {
45 | if mux.Vars(r)["directory"] == directory {
46 | if files, err := ioutil.ReadDir(fmt.Sprintf("%s/%s", handler.Path, directory)); err == nil {
47 | for _, file := range files {
48 | if mux.Vars(r)["file"] == file.Name() && file.Mode().IsRegular() {
49 | http.ServeFile(w, r, fmt.Sprintf("%s/%s/%s", handler.Path, directory, file.Name()))
50 | return
51 | }
52 | }
53 | }
54 | }
55 | }
56 | http.NotFound(w, r)
57 | }
58 |
59 | func RunServer(db types.Client, pattern, path, version string, demo bool) {
60 | templates := []string{
61 | fmt.Sprintf("%s/html/base.html", path),
62 | fmt.Sprintf("%s/html/panel.html", path),
63 | fmt.Sprintf("%s/html/explore.html", path),
64 | fmt.Sprintf("%s/html/browse.html", path),
65 | }
66 | router := mux.NewRouter().StrictSlash(true)
67 | handler := Handler{db: db, templates: templates, defaults: Response{DemoMode: demo, Version: version}}
68 | router.Handle("/", ErrorHandler(handler.Home))
69 | router.Handle("/explore", ErrorHandler(handler.Explore))
70 | router.Handle("/chart", ErrorHandler(handler.Chart)).Methods("GET")
71 | if demo {
72 | router.Handle("/1/dataset", ErrorHandler(handler.DatasetAPI)).Methods("GET")
73 | } else {
74 | router.Handle("/1/dataset", ErrorHandler(handler.DatasetAPI)).Methods("GET", "POST", "PUT", "DELETE")
75 | }
76 | router.Handle("/static/{directory}/{file}", StaticHandler{
77 | Path: path,
78 | Allowed: []string{
79 | "images",
80 | "css",
81 | "js",
82 | "fonts",
83 | },
84 | })
85 | log.Printf("Fit server listening @ %s", pattern)
86 | log.Fatal(http.ListenAndServe(pattern, handlers.CombinedLoggingHandler(os.Stdout, router)))
87 | }
88 |
--------------------------------------------------------------------------------
/types/functions.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | mtx "github.com/gonum/matrix/mat64"
5 | "strings"
6 | )
7 |
8 | type Function struct {
9 | Name string
10 | }
11 |
12 | func (fn Function) apply(mx []mtx.Matrix, f func(mtx.Matrix) float64) *mtx.Dense {
13 | var (
14 | cols int
15 | result *mtx.Dense
16 | )
17 | for i, view := range mx {
18 | if result == nil {
19 | _, cols = view.Dims()
20 | result = mtx.NewDense(len(mx), cols, nil)
21 | }
22 | // TODO: Determine if there is a better way to accomplish
23 | // this without type assertion
24 | other := view.(*mtx.Dense)
25 | for j := 0; j < cols; j++ {
26 | result.Set(i, j, f(other.ColView(j)))
27 | }
28 | }
29 | return result
30 | }
31 |
32 | func (fn Function) Apply(mx []mtx.Matrix) *mtx.Dense {
33 | switch strings.ToLower(fn.Name) {
34 | case "min":
35 | return fn.apply(mx, mtx.Min)
36 | case "max":
37 | return fn.apply(mx, mtx.Max)
38 | case "sum":
39 | return fn.apply(mx, mtx.Sum)
40 | default: // Use the average
41 | if len(mx) > 0 {
42 | return fn.apply(mx, func(o mtx.Matrix) float64 {
43 | r, _ := o.Dims()
44 | return mtx.Sum(o) / float64(r)
45 | })
46 | }
47 | }
48 | return mtx.NewDense(0, 0, nil)
49 | }
50 |
--------------------------------------------------------------------------------
/types/functions_test.go:
--------------------------------------------------------------------------------
1 | package types
2 |
--------------------------------------------------------------------------------
/types/grouping.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | mtx "github.com/gonum/matrix/mat64"
7 | "strconv"
8 | "strings"
9 | "time"
10 | )
11 |
12 | type grouping struct {
13 | Name string
14 | Index int
15 | Max string
16 | }
17 |
18 | // Grouping represents a "group by" configuration
19 | type Grouping struct {
20 | Name string
21 | Index int
22 | Max time.Duration
23 | }
24 |
25 | func (grp Grouping) UnmarshalJSON(data []byte) error {
26 | in := &grouping{}
27 | if err := json.Unmarshal(data, in); err != nil {
28 | return err
29 | }
30 | grp.Name = in.Name
31 | grp.Index = in.Index
32 | max, _ := time.ParseDuration(in.Max)
33 | grp.Max = max
34 | return nil
35 | }
36 |
37 | func (grp Grouping) MarshalJSON() ([]byte, error) {
38 | return json.Marshal(&grouping{
39 | Name: grp.Name,
40 | Index: grp.Index,
41 | Max: grp.Max.String(),
42 | })
43 | }
44 |
45 | func (grp Grouping) String() string {
46 | return fmt.Sprintf("%s,%d,%s", grp.Name, grp.Index, grp.Max.String())
47 | }
48 |
49 | func (grp Grouping) Group(other *mtx.Dense) []mtx.Matrix {
50 | r, c := other.Dims()
51 | views := make([]mtx.Matrix, 0)
52 | var (
53 | view mtx.Matrix
54 | previous time.Time
55 | current time.Time
56 | duration time.Duration
57 | )
58 | for i, j := 0, 1; i+j <= r; j++ {
59 | view = other.View(i, 0, j, c)
60 | current = time.Unix(int64(view.At(j-1, grp.Index)), 0).UTC()
61 | duration += current.Sub(previous)
62 | if duration >= grp.Max {
63 | views = append(views, view)
64 | i, j = i+j, 0
65 | duration = time.Duration(0)
66 | }
67 | previous = current
68 | }
69 | return views
70 | }
71 |
72 | // NewGrouping returns a grouping based on a string
73 | // parameter. See documentation for Duration str format
74 | //
75 | // Duration,0,1min
76 | // ^--------^-^----Name,Index,DurationStr
77 | func NewGrouping(arg string) *Grouping {
78 | split := strings.Split(arg, ",")
79 | var grouping *Grouping
80 | if len(split) > 0 {
81 | grouping = &Grouping{
82 | Name: split[0],
83 | }
84 | }
85 | if len(split) >= 2 {
86 | index, _ := strconv.ParseInt(split[1], 0, 64)
87 | grouping.Index = int(index)
88 | }
89 | if len(split) >= 3 {
90 | duration, _ := time.ParseDuration(split[2])
91 | grouping.Max = duration
92 | }
93 | return grouping
94 | }
95 |
--------------------------------------------------------------------------------
/types/grouping_test.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "fmt"
5 | mtx "github.com/gonum/matrix/mat64"
6 | "github.com/stretchr/testify/assert"
7 | "math"
8 | "math/rand"
9 | "testing"
10 | "time"
11 | )
12 |
13 | func round(num float64) int {
14 | return int(num + math.Copysign(0.5, num))
15 | }
16 |
17 | func toFixed(num float64, precision int) float64 {
18 | output := math.Pow(10, float64(precision))
19 | return float64(round(num*output)) / output
20 | }
21 |
22 | func TestGrouping(t *testing.T) {
23 | grouping := NewGrouping("Duration,0,2s")
24 | assert.Equal(t, 2*time.Second, grouping.Max)
25 | assert.Equal(t, 0, grouping.Index)
26 | assert.Equal(t, "Duration,0,2s", grouping.String())
27 | mx := mtx.NewDense(10, 10, nil)
28 | r, c := mx.Dims()
29 | start := time.Date(2001, time.January, 0, 0, 0, 1, 1, time.UTC)
30 | for i := 0; i < r; i++ {
31 | mx.Set(i, 0, float64(start.Unix()))
32 | mx.Set(i, 1, 1.0)
33 | start = start.Add(1 * time.Second)
34 | for j := 2; j < c; j++ {
35 | mx.Set(i, j, toFixed(rand.Float64(), 2))
36 | }
37 | }
38 | assert.Equal(t, mtx.Sum(mx.ColView(1)), 10.0)
39 | assert.True(t, mtx.Sum(mx.ColView(2)) < 10)
40 | views := grouping.Group(mx)
41 | assert.Len(t, views, 5)
42 | rows := 0
43 | for _, v := range views {
44 | fmt.Println(mtx.Formatted(v))
45 | r, _ := v.Dims()
46 | rows += r
47 | }
48 | assert.Equal(t, 10, r)
49 | //mx = apply(result, Avg)
50 | //r, c = mx.Dims()
51 | //assert.Equal(t, 5, r)
52 | //assert.Equal(t, 10, c)
53 | //fmt.Println(mtx.Formatted(mx))
54 | }
55 |
56 | func init() {
57 | rand.Seed(time.Now().Unix())
58 | }
59 |
--------------------------------------------------------------------------------
/types/query.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | mtx "github.com/gonum/matrix/mat64"
5 | "net/url"
6 | "strings"
7 | )
8 |
9 | // Query can be used to combine the results
10 | // of multiple datasets into a single
11 | // matrix of values. Queries can originate
12 | // from the command line as arguments,
13 | // a URL query string, or a JSON encoded
14 | // payload.
15 | //
16 | // Command line arguments take the same
17 | // form as URL encoding
18 | //
19 | // Text Specification:
20 | // d=DS1,x,y&d=DS2,z,fuu&grouping=Duration,0,1m&fn=avg
21 | //
22 | type Query struct {
23 | Datasets []struct {
24 | Name string // Name of the dataset
25 | Columns []string // Columns within the dataset to query
26 | }
27 | Function *Function
28 | Grouping *Grouping
29 | }
30 |
31 | // Len returns the length of the Query
32 | func (query Query) Len() int {
33 | return len(query.Datasets)
34 | }
35 |
36 | // Columns returns a flattened ordered
37 | // array of Column names
38 | func (query Query) Columns() []string {
39 | columns := make([]string, 0)
40 | for _, dataset := range query.Datasets {
41 | for _, column := range dataset.Columns {
42 | columns = append(columns, column)
43 | }
44 | }
45 | return columns
46 | }
47 |
48 | // String returns a valid URL query string
49 | func (query Query) String() string {
50 | values := url.Values{}
51 | values.Add("fn", query.Function.Name)
52 | if query.Grouping != nil {
53 | values.Add("grouping", query.Grouping.String())
54 | }
55 | for _, dataset := range query.Datasets {
56 | args := make([]string, len(dataset.Columns)+1)
57 | args[0] = dataset.Name
58 | for i, column := range dataset.Columns {
59 | args[i+1] = column
60 | }
61 | values.Add("q", strings.TrimRight(strings.Join(args, ","), ","))
62 | }
63 | return values.Encode()
64 | }
65 |
66 | // Apply returns a new modified matrix based on the query
67 | func (query Query) Apply(mx *mtx.Dense) *mtx.Dense {
68 | if query.Grouping != nil {
69 | return query.Function.Apply(query.Grouping.Group(mx))
70 | }
71 | return mx
72 | }
73 |
74 | // NewQuery constructs a Query from the provided
75 | // args and optionally specified function.
76 | // If function is specified the query returns
77 | // aggregated
78 | func NewQuery(args []string, function, grouping string) *Query {
79 | query := &Query{
80 | Datasets: make([]struct {
81 | Name string
82 | Columns []string
83 | }, len(args)),
84 | Function: &Function{
85 | Name: function,
86 | },
87 | }
88 | if grouping != "" {
89 | query.Grouping = NewGrouping(grouping)
90 | }
91 | for i, arg := range args {
92 | split := strings.Split(arg, ",")
93 | if len(split) >= 1 {
94 | query.Datasets[i].Name = split[0]
95 | }
96 | if len(split) > 1 {
97 | query.Datasets[i].Columns = split[1:]
98 | }
99 | }
100 | return query
101 | }
102 |
103 | // NewQueryQS constructs a query from a url.URL
104 | func NewQueryQS(u *url.URL) *Query {
105 | var args []string
106 | query := u.Query()
107 | if q, ok := query["q"]; ok {
108 | args = q
109 | }
110 | return NewQuery(args, query.Get("fn"), query.Get("grouping"))
111 | }
112 |
--------------------------------------------------------------------------------
/types/query_test.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/stretchr/testify/assert"
5 | "net/url"
6 | "testing"
7 | "time"
8 | )
9 |
10 | func TestQuery(t *testing.T) {
11 | u, err := url.Parse("http://localhost/?q=D0,x,y,z&q=D1,z&grouping=Duration,0,1m&fn=avg")
12 | assert.NoError(t, err)
13 | query := NewQueryQS(u)
14 | assert.Equal(t, 2, query.Len())
15 | assert.Equal(t, "D0", query.Datasets[0].Name)
16 | assert.Equal(t, 3, len(query.Datasets[0].Columns))
17 | assert.Equal(t, "x", query.Datasets[0].Columns[0])
18 | assert.Equal(t, "y", query.Datasets[0].Columns[1])
19 | assert.Equal(t, "z", query.Datasets[0].Columns[2])
20 | assert.Equal(t, 1, len(query.Datasets[1].Columns))
21 | assert.Equal(t, "z", query.Datasets[1].Columns[0])
22 | assert.Equal(t, "D1", query.Datasets[1].Name)
23 | assert.Equal(t, "avg", query.Function.Name)
24 | assert.Equal(t, 0, query.Grouping.Index)
25 | assert.Equal(t, time.Minute, query.Grouping.Max)
26 | assert.Equal(t, "fn=avg&grouping=Duration%2C0%2C1m0s&q=D0%2Cx%2Cy%2Cz&q=D1%2Cz", query.String())
27 | }
28 |
--------------------------------------------------------------------------------
/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "github.com/gonum/matrix"
7 | mtx "github.com/gonum/matrix/mat64"
8 | "io"
9 | "math"
10 | "sync"
11 | )
12 |
13 | var (
14 | ErrAPI = errors.New("api error")
15 | ErrNoData = errors.New("no data")
16 | ErrNotFound = errors.New("not found")
17 | ErrBadQuery = errors.New("bad query")
18 | )
19 |
20 | // Work around for handling NaN values in JSON
21 | // https://github.com/golang/go/issues/3480
22 | type value float64
23 |
24 | func (v value) MarshalJSON() ([]byte, error) {
25 | if math.IsNaN(float64(v)) {
26 | return json.Marshal(nil)
27 | }
28 | return json.Marshal(float64(v))
29 | }
30 |
31 | func (v *value) UnmarshalJSON(data []byte) error {
32 | if err := json.Unmarshal(data, nil); err != nil {
33 | val := float64(0.0)
34 | if err := json.Unmarshal(data, &val); err == nil {
35 | *v = value(val)
36 | }
37 | } else {
38 | *v = value(math.NaN())
39 | }
40 | return nil
41 | }
42 |
43 | type values []value
44 |
45 | type Client interface {
46 | Datasets() ([]*Dataset, error)
47 | Write(*Dataset) error
48 | Delete(string) error
49 | Query(*Query) (*Dataset, error)
50 | }
51 |
52 | // Stats contain statistics about the
53 | // underlying data in a dataset
54 | type Stats struct {
55 | Rows int
56 | Columns int
57 | }
58 |
59 | type dataset struct {
60 | Name string
61 | Columns []string
62 | Stats *Stats
63 | Mtx []value
64 | }
65 |
66 | // Dataset consists of a name and
67 | // an ordered array of column names
68 | type Dataset struct {
69 | Name string // Name of this dataset
70 | Columns []string // Ordered array of cols
71 | Mtx *mtx.Dense `json:"-"` // Dense Matrix contains all values in the dataset
72 | Stats *Stats
73 | lock sync.RWMutex
74 | index int
75 | WithValues bool
76 | }
77 |
78 | func (ds *Dataset) MarshalJSON() ([]byte, error) {
79 | ds.stats()
80 | out := &dataset{
81 | Name: ds.Name,
82 | Columns: ds.Columns,
83 | Stats: ds.Stats,
84 | }
85 | if ds.WithValues && ds.Mtx != nil {
86 | r, c := ds.Mtx.Dims()
87 | out.Mtx = make([]value, r*c)
88 | for i, val := range ds.Mtx.RawMatrix().Data {
89 | out.Mtx[i] = value(val)
90 | }
91 | }
92 | return json.Marshal(out)
93 | }
94 |
95 | func (ds *Dataset) UnmarshalJSON(data []byte) error {
96 | in := &dataset{}
97 | if err := json.Unmarshal(data, in); err != nil {
98 | return err
99 | }
100 | ds.Name = in.Name
101 | ds.Columns = in.Columns
102 | ds.Stats = in.Stats
103 | return matrix.Maybe(func() {
104 | if ds.WithValues && in.Mtx != nil {
105 | values := make([]float64, ds.Stats.Rows*ds.Stats.Columns)
106 | for i := 0; i < len(in.Mtx); i++ {
107 | values[i] = float64(in.Mtx[i])
108 | }
109 | ds.Mtx = mtx.NewDense(ds.Stats.Rows, ds.Stats.Columns, values)
110 | }
111 | })
112 | }
113 |
114 | // stats updates the Stats struct
115 | func (ds *Dataset) stats() {
116 | if ds.Stats == nil {
117 | ds.Stats = &Stats{}
118 | }
119 | if ds.Mtx != nil {
120 | ds.Stats.Rows, ds.Stats.Columns = ds.Mtx.Dims()
121 | }
122 | }
123 |
124 | // Len returns the length (number of rows) of the dataset
125 | func (ds Dataset) Len() int {
126 | len := 0
127 | if ds.Mtx != nil {
128 | len, _ = ds.Mtx.Dims()
129 | }
130 | return len
131 | }
132 |
133 | // CPos returns the position of a column
134 | // name in a dataset. If the column
135 | // does not exist it returns -1
136 | func (ds Dataset) CPos(name string) int {
137 | for i, col := range ds.Columns {
138 | if name == col {
139 | return i
140 | }
141 | }
142 | return -1
143 | }
144 |
145 | // Next returns the next row of values
146 | // If all values have been traversed
147 | // it returns io.EOF. Implements the
148 | // loader.Reader interface
149 | func (ds *Dataset) Next() ([]float64, error) {
150 | ds.lock.Lock()
151 | defer ds.lock.Unlock()
152 | if ds.Mtx == nil {
153 | return nil, ErrNoData
154 | }
155 | r, _ := ds.Mtx.Dims()
156 | if ds.index >= r {
157 | ds.index = 0
158 | return nil, io.EOF
159 | }
160 | rows := ds.Mtx.RawRowView(ds.index)
161 | ds.index++
162 | return rows, nil
163 | }
164 |
--------------------------------------------------------------------------------
/types/types_test.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "encoding/json"
5 | mtx "github.com/gonum/matrix/mat64"
6 | "github.com/stretchr/testify/assert"
7 | "math"
8 | "testing"
9 | )
10 |
11 | func TestDatasetJSON(t *testing.T) {
12 | ds := &Dataset{
13 | Name: "TestDataset",
14 | Columns: []string{"V1", "V2"},
15 | Mtx: mtx.NewDense(3, 2, []float64{1.0, 1.0, 2.0, 2.0, 3.0, math.NaN()}),
16 | WithValues: true,
17 | }
18 | raw, err := json.Marshal(ds)
19 | assert.NoError(t, err)
20 | out := &Dataset{WithValues: true}
21 | assert.NoError(t, json.Unmarshal(raw, out))
22 | assert.Equal(t, ds.Name, out.Name)
23 | assert.Equal(t, ds.Columns[0], out.Columns[0])
24 | assert.Equal(t, ds.Columns[1], out.Columns[1])
25 | assert.Equal(t, 1.0, ds.Mtx.At(0, 0))
26 | assert.Equal(t, 1.0, ds.Mtx.At(0, 1))
27 |
28 | assert.Equal(t, 2.0, ds.Mtx.At(1, 0))
29 | assert.Equal(t, 2.0, ds.Mtx.At(1, 1))
30 |
31 | assert.Equal(t, 3.0, ds.Mtx.At(2, 0))
32 | assert.True(t, math.IsNaN(ds.Mtx.At(2, 1)))
33 | }
34 |
--------------------------------------------------------------------------------
/www/css/app.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding-top: 50px;
3 | }
4 |
5 | .options-right {
6 | border-left: 1px solid #ccc;
7 | }
8 |
9 | .panel-body.chart {
10 | background-color: #000;
11 | }
12 |
13 | .browse {
14 | margin-top: 25px;
15 | text-align: center;
16 | }
17 |
18 | .navbar{
19 | background-color: #222222;
20 | min-height: 0px !important;
21 | padding-top: 2px;
22 | }
23 |
24 | /* Always float the navbar header */
25 | .navbar-header {
26 | float: left;
27 | }
28 |
29 | /* Undo the collapsing navbar */
30 | .navbar-collapse {
31 | display: block !important;
32 | height: auto !important;
33 | padding-bottom: 0;
34 | overflow: visible !important;
35 | visibility: visible !important;
36 | }
37 |
38 | .navbar-toggle {
39 | display: none;
40 | }
41 | .navbar-collapse {
42 | border-top: 0;
43 | }
44 |
45 | .navbar-brand {
46 | margin-left: -15px;
47 | }
48 |
49 | /* Always apply the floated nav */
50 | .navbar-nav {
51 | float: left;
52 | margin: 0;
53 | }
54 | .navbar-nav > li {
55 | float: left;
56 | }
57 | .navbar-nav > li > a {
58 | padding: 15px;
59 | }
60 |
61 | /* Redeclare since we override the float above */
62 | .navbar-nav.navbar-right {
63 | float: right;
64 | }
65 |
66 | /* Undo custom dropdowns */
67 | .navbar .navbar-nav .open .dropdown-menu {
68 | position: absolute;
69 | float: left;
70 | border: 1px solid #ccc;
71 | border: 1px solid rgba(0, 0, 0, .15);
72 | border-width: 0 1px 1px;
73 | border-radius: 0 0 4px 4px;
74 | -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
75 | box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
76 | }
77 | .navbar-default .navbar-nav .open .dropdown-menu > li > a {
78 | color: #333;
79 | }
80 | .navbar .navbar-nav .open .dropdown-menu > li > a:hover,
81 | .navbar .navbar-nav .open .dropdown-menu > li > a:focus,
82 | .navbar .navbar-nav .open .dropdown-menu > .active > a,
83 | .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,
84 | .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {
85 | color: #fff !important;
86 | background-color: #428bca !important;
87 | }
88 | .navbar .navbar-nav .open .dropdown-menu > .disabled > a,
89 | .navbar .navbar-nav .open .dropdown-menu > .disabled > a:hover,
90 | .navbar .navbar-nav .open .dropdown-menu > .disabled > a:focus {
91 | color: #999 !important;
92 | background-color: transparent !important;
93 | }
94 |
95 | /* Undo form expansion */
96 | .navbar-form {
97 | float: left;
98 | width: auto;
99 | padding-top: 0;
100 | padding-bottom: 0;
101 | margin-right: 0;
102 | margin-left: 0;
103 | border: 0;
104 | -webkit-box-shadow: none;
105 | box-shadow: none;
106 | }
107 |
108 | /* Copy-pasted from forms.less since we mixin the .form-inline styles. */
109 | .navbar-form .form-group {
110 | display: inline-block;
111 | margin-bottom: 0;
112 | vertical-align: middle;
113 | }
114 |
115 | .navbar-form .form-control {
116 | display: inline-block;
117 | width: auto;
118 | vertical-align: middle;
119 | }
120 |
121 | .navbar-form .form-control-static {
122 | display: inline-block;
123 | }
124 |
125 | .navbar-form .input-group {
126 | display: inline-table;
127 | vertical-align: middle;
128 | }
129 |
130 | .navbar-form .input-group .input-group-addon,
131 | .navbar-form .input-group .input-group-btn,
132 | .navbar-form .input-group .form-control {
133 | width: auto;
134 | }
135 |
136 | .navbar-form .input-group > .form-control {
137 | width: 100%;
138 | }
139 |
140 | .navbar-form .control-label {
141 | margin-bottom: 0;
142 | vertical-align: middle;
143 | }
144 |
145 | .navbar-form .radio,
146 | .navbar-form .checkbox {
147 | display: inline-block;
148 | margin-top: 0;
149 | margin-bottom: 0;
150 | vertical-align: middle;
151 | }
152 |
153 | .navbar-form .radio label,
154 | .navbar-form .checkbox label {
155 | padding-left: 0;
156 | }
157 |
158 | .navbar-form .radio input[type="radio"],
159 | .navbar-form .checkbox input[type="checkbox"] {
160 | position: relative;
161 | margin-left: 0;
162 | }
163 |
164 | .navbar-form .has-feedback .form-control-feedback {
165 | top: 0;
166 | }
167 |
168 |
--------------------------------------------------------------------------------
/www/css/font-awesome.min.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Font Awesome 4.6.3 by @davegandy - http://fontawesome.io - @fontawesome
3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.6.3');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.6.3') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.6.3') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.6.3') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.6.3') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.6.3#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}
5 |
--------------------------------------------------------------------------------
/www/fonts/FontAwesome.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/FontAwesome.otf
--------------------------------------------------------------------------------
/www/fonts/fontawesome-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/fontawesome-webfont.eot
--------------------------------------------------------------------------------
/www/fonts/fontawesome-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/fontawesome-webfont.ttf
--------------------------------------------------------------------------------
/www/fonts/fontawesome-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/fontawesome-webfont.woff
--------------------------------------------------------------------------------
/www/fonts/fontawesome-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/fontawesome-webfont.woff2
--------------------------------------------------------------------------------
/www/fonts/glyphicons-halflings-regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/glyphicons-halflings-regular.eot
--------------------------------------------------------------------------------
/www/fonts/glyphicons-halflings-regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/glyphicons-halflings-regular.ttf
--------------------------------------------------------------------------------
/www/fonts/glyphicons-halflings-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/glyphicons-halflings-regular.woff
--------------------------------------------------------------------------------
/www/fonts/glyphicons-halflings-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kevinschoon/fit/fc27e31445f0d6fbcfacc8dbeb0f920657fedcb2/www/fonts/glyphicons-halflings-regular.woff2
--------------------------------------------------------------------------------
/www/html/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
TODO | 79 |
---|
84 | TODO 85 | | 86 |