killbill-aviate 2.3.0 → 2.4.0.pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/javascripts/aviate/kiddo/axes.js +54 -0
- data/app/assets/javascripts/aviate/kiddo/charts/line_chart.js +138 -0
- data/app/assets/javascripts/aviate/kiddo/charts/utils/mouse_over.js +149 -0
- data/app/assets/javascripts/aviate/kiddo/helper.js +52 -0
- data/app/assets/javascripts/aviate/kiddo/renderer.js +36 -0
- data/app/assets/javascripts/aviate/kiddo/settings.js +24 -0
- data/app/assets/javascripts/aviate_application.js +1 -0
- data/app/assets/stylesheets/aviate/usage.css +12 -0
- data/app/controllers/aviate/usage_controller.rb +224 -0
- data/app/views/aviate/usage/index.html.erb +70 -0
- data/app/views/aviate/usage/show.html.erb +92 -0
- data/config/routes.rb +3 -0
- data/lib/aviate/client.rb +30 -0
- data/lib/aviate/version.rb +1 -1
- metadata +14 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '07590f4ddf4251690f20b6c4368c673c834ff9fe7cc1caa7965375073e5be898'
|
|
4
|
+
data.tar.gz: 4ceb836ff7732f47da9c8e7f0836d9ebbdfc034f4e2b09a60556f41ad9e17889
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e25e698e0b00458d0f9ed77f9f862ac0a26348e06cde2ebddc7ea3f9208cd4df5edf32bcf6c4832248f12dc1d93274b2126f6e790cc87b7ed8d87be7a15b52d0
|
|
7
|
+
data.tar.gz: 786a7afe69526f66db9e3b5129d362fe8a2ff755cce777d6e85e79adfeee88d63d29c2f394fdce22ea78883d20ebd7641bdd8d9946d8097b02c3aec173b8dd6d
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
(function (Aviate, d3) {
|
|
2
|
+
Aviate.Axes = function () {
|
|
3
|
+
var self = this;
|
|
4
|
+
|
|
5
|
+
var makeXAxis = function () {
|
|
6
|
+
return d3.axisBottom(self.x).ticks(6);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
var makeYAxis = function () {
|
|
10
|
+
return d3
|
|
11
|
+
.axisLeft(self.y)
|
|
12
|
+
.tickFormat(d3.format(",d"));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
var xAxis = makeXAxis();
|
|
16
|
+
var yAxis = makeYAxis();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
x: xAxis,
|
|
20
|
+
y: yAxis,
|
|
21
|
+
render: function (svg) {
|
|
22
|
+
svg
|
|
23
|
+
.append("g")
|
|
24
|
+
.attr("class", "grid")
|
|
25
|
+
.attr(
|
|
26
|
+
"transform",
|
|
27
|
+
"translate(" + self.margin_left + "," + self.height + ")"
|
|
28
|
+
)
|
|
29
|
+
.call(makeXAxis().tickSize(-self.height).tickFormat(""));
|
|
30
|
+
|
|
31
|
+
svg
|
|
32
|
+
.append("g")
|
|
33
|
+
.attr("class", "grid")
|
|
34
|
+
.attr("transform", "translate(" + self.margin_left + ",0)")
|
|
35
|
+
.call(makeYAxis().tickSize(-self.width).tickFormat(""));
|
|
36
|
+
|
|
37
|
+
svg
|
|
38
|
+
.append("g")
|
|
39
|
+
.attr("class", "x axis")
|
|
40
|
+
.attr(
|
|
41
|
+
"transform",
|
|
42
|
+
"translate(" + self.margin_left + "," + self.height + ")"
|
|
43
|
+
)
|
|
44
|
+
.call(xAxis);
|
|
45
|
+
|
|
46
|
+
svg
|
|
47
|
+
.append("g")
|
|
48
|
+
.attr("class", "y axis")
|
|
49
|
+
.attr("transform", "translate(" + self.margin_left + ",0)")
|
|
50
|
+
.call(yAxis);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
})((window.Aviate = window.Aviate || {}), d3);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
(function (Aviate, d3) {
|
|
2
|
+
Aviate.LineChart = function () {
|
|
3
|
+
var self = this;
|
|
4
|
+
|
|
5
|
+
this.x = d3.scaleTime().range([0, this.width]);
|
|
6
|
+
this.y = d3.scaleLinear().range([this.height, 0]);
|
|
7
|
+
|
|
8
|
+
var valueline = d3
|
|
9
|
+
.line()
|
|
10
|
+
.x(function (d) {
|
|
11
|
+
return self.x(d.x);
|
|
12
|
+
})
|
|
13
|
+
.y(function (d) {
|
|
14
|
+
return self.y(d.y);
|
|
15
|
+
})
|
|
16
|
+
.curve(d3.curveStepAfter);
|
|
17
|
+
|
|
18
|
+
var axes = Aviate.Axes.apply(this);
|
|
19
|
+
var helper = new Aviate.Helper();
|
|
20
|
+
|
|
21
|
+
var colors = [
|
|
22
|
+
"#2196F3", // blue
|
|
23
|
+
"#E53935", // red
|
|
24
|
+
"#43A047", // green
|
|
25
|
+
"#FB8C00", // orange
|
|
26
|
+
"#8E24AA", // purple
|
|
27
|
+
"#00ACC1", // cyan
|
|
28
|
+
"#F4511E", // deep orange
|
|
29
|
+
"#6D4C41", // brown
|
|
30
|
+
];
|
|
31
|
+
self.color = d3.scaleOrdinal().range(colors);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
render: function (svg, json) {
|
|
35
|
+
var datasets = json.data;
|
|
36
|
+
|
|
37
|
+
datasets.forEach(function (dataset) {
|
|
38
|
+
dataset.values.forEach(function (d) {
|
|
39
|
+
d.date = typeof d.x === "string" ? d.x.split("T")[0] : d.x;
|
|
40
|
+
d.x = helper.parseDate(d.date);
|
|
41
|
+
d.y = +d.y;
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Scale the range of the data before rendering axes
|
|
46
|
+
var allValues = datasets.reduce(function (result, element) {
|
|
47
|
+
return result.concat(element.values);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
var x_domain = d3.extent(allValues, function (d) {
|
|
51
|
+
return d.x;
|
|
52
|
+
});
|
|
53
|
+
self.x.domain(x_domain);
|
|
54
|
+
|
|
55
|
+
var y_domain = d3.extent(allValues, function (d) {
|
|
56
|
+
return d.y;
|
|
57
|
+
});
|
|
58
|
+
self.y.domain(y_domain);
|
|
59
|
+
|
|
60
|
+
// Render axes
|
|
61
|
+
axes.render(svg);
|
|
62
|
+
|
|
63
|
+
self.color.domain(datasets.map(function (dataset) {
|
|
64
|
+
return dataset.name;
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Create legend container to the right of the chart area
|
|
68
|
+
var legendContainer = svg
|
|
69
|
+
.append("g")
|
|
70
|
+
.attr("class", "chart-legend")
|
|
71
|
+
.attr("transform", "translate(" + (self.width + self.margin_left + 15) + ", 10)");
|
|
72
|
+
|
|
73
|
+
// Calculate latest values for legend
|
|
74
|
+
var legendData = datasets.map(function (dataset) {
|
|
75
|
+
var latestValue = dataset.values[dataset.values.length - 1];
|
|
76
|
+
return {
|
|
77
|
+
name: dataset.name,
|
|
78
|
+
value: latestValue ? latestValue.y : 0,
|
|
79
|
+
color: self.color(dataset.name),
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
var legendItems = legendContainer
|
|
84
|
+
.selectAll(".legend-item")
|
|
85
|
+
.data(legendData)
|
|
86
|
+
.enter()
|
|
87
|
+
.append("g")
|
|
88
|
+
.attr("class", "legend-item");
|
|
89
|
+
|
|
90
|
+
var yOffset = 0;
|
|
91
|
+
legendItems.each(function (d) {
|
|
92
|
+
var legendItem = d3.select(this);
|
|
93
|
+
|
|
94
|
+
legendItem
|
|
95
|
+
.append("circle")
|
|
96
|
+
.attr("cx", 6)
|
|
97
|
+
.attr("cy", yOffset)
|
|
98
|
+
.attr("r", 6)
|
|
99
|
+
.style("fill", d.color);
|
|
100
|
+
|
|
101
|
+
var labelText = d.name + ": " + helper.formatValue(d.value);
|
|
102
|
+
legendItem
|
|
103
|
+
.append("text")
|
|
104
|
+
.attr("x", 18)
|
|
105
|
+
.attr("y", yOffset)
|
|
106
|
+
.attr("dy", "0.35em")
|
|
107
|
+
.style("font-size", "0.875rem")
|
|
108
|
+
.style("font-weight", "500")
|
|
109
|
+
.style("fill", "#6B7280")
|
|
110
|
+
.text(labelText);
|
|
111
|
+
|
|
112
|
+
yOffset += 22;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Render data lines (stepped)
|
|
116
|
+
datasets.forEach(function (dataset) {
|
|
117
|
+
var data = dataset.values,
|
|
118
|
+
name = dataset.name;
|
|
119
|
+
|
|
120
|
+
svg
|
|
121
|
+
.append("path")
|
|
122
|
+
.attr("class", "line")
|
|
123
|
+
.attr("d", valueline(data))
|
|
124
|
+
.attr("transform", "translate(" + self.margin_left + ",0)")
|
|
125
|
+
.style("stroke", function () {
|
|
126
|
+
return self.color(name);
|
|
127
|
+
})
|
|
128
|
+
.style("stroke-width", "0.125rem")
|
|
129
|
+
.style("fill", "none")
|
|
130
|
+
.style("opacity", 0.9);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
self.datasets = datasets;
|
|
134
|
+
Aviate.Utils.MouseOver.apply(self).render(svg, self.x, self.y);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
})((window.Aviate = window.Aviate || {}), d3);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
(function (Aviate, d3) {
|
|
2
|
+
Aviate.Utils = Aviate.Utils || {};
|
|
3
|
+
|
|
4
|
+
Aviate.Utils.MouseOver = function () {
|
|
5
|
+
var self = this;
|
|
6
|
+
var helper = new Aviate.Helper();
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
render: function (svg) {
|
|
10
|
+
var focus = svg
|
|
11
|
+
.append("g")
|
|
12
|
+
.attr("class", "focus")
|
|
13
|
+
.style("display", "none");
|
|
14
|
+
|
|
15
|
+
// Vertical line
|
|
16
|
+
focus
|
|
17
|
+
.append("line")
|
|
18
|
+
.attr("class", "mouse-line")
|
|
19
|
+
.attr("y1", 0)
|
|
20
|
+
.attr("y2", self.height)
|
|
21
|
+
.style("stroke", "#999")
|
|
22
|
+
.style("stroke-dasharray", "3,3")
|
|
23
|
+
.style("opacity", 0.5);
|
|
24
|
+
|
|
25
|
+
// Circles for each series
|
|
26
|
+
self.datasets.forEach(function () {
|
|
27
|
+
focus
|
|
28
|
+
.append("circle")
|
|
29
|
+
.attr("r", 4)
|
|
30
|
+
.style("fill", "none")
|
|
31
|
+
.style("stroke-width", 2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Tooltip
|
|
35
|
+
var tooltip = d3
|
|
36
|
+
.select("body")
|
|
37
|
+
.append("div")
|
|
38
|
+
.attr("class", "chart-tooltip")
|
|
39
|
+
.style("position", "absolute")
|
|
40
|
+
.style("background", "white")
|
|
41
|
+
.style("border", "1px solid #ddd")
|
|
42
|
+
.style("border-radius", "4px")
|
|
43
|
+
.style("padding", "8px 12px")
|
|
44
|
+
.style("font-size", "0.8125rem")
|
|
45
|
+
.style("pointer-events", "none")
|
|
46
|
+
.style("opacity", 0)
|
|
47
|
+
.style("z-index", 1000)
|
|
48
|
+
.style("box-shadow", "0 2px 8px rgba(0,0,0,0.1)");
|
|
49
|
+
|
|
50
|
+
// Overlay rect to capture mouse
|
|
51
|
+
svg
|
|
52
|
+
.append("rect")
|
|
53
|
+
.attr("class", "overlay")
|
|
54
|
+
.attr("width", self.width)
|
|
55
|
+
.attr("height", self.height)
|
|
56
|
+
.attr("transform", "translate(" + self.margin_left + ",0)")
|
|
57
|
+
.style("fill", "none")
|
|
58
|
+
.style("pointer-events", "all")
|
|
59
|
+
.on("mouseover", function () {
|
|
60
|
+
focus.style("display", null);
|
|
61
|
+
tooltip.style("opacity", 1);
|
|
62
|
+
})
|
|
63
|
+
.on("mouseout", function () {
|
|
64
|
+
focus.style("display", "none");
|
|
65
|
+
tooltip.style("opacity", 0);
|
|
66
|
+
})
|
|
67
|
+
.on("mousemove", function () {
|
|
68
|
+
var mouseX = d3.pointer(event, this)[0];
|
|
69
|
+
var x0 = self.x.invert(mouseX);
|
|
70
|
+
|
|
71
|
+
// Find closest data point for each dataset
|
|
72
|
+
var tooltipRows = [];
|
|
73
|
+
var closestDate = null;
|
|
74
|
+
self.datasets.forEach(function (dataset, i) {
|
|
75
|
+
var data = dataset.values;
|
|
76
|
+
var idx = helper.bisectDate(data, x0, 1);
|
|
77
|
+
var d0 = data[idx - 1];
|
|
78
|
+
var d1 = data[idx];
|
|
79
|
+
if (!d0 && !d1) return;
|
|
80
|
+
var d = d0 && d1
|
|
81
|
+
? x0 - d0.x > d1.x - x0 ? d1 : d0
|
|
82
|
+
: d0 || d1;
|
|
83
|
+
|
|
84
|
+
tooltipRows.push({
|
|
85
|
+
name: dataset.name,
|
|
86
|
+
value: d.y,
|
|
87
|
+
color: self.color(dataset.name),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Use the matched data point's own date (a "%Y-%m-%d" string) for the
|
|
91
|
+
// tooltip header, not the raw mouse position - self.x/self.y are pixel
|
|
92
|
+
// scales, not formatters, and re-scaling x0 through them produced a
|
|
93
|
+
// meaningless pixel offset instead of a date.
|
|
94
|
+
if (closestDate === null) {
|
|
95
|
+
closestDate = d.date;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
focus
|
|
99
|
+
.selectAll("circle")
|
|
100
|
+
.filter(function (_, j) { return j === i; })
|
|
101
|
+
.attr("cx", self.x(d.x) + self.margin_left)
|
|
102
|
+
.attr("cy", self.y(d.y));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Update vertical line
|
|
106
|
+
focus
|
|
107
|
+
.select(".mouse-line")
|
|
108
|
+
.attr("x1", self.x(x0) + self.margin_left)
|
|
109
|
+
.attr("x2", self.x(x0) + self.margin_left);
|
|
110
|
+
|
|
111
|
+
// Sort by value descending for tooltip
|
|
112
|
+
tooltipRows.sort(function (a, b) { return b.value - a.value; });
|
|
113
|
+
|
|
114
|
+
var dateStr = helper.formatDate(closestDate);
|
|
115
|
+
var rowsHtml = tooltipRows.map(function (row) {
|
|
116
|
+
return (
|
|
117
|
+
'<div style="display:flex;align-items:center;gap:6px;margin:2px 0;">' +
|
|
118
|
+
'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + row.color + ';flex-shrink:0;"></span>' +
|
|
119
|
+
'<span>' + row.name + ":</span>" +
|
|
120
|
+
'<span style="font-weight:600;">' + helper.formatValue(row.value) + "</span>" +
|
|
121
|
+
"</div>"
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
tooltip
|
|
126
|
+
.html(
|
|
127
|
+
'<div style="font-weight:600;margin-bottom:4px;">' + dateStr + "</div>" +
|
|
128
|
+
rowsHtml.join("")
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Position tooltip, flip to left when near right edge
|
|
132
|
+
var tooltipNode = tooltip.node();
|
|
133
|
+
var tooltipWidth = tooltipNode ? tooltipNode.getBoundingClientRect().width : 200;
|
|
134
|
+
var chartRect = svg.node().getBoundingClientRect();
|
|
135
|
+
var mousePageX = d3.pointer(event, document.body)[0];
|
|
136
|
+
var leftPos = mousePageX + 15;
|
|
137
|
+
|
|
138
|
+
if (leftPos + tooltipWidth > window.innerWidth - 20) {
|
|
139
|
+
leftPos = mousePageX - tooltipWidth - 15;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
tooltip
|
|
143
|
+
.style("left", leftPos + "px")
|
|
144
|
+
.style("top", chartRect.top + "px");
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
})((window.Aviate = window.Aviate || {}), d3);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
(function (Aviate, d3) {
|
|
2
|
+
Aviate.Helper = function () {
|
|
3
|
+
var formatValue = function (d) {
|
|
4
|
+
return d % 1 === 0 ? d3.format(",d")(d) : d3.format(",.2f")(d);
|
|
5
|
+
};
|
|
6
|
+
var formatCurrency = function (d) {
|
|
7
|
+
return "$" + formatValue(d);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
var humanizeSegment = function (segment) {
|
|
11
|
+
segment = String(segment || "")
|
|
12
|
+
.replace(/[_-]+/g, " ")
|
|
13
|
+
.replace(/\s+/g, " ")
|
|
14
|
+
.trim();
|
|
15
|
+
|
|
16
|
+
if (!segment) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return segment
|
|
21
|
+
.split(" ")
|
|
22
|
+
.map(function (word) {
|
|
23
|
+
if (/^\d+(\.\d+)?$/.test(word)) {
|
|
24
|
+
return word;
|
|
25
|
+
}
|
|
26
|
+
if (/^[A-Z]{2,3}$/.test(word)) {
|
|
27
|
+
return word;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
31
|
+
})
|
|
32
|
+
.join(" ");
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
var parseDateFn = d3.timeParse("%Y-%m-%d");
|
|
36
|
+
var formatDateFn = d3.timeFormat("%b %d, %Y");
|
|
37
|
+
var formatDate = function (dateStr) {
|
|
38
|
+
var parsed = parseDateFn(dateStr);
|
|
39
|
+
return parsed ? formatDateFn(parsed) : dateStr;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
parseDate: parseDateFn,
|
|
44
|
+
bisectDate: d3.bisector(function (d) {
|
|
45
|
+
return d.x;
|
|
46
|
+
}).left,
|
|
47
|
+
formatCurrency: formatCurrency,
|
|
48
|
+
formatValue: formatValue,
|
|
49
|
+
formatDate: formatDate,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
})((window.Aviate = window.Aviate || {}), d3);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
(function (Aviate, d3) {
|
|
2
|
+
Aviate.Renderer = function (selector) {
|
|
3
|
+
this.element = d3.select(selector);
|
|
4
|
+
|
|
5
|
+
var settings = Aviate.Settings.apply(this);
|
|
6
|
+
|
|
7
|
+
var svg = this.element
|
|
8
|
+
.append("svg")
|
|
9
|
+
.attr("width", settings.raw_width)
|
|
10
|
+
.attr("height", settings.raw_height)
|
|
11
|
+
.attr("style", "overflow: visible")
|
|
12
|
+
.append("g")
|
|
13
|
+
.attr(
|
|
14
|
+
"transform",
|
|
15
|
+
"translate(" + settings.margin_left + "," + settings.margin_top + ")"
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
lineChart: function (data) {
|
|
20
|
+
var chart = Aviate.LineChart.apply(settings);
|
|
21
|
+
chart.render(svg, data);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
noData: function () {
|
|
25
|
+
svg
|
|
26
|
+
.append("text")
|
|
27
|
+
.attr("class", "chart-info")
|
|
28
|
+
.attr(
|
|
29
|
+
"transform",
|
|
30
|
+
"translate(" + settings.width / 2 + "," + settings.height / 2 + ")"
|
|
31
|
+
)
|
|
32
|
+
.text("No data to display.");
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
})((window.Aviate = window.Aviate || {}), d3);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
(function (Aviate, d3) {
|
|
2
|
+
Aviate.Settings = function () {
|
|
3
|
+
var margin_top = 30,
|
|
4
|
+
margin_bottom = 40,
|
|
5
|
+
margin_left = 50,
|
|
6
|
+
margin_right = 150,
|
|
7
|
+
raw_width = parseInt(
|
|
8
|
+
this.element.node().getBoundingClientRect().width,
|
|
9
|
+
10
|
|
10
|
+
),
|
|
11
|
+
raw_height = 500;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
margin_top: margin_top,
|
|
15
|
+
margin_bottom: margin_bottom,
|
|
16
|
+
margin_left: margin_left,
|
|
17
|
+
margin_right: margin_right,
|
|
18
|
+
raw_width: raw_width,
|
|
19
|
+
raw_height: raw_height,
|
|
20
|
+
width: raw_width - margin_left - margin_right,
|
|
21
|
+
height: raw_height - margin_top - margin_bottom,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
})((window.Aviate = window.Aviate || {}), d3);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Aviate
|
|
6
|
+
class UsageController < Aviate::EngineController
|
|
7
|
+
before_action :aviate_authentication
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
cached_options = options_for_klient
|
|
11
|
+
@account_id = params.require(:account_id)
|
|
12
|
+
@account = Kaui::Account.find_by_id(@account_id, false, false, cached_options)
|
|
13
|
+
|
|
14
|
+
bundles = @account.bundles(cached_options)
|
|
15
|
+
@subscriptions = []
|
|
16
|
+
bundles.each do |bundle|
|
|
17
|
+
(bundle.subscriptions || []).each do |sub|
|
|
18
|
+
@subscriptions << sub if sub.billing_end_date.blank?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show
|
|
24
|
+
cached_options = options_for_klient
|
|
25
|
+
subscription_id = params.require(:id)
|
|
26
|
+
|
|
27
|
+
@subscription = Kaui::Subscription.find_by_id(subscription_id, 'NONE', cached_options)
|
|
28
|
+
@account_id = @subscription.account_id
|
|
29
|
+
@account = Kaui::Account.find_by_id(@account_id, false, false, cached_options)
|
|
30
|
+
|
|
31
|
+
start_date, end_date = billing_period(@subscription)
|
|
32
|
+
@period_start = format_date(start_date)
|
|
33
|
+
@period_end = format_date(end_date)
|
|
34
|
+
@period_closed = end_date < Time.zone.today
|
|
35
|
+
|
|
36
|
+
# Step 3: Retrieve unit types from catalog
|
|
37
|
+
unit_types = fetch_unit_types(@subscription, start_date, cached_options)
|
|
38
|
+
|
|
39
|
+
# Step 4: Fetch daily usage time-series in parallel
|
|
40
|
+
samples_by_unit = fetch_all_samples(subscription_id, unit_types, start_date, end_date, cached_options)
|
|
41
|
+
|
|
42
|
+
# Step 5: Build cumulative series per unit type
|
|
43
|
+
@chart_data = build_chart_data(samples_by_unit, unit_types)
|
|
44
|
+
|
|
45
|
+
# Step 6: Fetch invoice dry run for accrued cost
|
|
46
|
+
@accrued_cost = fetch_accrued_cost(@account_id, cached_options)
|
|
47
|
+
|
|
48
|
+
# Step 7: Fetch wallet balance
|
|
49
|
+
@wallet_balance = fetch_wallet_balance(@account_id, cached_options)
|
|
50
|
+
|
|
51
|
+
# Build chart JSON for the view
|
|
52
|
+
@chart_json = build_chart_json(@chart_data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def billing_period(subscription)
|
|
58
|
+
start_date = parse_kb_date(subscription.charged_through_date)
|
|
59
|
+
end_date = compute_next_bcd(start_date, subscription.bill_cycle_day_local)
|
|
60
|
+
[start_date, end_date]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_kb_date(date_str)
|
|
64
|
+
return Time.zone.today if date_str.blank?
|
|
65
|
+
|
|
66
|
+
Date.parse(date_str.to_s)
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
Time.zone.today
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def compute_next_bcd(start_date, bill_cycle_day_local)
|
|
72
|
+
return start_date + 30 if bill_cycle_day_local.blank?
|
|
73
|
+
|
|
74
|
+
bcd = bill_cycle_day_local.to_i
|
|
75
|
+
bcd = 28 if bcd > 28
|
|
76
|
+
bcd = 1 if bcd < 1
|
|
77
|
+
|
|
78
|
+
if start_date.day < bcd
|
|
79
|
+
next_date = Date.new(start_date.year, start_date.month, bcd)
|
|
80
|
+
else
|
|
81
|
+
next_month = start_date.next_month
|
|
82
|
+
last_day = [bcd, Date.new(next_month.year, next_month.month, -1).day].min
|
|
83
|
+
next_date = Date.new(next_month.year, next_month.month, last_day)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
next_date
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
start_date + 30
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fetch_unit_types(subscription, start_date, cached_options)
|
|
92
|
+
# NOTE: KillBillClient::Model::Catalog.get_catalog_phase parses the response as a
|
|
93
|
+
# Catalog object by default, but the API actually returns a Phase-shaped payload
|
|
94
|
+
# (it has no `usages` accessor otherwise) - request it as a Phase explicitly.
|
|
95
|
+
phase = KillBillClient::Model::Phase.get(
|
|
96
|
+
"#{KillBillClient::Model::Catalog::KILLBILL_API_CATALOG_PREFIX}/phase",
|
|
97
|
+
{
|
|
98
|
+
subscriptionId: subscription.subscription_id,
|
|
99
|
+
requestedDate: start_date.to_s
|
|
100
|
+
},
|
|
101
|
+
cached_options
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return [] if phase.nil? || phase.usages.blank?
|
|
105
|
+
|
|
106
|
+
unit_types = []
|
|
107
|
+
Array(phase.usages).each do |usage|
|
|
108
|
+
usage_hash = usage.respond_to?(:to_hash) ? usage.to_hash : usage
|
|
109
|
+
tiers = usage_hash['tiers'] || usage_hash[:tiers] || []
|
|
110
|
+
Array(tiers).each do |tier|
|
|
111
|
+
tier_hash = tier.respond_to?(:to_hash) ? tier.to_hash : tier
|
|
112
|
+
blocks = tier_hash['blocks'] || tier_hash[:blocks] || []
|
|
113
|
+
Array(blocks).each do |block|
|
|
114
|
+
block_hash = block.respond_to?(:to_hash) ? block.to_hash : block
|
|
115
|
+
unit = block_hash['unit'] || block_hash[:unit]
|
|
116
|
+
unit_types << unit if unit.present?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
unit_types.uniq.compact
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Rails.logger.warn("Failed to fetch catalog phase: #{e.message}")
|
|
124
|
+
[]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def fetch_all_samples(subscription_id, unit_types, start_date, end_date, cached_options)
|
|
128
|
+
samples_by_unit = {}
|
|
129
|
+
from = start_date.to_s
|
|
130
|
+
to = end_date.to_s
|
|
131
|
+
|
|
132
|
+
return samples_by_unit if unit_types.empty?
|
|
133
|
+
|
|
134
|
+
# rubocop:disable ThreadSafety/NewThread -- fetching samples for each unit type in parallel is intentional
|
|
135
|
+
threads = unit_types.map do |unit_type|
|
|
136
|
+
Thread.new do
|
|
137
|
+
samples = Killbill::Aviate::AviateClient.host_samples(
|
|
138
|
+
subscription_id, unit_type, from, to, cached_options
|
|
139
|
+
)
|
|
140
|
+
[unit_type, samples]
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
Rails.logger.warn("host_samples failed for #{unit_type}: #{e.message}")
|
|
143
|
+
[unit_type, []]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
# rubocop:enable ThreadSafety/NewThread
|
|
147
|
+
|
|
148
|
+
threads.each do |t|
|
|
149
|
+
unit_type, samples = t.value
|
|
150
|
+
samples_by_unit[unit_type] = samples
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
samples_by_unit
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def build_chart_data(samples_by_unit, unit_types)
|
|
157
|
+
return {} if samples_by_unit.empty?
|
|
158
|
+
|
|
159
|
+
chart_data = {}
|
|
160
|
+
unit_types.each do |unit_type|
|
|
161
|
+
samples = samples_by_unit[unit_type] || []
|
|
162
|
+
next if samples.empty?
|
|
163
|
+
|
|
164
|
+
cumulative = 0
|
|
165
|
+
values = samples.map do |s|
|
|
166
|
+
cumulative += s[:value]
|
|
167
|
+
{ 'x' => s[:date]&.split('T')&.first || s[:date], 'y' => cumulative }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
chart_data[unit_type] = values
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
chart_data
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_chart_json(chart_data)
|
|
177
|
+
return nil if chart_data.empty?
|
|
178
|
+
|
|
179
|
+
series = chart_data.map do |unit_type, values|
|
|
180
|
+
{ 'name' => unit_type, 'values' => values }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
{ 'type' => 'TIMELINE', 'data' => series }.to_json
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def fetch_accrued_cost(account_id, cached_options)
|
|
187
|
+
# Use the UPCOMING_INVOICE dry-run type (matches Kaui's own next_invoice_date action) so
|
|
188
|
+
# that in-arrear usage recorded so far in the still-open period is reflected. A plain
|
|
189
|
+
# TARGET_DATE dry-run with no date returns 204/nil here, since there's no invoicing
|
|
190
|
+
# event actually scheduled for "now" mid-period.
|
|
191
|
+
invoice = KillBillClient::Model::Invoice.trigger_invoice_dry_run(
|
|
192
|
+
account_id,
|
|
193
|
+
nil,
|
|
194
|
+
true,
|
|
195
|
+
[],
|
|
196
|
+
current_user&.kb_username,
|
|
197
|
+
nil,
|
|
198
|
+
nil,
|
|
199
|
+
cached_options
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return nil if invoice.nil? || invoice.amount.nil?
|
|
203
|
+
|
|
204
|
+
invoice.amount
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
Rails.logger.warn("Invoice dry run failed: #{e.message}")
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def fetch_wallet_balance(account_id, cached_options)
|
|
211
|
+
wallets = Killbill::Aviate::AviateClient.retrieve_wallets(account_id, cached_options)
|
|
212
|
+
return nil if wallets.blank?
|
|
213
|
+
|
|
214
|
+
wallets.sum { |w| (w['balance'] || 0).to_f }
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
Rails.logger.warn("Wallet balance fetch failed: #{e.message}")
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def format_date(date)
|
|
221
|
+
date.strftime('%b %d, %Y')
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<div class="kaui-container usage-index">
|
|
2
|
+
<%= render "kaui/components/breadcrumb/breadcrumb" %>
|
|
3
|
+
<div class="d-flex" style="gap: 4rem;">
|
|
4
|
+
<%= render :template => 'kaui/layouts/kaui_account_sidebar' %>
|
|
5
|
+
<div class="usage-content" style="max-width: 67.5rem; width: 100%;">
|
|
6
|
+
<div class="usage-header mb-4">
|
|
7
|
+
<h2>Usage</h2>
|
|
8
|
+
<p class="text-muted">Select a subscription to view its metered usage.</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<% if @subscriptions.empty? %>
|
|
12
|
+
<div class="text-center py-5 text-muted">
|
|
13
|
+
<p>No active subscriptions found for this account.</p>
|
|
14
|
+
</div>
|
|
15
|
+
<% else %>
|
|
16
|
+
<table id="usage-table" class="usage-table">
|
|
17
|
+
<thead>
|
|
18
|
+
<tr>
|
|
19
|
+
<th>Subscription</th>
|
|
20
|
+
<th>Plan</th>
|
|
21
|
+
<th>Product</th>
|
|
22
|
+
<th>Start Date</th>
|
|
23
|
+
<th></th>
|
|
24
|
+
</tr>
|
|
25
|
+
</thead>
|
|
26
|
+
<tbody>
|
|
27
|
+
<% @subscriptions.each do |sub| %>
|
|
28
|
+
<tr>
|
|
29
|
+
<td>
|
|
30
|
+
<span class="object-id-popover" data-id="<%= sub.subscription_id %>">
|
|
31
|
+
<%= truncate(sub.subscription_id, length: 12) %>
|
|
32
|
+
</span>
|
|
33
|
+
</td>
|
|
34
|
+
<td><%= sub.plan_name || '—' %></td>
|
|
35
|
+
<td><%= sub.product_name || '—' %></td>
|
|
36
|
+
<td><%= sub.start_date.present? ? Date.parse(sub.start_date.to_s).strftime('%b %d, %Y') : '—' %></td>
|
|
37
|
+
<td class="text-end">
|
|
38
|
+
<%= render "kaui/components/button/button", {
|
|
39
|
+
label: "View Usage",
|
|
40
|
+
variant: "outline-secondary d-inline-flex align-items-center gap-1",
|
|
41
|
+
type: "button",
|
|
42
|
+
html_class: "kaui-button custom-hover",
|
|
43
|
+
html_options: {
|
|
44
|
+
onclick: "window.location.href='#{aviate_engine.usage_subscription_path(id: sub.subscription_id)}'"
|
|
45
|
+
}
|
|
46
|
+
} %>
|
|
47
|
+
</td>
|
|
48
|
+
</tr>
|
|
49
|
+
<% end %>
|
|
50
|
+
</tbody>
|
|
51
|
+
</table>
|
|
52
|
+
<% end %>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<% unless @subscriptions.empty? %>
|
|
58
|
+
<%= javascript_tag do %>
|
|
59
|
+
|
|
60
|
+
$(document).ready(function() {
|
|
61
|
+
var table = $('#usage-table').DataTable({
|
|
62
|
+
"processing": false,
|
|
63
|
+
"info": false,
|
|
64
|
+
"paging": false,
|
|
65
|
+
"searching": false,
|
|
66
|
+
"ordering": false
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
<% end %>
|
|
70
|
+
<% end %>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<div class="kaui-container usage-show">
|
|
2
|
+
<%= render "kaui/components/breadcrumb/breadcrumb" %>
|
|
3
|
+
<div class="d-flex" style="gap: 4rem;">
|
|
4
|
+
<%= render :template => 'kaui/layouts/kaui_account_sidebar' %>
|
|
5
|
+
<div class="usage-content" style="max-width: 67.5rem; width: 100%;">
|
|
6
|
+
<div class="usage-header mb-4">
|
|
7
|
+
<h2>Usage</h2>
|
|
8
|
+
<p class="text-muted">
|
|
9
|
+
Subscription: <strong><%= @subscription.plan_name || @subscription.product_name %></strong>
|
|
10
|
+
— <%= @period_start %> to <%= @period_end %>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<% if @chart_json.present? || @accrued_cost.present? || @wallet_balance.present? %>
|
|
15
|
+
<div class="usage-stats d-flex gap-4 mb-4">
|
|
16
|
+
<% if @chart_data.any? %>
|
|
17
|
+
<% @chart_data.each do |unit_type, values| %>
|
|
18
|
+
<div class="stat-card card p-3">
|
|
19
|
+
<div class="stat-label text-muted small">Units Consumed (<%= unit_type %>)</div>
|
|
20
|
+
<div class="stat-value h4"><%= values.last&.dig('y') || 0 %></div>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% if @accrued_cost.present? %>
|
|
26
|
+
<div class="stat-card card p-3">
|
|
27
|
+
<div class="stat-label text-muted small"><%= @period_closed ? 'Invoice Amount' : 'Accrued Cost (today)' %></div>
|
|
28
|
+
<div class="stat-value h4"><%= number_to_currency(@accrued_cost) %></div>
|
|
29
|
+
</div>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<% if @wallet_balance.present? %>
|
|
33
|
+
<div class="stat-card card p-3">
|
|
34
|
+
<div class="stat-label text-muted small">Wallet Balance</div>
|
|
35
|
+
<div class="stat-value h4"><%= number_to_currency(@wallet_balance) %></div>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<div class="chart-container card p-3">
|
|
42
|
+
<% if @chart_json.present? %>
|
|
43
|
+
<div id="loading-spinner" class="text-center py-5">
|
|
44
|
+
<div class="spinner-border" role="status">
|
|
45
|
+
<span class="visually-hidden">Loading...</span>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div id="aviateUsageChartAnchor"
|
|
49
|
+
data-chart-json='<%= raw @chart_json %>'
|
|
50
|
+
style="width: 100%;">
|
|
51
|
+
</div>
|
|
52
|
+
<% else %>
|
|
53
|
+
<div class="text-center py-5 text-muted">
|
|
54
|
+
<% if @chart_data.empty? %>
|
|
55
|
+
<p>No usage data available for this period.</p>
|
|
56
|
+
<% else %>
|
|
57
|
+
<p>Unable to render chart.</p>
|
|
58
|
+
<% end %>
|
|
59
|
+
<% if @accrued_cost.nil? && @wallet_balance.nil? %>
|
|
60
|
+
<p>No usage, cost, or wallet data available.</p>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<% if @chart_json.present? %>
|
|
70
|
+
<%= javascript_include_tag 'aviate_application' %>
|
|
71
|
+
|
|
72
|
+
<%= javascript_tag do %>
|
|
73
|
+
$(document).ready(function() {
|
|
74
|
+
if ($("#aviateUsageChartAnchor").length === 0) return;
|
|
75
|
+
|
|
76
|
+
var chartJson = $("#aviateUsageChartAnchor").data("chart-json");
|
|
77
|
+
if (!chartJson || !chartJson.data || chartJson.data.length === 0) {
|
|
78
|
+
$("#loading-spinner").remove();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
var renderer = new Aviate.Renderer("#aviateUsageChartAnchor");
|
|
84
|
+
renderer.lineChart(chartJson);
|
|
85
|
+
} catch (ex) {
|
|
86
|
+
console.error("Chart render error:", ex);
|
|
87
|
+
} finally {
|
|
88
|
+
$("#loading-spinner").remove();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
<% end %>
|
|
92
|
+
<% end %>
|
data/config/routes.rb
CHANGED
data/lib/aviate/client.rb
CHANGED
|
@@ -79,6 +79,21 @@ module Killbill
|
|
|
79
79
|
JSON.parse(e.message)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
def host_samples(subscription_id, sample_kind, from_date, to_date, options = nil)
|
|
83
|
+
path = "#{KILLBILL_AVIATE_PREFIX}/health/host_samples"
|
|
84
|
+
query_params = {
|
|
85
|
+
from: from_date,
|
|
86
|
+
to: to_date,
|
|
87
|
+
category_and_sample_kind: "#{subscription_id},#{sample_kind}"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
request_options = build_request_options(options)
|
|
91
|
+
response = KillBillClient::API.get path, query_params, request_options
|
|
92
|
+
parse_csv_response(response.body)
|
|
93
|
+
rescue KillBillClient::API::ResponseError
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
82
97
|
def authenticate(email, password, options = nil)
|
|
83
98
|
path = "#{KILLBILL_AVIATE_PREFIX}/auth"
|
|
84
99
|
auth = Base64.strict_encode64("#{email}:#{password}")
|
|
@@ -102,6 +117,21 @@ module Killbill
|
|
|
102
117
|
|
|
103
118
|
private
|
|
104
119
|
|
|
120
|
+
def parse_csv_response(body)
|
|
121
|
+
return [] if body.nil? || body.strip.empty?
|
|
122
|
+
|
|
123
|
+
require 'csv'
|
|
124
|
+
rows = CSV.parse(body, headers: true)
|
|
125
|
+
rows.map do |row|
|
|
126
|
+
{
|
|
127
|
+
date: row['timestamp']&.strip,
|
|
128
|
+
value: row['value']&.strip&.to_f
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
rescue CSV::MalformedCSVError
|
|
132
|
+
[]
|
|
133
|
+
end
|
|
134
|
+
|
|
105
135
|
def build_request_options(options)
|
|
106
136
|
return {} if options.nil?
|
|
107
137
|
|
data/lib/aviate/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: killbill-aviate
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.4.0.pre.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kill Bill core team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: killbill-assets-ui
|
|
@@ -77,17 +77,27 @@ files:
|
|
|
77
77
|
- Rakefile
|
|
78
78
|
- app/assets/config/aviate_manifest.js
|
|
79
79
|
- app/assets/javascripts/aviate/aviate.js
|
|
80
|
+
- app/assets/javascripts/aviate/kiddo/axes.js
|
|
81
|
+
- app/assets/javascripts/aviate/kiddo/charts/line_chart.js
|
|
82
|
+
- app/assets/javascripts/aviate/kiddo/charts/utils/mouse_over.js
|
|
83
|
+
- app/assets/javascripts/aviate/kiddo/helper.js
|
|
84
|
+
- app/assets/javascripts/aviate/kiddo/renderer.js
|
|
85
|
+
- app/assets/javascripts/aviate/kiddo/settings.js
|
|
80
86
|
- app/assets/javascripts/aviate_application.js
|
|
81
87
|
- app/assets/stylesheets/aviate/aviate.css
|
|
88
|
+
- app/assets/stylesheets/aviate/usage.css
|
|
82
89
|
- app/assets/stylesheets/aviate_application.css
|
|
83
90
|
- app/controllers/aviate/configuration_controller.rb
|
|
84
91
|
- app/controllers/aviate/engine_controller.rb
|
|
92
|
+
- app/controllers/aviate/usage_controller.rb
|
|
85
93
|
- app/controllers/aviate/wallets_controller.rb
|
|
86
94
|
- app/helpers/aviate/application_helper.rb
|
|
87
95
|
- app/services/aviate/wallet.rb
|
|
88
96
|
- app/services/aviate/wallet_api.rb
|
|
89
97
|
- app/views/aviate/configuration/index.html.erb
|
|
90
98
|
- app/views/aviate/layouts/aviate_application.html.erb
|
|
99
|
+
- app/views/aviate/usage/index.html.erb
|
|
100
|
+
- app/views/aviate/usage/show.html.erb
|
|
91
101
|
- app/views/aviate/wallets/_form.html.erb
|
|
92
102
|
- app/views/aviate/wallets/index.html.erb
|
|
93
103
|
- app/views/aviate/wallets/new.html.erb
|
|
@@ -114,9 +124,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
114
124
|
version: '0'
|
|
115
125
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
116
126
|
requirements:
|
|
117
|
-
- - "
|
|
127
|
+
- - ">"
|
|
118
128
|
- !ruby/object:Gem::Version
|
|
119
|
-
version:
|
|
129
|
+
version: 1.3.1
|
|
120
130
|
requirements: []
|
|
121
131
|
rubygems_version: 3.4.10
|
|
122
132
|
signing_key:
|