mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Add UI and graphs.
Add CORS. Minor bug fixes.
This commit is contained in:
parent
d75da7681b
commit
a2095a6f79
20
README.md
20
README.md
@ -1,15 +1,27 @@
|
||||
# wakapi
|
||||
|
||||
## Usage
|
||||
* Create an empty MySQL database
|
||||
* Clone repository
|
||||
* Copy `.env.example` to `.env` and set config parameters
|
||||
* Copy `.env.example` to `.env` and set database credentials
|
||||
* Install dependencies: `go get -d ./...`
|
||||
* Set target port in `config.ini`
|
||||
* Run server: `go run *.go`
|
||||
* Edit your local `~/.wakatime.cfg` file and add `api_url = https://your.server:someport/api/heartbeat`
|
||||
|
||||
**First run** (create user account): When running the server for the very first time, the database gets populated. Afterwards you have to create yourself a user account. Until proper user sign up and login is implemented, this is done via SQL, like this.
|
||||
* `mysql -u yourusername -p -H your.hostname`
|
||||
* `USE yourdatabasename;`
|
||||
* `INSERT INTO users (id, api_key) VALUES ('your_cool_nickname', '728f084c-85e0-41de-aa2a-b6cc871200c1');` (the latter value is your api key from `~/.wakatime.cfg`)
|
||||
|
||||
## Todo
|
||||
* Persisted (for performance)
|
||||
* Persisted summaries / aggregations (for performance)
|
||||
* User sign up and log in
|
||||
* UI / Graphs
|
||||
* Additional endpoints for retrieving statistics data
|
||||
* Unit tests
|
||||
|
||||
## Important note
|
||||
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**
|
||||
|
||||
## License
|
||||
* GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
11
main.go
11
main.go
@ -12,6 +12,7 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/rs/cors"
|
||||
ini "gopkg.in/ini.v1"
|
||||
|
||||
"github.com/n1try/wakapi/middlewares"
|
||||
@ -85,6 +86,11 @@ func main() {
|
||||
|
||||
// Middlewares
|
||||
authenticate := &middlewares.AuthenticateMiddleware{UserSrvc: userSrvc}
|
||||
cors := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
Debug: false,
|
||||
})
|
||||
|
||||
// Setup Routing
|
||||
router := mux.NewRouter()
|
||||
@ -98,10 +104,13 @@ func main() {
|
||||
aggreagations.Methods("GET").HandlerFunc(summaryHandler.Get)
|
||||
|
||||
// Sub-Routes Setup
|
||||
router.PathPrefix("/api").Handler(negroni.Classic().With(
|
||||
router.PathPrefix("/api").Handler(negroni.Classic().
|
||||
With(cors).
|
||||
With(
|
||||
negroni.HandlerFunc(authenticate.Handle),
|
||||
negroni.Wrap(apiRouter),
|
||||
))
|
||||
router.PathPrefix("/").Handler(negroni.Classic().With(negroni.Wrap(http.FileServer(http.Dir("./static")))))
|
||||
|
||||
// Listen HTTP
|
||||
portString := "127.0.0.1:" + strconv.Itoa(config.Port)
|
||||
|
243
static/index.html
Normal file
243
static/index.html
Normal file
@ -0,0 +1,243 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Coding Stats</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<link rel="icon" data-emoji="📊" type="image/png">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
width: 60%;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'header header header'
|
||||
'sec1 sec1 sec2'
|
||||
'sec1 sec1 sec3'
|
||||
'sec1 sec1 sec4'
|
||||
'footer footer footer';
|
||||
grid-gap: 10px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.projects-container {
|
||||
grid-area: sec1
|
||||
}
|
||||
|
||||
.os-container {
|
||||
grid-area: sec2
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
grid-area: sec3
|
||||
}
|
||||
|
||||
.language-container {
|
||||
grid-area: sec4
|
||||
}
|
||||
|
||||
.header-container {
|
||||
grid-area: header
|
||||
}
|
||||
|
||||
.apikey-input {
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Statistics</h1>
|
||||
<div class="input-container" id="input-container">
|
||||
<label for="apikey">API Key: </label>
|
||||
<input type="text" class="apikey-input" id="apikey-input" name="apikey" placeholder="Enter your API key here">
|
||||
<button onclick="load('day')">Day</button>
|
||||
<button onclick="load('week')">Week</button>
|
||||
<button onclick="load('month')">Month</button>
|
||||
<button onclick="load('year')">Year</button>
|
||||
</div>
|
||||
<div class="grid-container" id="grid-container">
|
||||
<div class="header-container">
|
||||
<p>
|
||||
<strong>Total:</strong> <span id="total-span"></span><br>
|
||||
</p>
|
||||
</div>
|
||||
<div class="projects-container" id="projects-container">
|
||||
<canvas id="chart-projects"></canvas>
|
||||
</div>
|
||||
<div class="os-container" id="os-container">
|
||||
<canvas id="chart-os"></canvas>
|
||||
</div>
|
||||
<div class="editor-container" id="editor-container">
|
||||
<canvas id="chart-editor"></canvas>
|
||||
</div>
|
||||
<div class="language-container" id="language-container">
|
||||
<canvas id="chart-language"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
String.prototype.toHHMMSS = function () {
|
||||
var sec_num = parseInt(this, 10);
|
||||
var hours = Math.floor(sec_num / 3600);
|
||||
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
|
||||
var seconds = sec_num - (hours * 3600) - (minutes * 60);
|
||||
|
||||
if (hours < 10) { hours = "0" + hours; }
|
||||
if (minutes < 10) { minutes = "0" + minutes; }
|
||||
if (seconds < 10) { seconds = "0" + seconds; }
|
||||
return hours + ':' + minutes + ':' + seconds;
|
||||
}
|
||||
|
||||
function load(interval) {
|
||||
let apiKey = document.getElementById('apikey-input').value
|
||||
fetch(`/api/summary?interval=${interval}&live=true`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Basic ${btoa(apiKey)}`
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 401) {
|
||||
console.error('Unauthorized')
|
||||
alert('Unauthorized')
|
||||
}
|
||||
return res
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((json) => draw(json))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
|
||||
function draw(data) {
|
||||
let titleOptions = {
|
||||
display: true,
|
||||
fontSize: 16
|
||||
}
|
||||
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
callbacks: {
|
||||
label: (item) => {
|
||||
let idx = type === 'pie' ? item.index : item.datasetIndex
|
||||
let d = data[key][idx]
|
||||
return `${d.key}: ${d.total.toString().toHHMMSS()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let projectChart = new Chart(document.getElementById("chart-projects").getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: data.projects
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
data: [parseInt(p.total)],
|
||||
backgroundColor: getRandomColor(p.key)
|
||||
}
|
||||
})
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: 'Projects' }),
|
||||
tooltips: getTooltipOptions('projects', 'bar'),
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let osChart = new Chart(document.getElementById("chart-os").getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data.operating_systems.map(p => parseInt(p.total)),
|
||||
backgroundColor: data.operating_systems.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: data.operating_systems.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: 'Operating Systems' }),
|
||||
tooltips: getTooltipOptions('operating_systems', 'pie')
|
||||
}
|
||||
});
|
||||
|
||||
let editorChart = new Chart(document.getElementById("chart-editor").getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data.editors.map(p => parseInt(p.total)),
|
||||
backgroundColor: data.editors.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: data.editors.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: 'Editors' }),
|
||||
tooltips: getTooltipOptions('editors', 'pie')
|
||||
}
|
||||
});
|
||||
|
||||
let languageChart = new Chart(document.getElementById("chart-language").getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data.languages
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: data.languages.map(p => getRandomColor(p.key))
|
||||
}],
|
||||
labels: data.languages
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
title: Object.assign(titleOptions, { text: 'Languages' }),
|
||||
tooltips: getTooltipOptions('languages', 'pie')
|
||||
}
|
||||
});
|
||||
|
||||
getTotal(data.operating_systems)
|
||||
document.getElementById('grid-container').style.visibility = 'visible';
|
||||
}
|
||||
|
||||
function getRandomColor(seed) {
|
||||
seed = seed ? seed : '1234567';
|
||||
Math.seedrandom(seed);
|
||||
var letters = '0123456789ABCDEF'.split('');
|
||||
var color = '#';
|
||||
for (var i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function getTotal(data) {
|
||||
let total = data.reduce((acc, d) => acc + d.total, 0)
|
||||
document.getElementById("total-span").innerText = total.toString().toHHMMSS()
|
||||
}
|
||||
|
||||
// https://koddsson.com/posts/emoji-favicon/
|
||||
const favicon = document.querySelector("link[rel=icon]");
|
||||
if (favicon) {
|
||||
const emoji = favicon.getAttribute("data-emoji");
|
||||
if (emoji) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.height = 64;
|
||||
canvas.width = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = "64px serif";
|
||||
ctx.fillText(emoji, 0, 64);
|
||||
favicon.href = canvas.toDataURL();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user