├── .gitattributes
├── .github
└── workflows
│ └── gh-pages.yml
├── .gitignore
├── LICENSE
├── pull_stats.py
└── web
├── index.html
├── script.js
└── style.css
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 |
--------------------------------------------------------------------------------
/.github/workflows/gh-pages.yml:
--------------------------------------------------------------------------------
1 | name: github pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | schedule:
8 | - cron: "15 */12 * * *"
9 |
10 | jobs:
11 | deploy:
12 | runs-on: ubuntu-24.04
13 | steps:
14 | - uses: actions/checkout@v2
15 |
16 | - name: Pull stats
17 | run: ./pull_stats.py
18 |
19 | - name: Deploy
20 | uses: peaceiris/actions-gh-pages@v3
21 | with:
22 | github_token: ${{ secrets.GITHUB_TOKEN }}
23 | publish_dir: ./web
24 | user_name: 'github-actions[bot]'
25 | user_email: 'github-actions[bot]@users.noreply.github.com'
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /stats
2 | /web/data
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Kristian Klausen
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 |
--------------------------------------------------------------------------------
/pull_stats.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | import subprocess
4 | import json
5 | import shutil
6 |
7 | if subprocess.call(["wget", "--execute", "robots=off", "--recursive", "--no-host-directories", "--no-parent", "--reject-regex", "backup", "--accept", "*.json", "https://flathub.org/stats/"]) != 0:
8 | exit(1)
9 |
10 | files = []
11 | refs = {}
12 |
13 | for (dirpath, _, filenames) in os.walk("stats"):
14 | files.extend(os.path.join(dirpath, filename) for filename in filenames)
15 | files.sort()
16 | # The latest stats aren't very accurate, please see: https://github.com/klausenbusk/flathub-stats/issues/5
17 | del files[-1]
18 |
19 | for f in files:
20 | print(f)
21 | with open(f) as json_file:
22 | data = json.load(json_file)
23 | date = data["date"]
24 | for ref in data["refs"]:
25 | refs.setdefault(ref, {"ref": ref, "stats": []})["stats"].append({"date": date, "arches": data["refs"][ref]})
26 |
27 |
28 | os.chdir("web")
29 | if os.path.isdir("data"):
30 | shutil.rmtree("data")
31 | os.mkdir("data")
32 |
33 | def writeJson(f, data):
34 | with open(f, "w") as outfile:
35 | json.dump(data, outfile)
36 |
37 | for ref in refs:
38 | writeJson(f"data/{ref.replace('/', '_')}.json", refs[ref])
39 |
40 | writeJson("data/refs.json", list(refs.keys()))
41 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Flathub Stats
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
23 |
24 |
25 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/web/script.js:
--------------------------------------------------------------------------------
1 | let chart;
2 | let refs = new Set();
3 | let stats;
4 | let ref;
5 | let interval;
6 | /** How many days to group into one data point. @type {number} */
7 | let granularity;
8 | let downloadType;
9 | let min = null;
10 |
11 | function initChart() {
12 | let ctx = document.getElementById("chart").getContext("2d");
13 | chart = new Chart(ctx, {
14 | // The type of chart we want to create
15 | type: "line",
16 |
17 | // Configuration options go here
18 | options: {
19 | tension: 0.5,
20 | borderCapStyle: "round",
21 | borderJoinStyle: "round",
22 |
23 | scales: {
24 | x: {
25 | type: "time",
26 | time: {
27 | minUnit: "day",
28 | }
29 | }
30 | },
31 | tooltips: {
32 | mode: "x",
33 | intersect: false
34 | }
35 | }
36 | });
37 | }
38 |
39 | function updateBasicStats() {
40 | let total = 0;
41 | let average = 0;
42 | let first = null;
43 | chart.data.datasets.forEach((dataset) => {
44 | dataset.data.forEach((dataPoint) => {
45 | if (!min || min <= dataPoint.x) {
46 | total += dataPoint.y;
47 | if (!first || dataPoint.x < first) {
48 | first = dataPoint.x;
49 | }
50 | }
51 | })
52 | });
53 | average = total / Math.round(((new Date()) - first) / (24*60*60*1000));
54 | document.getElementById("basic-stats").textContent = `Total: ${total} downloads | Average: ${average.toFixed(2)} downloads per day`;
55 | }
56 |
57 | function updateDatasets() {
58 | let chartColors = [
59 | "rgb(255, 99, 132)", // red
60 | "rgb(255, 159, 64)", // orange
61 | "rgb(255, 205, 86)", // yellow
62 | "rgb(75, 192, 192)", // green
63 | "rgb(54, 162, 235)", // blue
64 | "rgb(153, 102, 255)", // purple
65 | ];
66 |
67 | let datasets = {};
68 | for (let dataPoint of stats) {
69 | for (let arch of Object.keys(dataPoint.arches)) {
70 | if (!(arch in datasets)) {
71 | let color = chartColors.pop();
72 | datasets[arch] = {
73 | label: arch,
74 | backgroundColor: Chart.helpers.color(color).alpha(0.2).rgbString(),
75 | borderColor: color,
76 | fill: true,
77 | data: []
78 | };
79 | }
80 | let downloads = 0;
81 | // Upstream logic: https://github.com/flathub/flathub-stats/blob/7711d11dd8224cd9a6655d3eaac97c9ae2ef46ea/update-stats.py#L23
82 | switch (downloadType) {
83 | case "installs+updates":
84 | downloads = dataPoint.arches[arch][0];
85 | break;
86 | case "installs":
87 | downloads = dataPoint.arches[arch][0] - dataPoint.arches[arch][1];
88 | break;
89 | case "updates":
90 | downloads = dataPoint.arches[arch][1];
91 | break;
92 | }
93 |
94 | let dataset = datasets[arch];
95 | dataset.data.push({
96 | x: new Date(dataPoint.date),
97 | y: downloads
98 | });
99 | }
100 | }
101 |
102 | if (granularity > 1) {
103 | for (let arch of Object.keys(datasets)) {
104 | let oldData = datasets[arch].data;
105 | let newData = [];
106 | for (let i = 0; i < oldData.length; i += granularity) {
107 | let sum = 0;
108 | for (let di = 0; di < granularity && i+di < oldData.length; di++) {
109 | sum += oldData[i+di].y;
110 | }
111 | newData.push({
112 | x: oldData[i].x,
113 | y: sum
114 | });
115 | }
116 | datasets[arch].data = newData;
117 | }
118 | }
119 |
120 | chart.data.datasets = Object.values(datasets);
121 | chart.update();
122 | updateBasicStats();
123 | }
124 |
125 | function updateURL() {
126 | const params = new URLSearchParams();
127 | params.set("ref", ref);
128 | if (interval !== "infinity") params.set("interval", interval);
129 | if (granularity !== 1) params.set("granularity", granularity);
130 | if (downloadType !== "installs+updates") params.set("downloadType", downloadType);
131 | window.location.hash = '#' + params.toString();
132 | }
133 |
134 | async function refHandler(event) {
135 | let refEventValue = event.target.value;
136 | if (!refs.has(refEventValue)) {
137 | return;
138 | }
139 | ref = refEventValue;
140 | let response = await fetch(`./data/${ref.replace("/", "_")}.json`);
141 | let json = await response.json();
142 |
143 | stats = json.stats;
144 | updateDatasets();
145 | updateURL();
146 | }
147 |
148 | function intervalHandler(event) {
149 | interval = event.target.value;
150 | if (interval === "infinity") {
151 | delete chart.options.scales.x.min;
152 | min = null;
153 | } else {
154 | min = new Date();
155 | min.setDate(min.getDate() - interval);
156 | chart.options.scales.x.min = min;
157 | }
158 | chart.update();
159 | updateBasicStats();
160 | updateURL();
161 | }
162 |
163 | function granularityHandler(event) {
164 | granularity = parseInt(event.target.value);
165 | updateDatasets();
166 | updateURL();
167 | }
168 |
169 | function downloadTypeHandler(event) {
170 | downloadType = event.target.value;
171 | updateDatasets();
172 | updateURL();
173 | }
174 |
175 | async function init() {
176 | initChart();
177 |
178 | let response = await fetch("./data/refs.json");
179 | let json = await response.json();
180 | json.forEach(ref => refs.add(ref));
181 | let refsElement = document.getElementById("refs");
182 |
183 | for (let ref of refs.keys()) {
184 | let option = document.createElement("option");
185 | option.value = ref;
186 | refsElement.append(option);
187 | }
188 |
189 | let refElement = document.getElementById("ref");
190 | let intervalSelectElement = document.getElementById("interval-select");
191 | let granularitySelectElement = document.getElementById("granularity");
192 | let downloadTypeElement = document.getElementById("downloadType");
193 | let params = new URLSearchParams(window.location.hash.substring(1));
194 |
195 | refElement.value = params.has("ref") ? params.get("ref") : refsElement.childNodes[0].value;
196 | if (params.has("interval")) {
197 | intervalSelectElement.value = params.get("interval");
198 | }
199 | interval = intervalSelectElement.value;
200 | if (params.has("granularity")) {
201 | granularitySelectElement.value = params.get("granularity");
202 | }
203 | granularity = parseInt(granularitySelectElement.value);
204 | if (params.has("downloadType")) {
205 | downloadTypeElement.value = params.get("downloadType");
206 | }
207 | downloadType = downloadTypeElement.value;
208 |
209 | refElement.addEventListener("change", refHandler);
210 | intervalSelectElement.addEventListener("change", intervalHandler);
211 | granularitySelectElement.addEventListener("change", granularityHandler);
212 | downloadTypeElement.addEventListener("change", downloadTypeHandler);
213 |
214 | await refHandler({target: {value: refElement.value}});
215 | intervalSelectElement.dispatchEvent(new Event("change"));
216 | downloadTypeElement.dispatchEvent(new Event("change"));
217 | }
218 |
219 | window.addEventListener("DOMContentLoaded", init);
220 |
--------------------------------------------------------------------------------
/web/style.css:
--------------------------------------------------------------------------------
1 | html {
2 | color-scheme: light dark;
3 | }
4 | html, body {
5 | height: 100%;
6 | margin: 0;
7 | padding: 0;
8 | }
9 | body {
10 | display: flex;
11 | flex-flow: column nowrap;
12 | }
13 | main {
14 | display: flex;
15 | flex: 0 1 100%;
16 | align-items: center;
17 | justify-content: center;
18 | }
19 | main canvas {
20 | max-width: 100%;
21 | max-height: 100%;
22 | }
23 |
24 | header,
25 | footer {
26 | flex: 0 0 auto;
27 | background: rgba(100, 100, 100, 0.1);
28 | padding: 1rem;
29 | }
30 |
--------------------------------------------------------------------------------