This commit is contained in:
2020-09-20 17:28:09 +02:00
parent ef2a02577e
commit 142685b306
14 changed files with 672 additions and 287 deletions

View File

@@ -1,106 +1,40 @@
<script>
import DateInput from './components/DateInput.svelte'
// import Chart from './components/Chart.svelte'
import Router, { link } from 'svelte-spa-router'
import dj from 'dayjs'
import RelativeTime from 'dayjs/plugin/relativeTime'
import Duration from 'dayjs/plugin/duration'
import Dev from './components/Dev.svelte'
import RangeChooser from './components/RangeChooser.svelte'
import Dashboard from './pages/Dashboard.svelte'
import Limits from './pages/Limits.svelte'
import { onMount } from 'svelte'
import dayjs from 'dayjs'
dj.extend(Duration)
dj.extend(RelativeTime)
import { data, countInGroup } from './lib'
import { env } from 'process'
const routes = {
'/': Dashboard,
let top = 20
let full = 50
let loading = true
let init = false
let counted
let start
let end
let topData = {
labels: [],
data: [],
'/limits': Limits,
}
async function calculate() {
try {
loading = true
const logs = await data({
start,
end: dayjs(end).endOf('day'),
})
counted = countInGroup(logs)
} finally {
loading = false
}
// const onlyTop = counted.slice(0, top)
// console.log(onlyTop)
// topData = {
// labels: onlyTop.map((n) => n[0]),
// data: onlyTop.map((n) => n[1] / 1000),
// }
}
$: if (init) {
start, end
calculate()
}
onMount(() => {
setTimeout(() => {
init = true
}, 25)
})
</script>
<style>
.top {
main {
padding: 1em;
margin: auto;
width: 100%;
max-width: 50em;
}
.link {
margin-left: 2em;
}
td,
th {
padding: 0.25em;
}
h3 {
margin-right: 1em;
}
</style>
<Dev />
<div class="top rounded">
<h1 class="text-6xl mb-4">Ora</h1>
<div class="flex justify-end items-center mb-2">
<h3>Time Range</h3>
<RangeChooser bind:start bind:end />
<main>
<div class="mb-8">
<a href="../options/index.html"><button class="btn">Options</button></a>
<a use:link={'/'}><button class="btn">Dashboard</button></a>
<a use:link={'/limits'}><button class="btn">Limits</button></a>
</div>
{#if loading}
<div class="loading loading-lg" />
{:else if counted}
<h2 class="text-2xl">Top {top}</h2>
<b>Chart</b>
<h2 class="text-2xl mt-4">Top {full}</h2>
<table class="table">
<tr>
<th>Time Spent</th>
<th>Host</th>
</tr>
{#each counted.slice(0, 100) as { host, total, human }}
<tr>
<td>{human}</td>
<td class="link"><a href={'https://' + host}>{host}</a></td>
</tr>
{/each}
</table>
{/if}
</div>
<Router {routes} />
</main>

View File

@@ -0,0 +1,111 @@
<script>
import * as d3 from 'd3'
import { map, min, max } from 'lodash'
import { onMount } from 'svelte'
let wrapper
export let data = [
// { lang: 'ts', popularity: 10 },
// { lang: 'js', popularity: 7 },
// { lang: 'py', popularity: 9 },
// { lang: 'rs', popularity: 8 },
// { year: 2018, value: 8 },
// { year: 2019, value: 9 },
// { year: 2020, value: 3 },
{ cat: 'Phillip', value: 10 },
{ cat: 'Rita', value: 12 },
{ cat: 'Tom', value: 20 },
{ cat: 'Oscar', value: 19 },
{ cat: 'Lulu', value: 8 },
{ cat: 'Keko', value: 14 },
{ cat: 'Lena', value: 9 },
]
onMount(async () => {
// Dynamic left padding depending on the labels
const longestKey = max(map(data, (d) => d.name.length))
const mt = Math.min(longestKey * 6, 120)
const margin = { left: mt, top: 50, bottom: 50, right: 50 }
const styles = window.getComputedStyle(wrapper)
const barHeight = 20
const width = parseInt(styles.width)
const height = Math.ceil(data.length * 1.5 * barHeight)
const svg = d3.select(wrapper).attr('viewBox', [0, 0, width, height])
const yAxis = (g) =>
g.attr('transform', `translate(${margin.left},0)`).call(
d3
.axisLeft(y)
.tickFormat((i) => data[i].name)
.tickSizeOuter(0)
)
const xAxis = (g) =>
g
.attr('transform', `translate(0,${margin.top})`)
.call(d3.axisTop(x).ticks(width / 100, 's'))
.call((g) => g.select('.domain').remove())
const y = d3
.scaleBand()
.domain(d3.range(data.length))
.rangeRound([margin.bottom, height - margin.top])
.padding(0.2)
const x = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value)])
.range([margin.left, width - margin.right])
const format = x.tickFormat(20, 's')
// Bars
svg
.append('g')
.attr('fill', 'steelblue')
.selectAll('rect')
.data(data)
.join('rect')
.attr('x', x(0))
.attr('y', (d, i) => y(i))
.attr('width', (d) => x(d.value) - x(0))
.attr('height', y.bandwidth())
svg
.append('g')
.attr('fill', 'white')
.attr('text-anchor', 'end')
.attr('font-family', 'sans-serif')
.attr('font-size', 12)
.selectAll('text')
.data(data)
.join('text')
.attr('x', (d) => x(d.value))
.attr('y', (d, i) => y(i) + y.bandwidth() / 2)
.attr('dy', '0.35em')
.attr('dx', -4)
.text((d) => d.human)
.call((text) =>
text
.filter((d) => x(d.value) - x(0) < d.human.length * 7) // short bars
.attr('dx', +4)
.attr('fill', 'black')
.attr('text-anchor', 'start')
)
svg.append('g').call(xAxis)
svg.append('g').call(yAxis)
})
</script>
<style>
svg {
width: 100%;
/* height: 25em; */
}
</style>
<svg bind:this={wrapper} preserveAspectRatio="xMidYMid meet" />

View File

@@ -1,115 +0,0 @@
<script>
import * as am4core from '@amcharts/amcharts4/core'
import * as am4charts from '@amcharts/amcharts4/charts'
import am4themes_frozen from '@amcharts/amcharts4/themes/frozen'
import am4themes_animated from '@amcharts/amcharts4/themes/animated'
import { onMount } from 'svelte'
/* Chart code */
// Themes begin
am4core.useTheme(am4themes_frozen)
am4core.useTheme(am4themes_animated)
let el
let chart
export let data = [
{
network: 'Facebook',
MAU: 2255250000,
},
{
network: 'Google+',
MAU: 430000000,
},
{
network: 'Instagram',
MAU: 1000000000,
},
{
network: 'Pinterest',
MAU: 246500000,
},
{
network: 'Reddit',
MAU: 355000000,
},
{
network: 'TikTok',
MAU: 500000000,
},
{
network: 'Tumblr',
MAU: 624000000,
},
{
network: 'Twitter',
MAU: 329500000,
},
{
network: 'WeChat',
MAU: 1000000000,
},
{
network: 'Weibo',
MAU: 431000000,
},
{
network: 'Whatsapp',
MAU: 1433333333,
},
{
network: 'YouTube',
MAU: 1900000000,
},
]
$: if (chart) {
chart.data = data
}
onMount(() => {
chart = am4core.create(el, am4charts.XYChart)
let categoryAxis = chart.yAxes.push(new am4charts.CategoryAxis())
categoryAxis.renderer.grid.template.location = 0
categoryAxis.dataFields.category = 'host'
categoryAxis.renderer.minGridDistance = 1
categoryAxis.renderer.inversed = true
categoryAxis.renderer.grid.template.disabled = true
let valueAxis = chart.xAxes.push(new am4charts.ValueAxis())
valueAxis.min = 0
let series = chart.series.push(new am4charts.ColumnSeries())
series.dataFields.categoryY = 'host'
series.dataFields.valueX = 'total'
series.columns.template.strokeOpacity = 0
series.columns.template.column.cornerRadiusBottomRight = 5
series.columns.template.column.cornerRadiusTopRight = 5
let labelBullet = series.bullets.push(new am4charts.LabelBullet())
labelBullet.label.horizontalCenter = 'left'
labelBullet.label.dx = 10
labelBullet.label.text = '{values.valueX.workingValue}'
// labelBullet.label.text = "{values.valueX.workingValue.formatNumber('#.0as')}"
labelBullet.locationX = 1
// as by default columns of the same series are of the same color, we add adapter which takes colors from chart.colors color set
series.columns.template.adapter.add('fill', function (fill, target) {
return chart.colors.getIndex(target.dataItem.index)
})
categoryAxis.sortBySeries = series
chart.data = data
})
</script>
<style>
div {
width: 100%;
height: 32em;
}
</style>
<div bind:this={el}>chart</div>

View File

@@ -1,68 +0,0 @@
<script>
import { onMount } from 'svelte'
import Chart from 'chart.js'
import palette from 'google-palette'
Chart.defaults.global.legend.display = false
export let type = 'horizontalBar'
export let data = {
labels: [],
data: [],
}
export let options = {
scales: {
xAxes: [
{
// type: 'logarithmic',
ticks: {
beginAtZero: true,
},
},
],
},
}
let ctx
let mounted
function draw() {
const backgroundColor = palette('rainbow', data.data.length).map((color) => '#' + color + '88')
new Chart(ctx, {
type,
options,
data: {
labels: data.labels,
datasets: [
{
data: data.data,
// backgroundColor: backgroundColor,
backgroundColor: '#dddddd',
borderColor: '#000000',
borderWidth: 1,
},
],
},
})
}
$: if (mounted) {
data
draw()
}
onMount(() => {
mounted = true
})
</script>
<style>
canvas {
width: 100%;
height: 20em;
}
</style>
<canvas bind:this={ctx} />

View File

@@ -3,7 +3,7 @@
import day from 'dayjs'
import { range, random } from 'lodash'
import { insertLog, normalizeTimestamp } from '../../shared/db'
import { insertLog, normalizeTimestamp, clear as clearDB } from '../../shared/db'
let loading = false
@@ -30,14 +30,22 @@
async function clear() {
try {
loading = true
await Logs.remove({}, { multi: true })
await clearDB()
} finally {
loading = false
}
}
</script>
<style>
div {
position: absolute;
top: 0;
right: 0;
}
</style>
<div class="p-2">
<button class="btn" class:loading disabled={loading} on:click={fill}>Add Random Data</button>
<button class="btn btn-error" class:loading disabled={loading} on:click={clear}>Delete data</button>
<button class="btn btn-sm" class:loading disabled={loading} on:click={fill}>Add Random Data</button>
<button class="btn btn-sm btn-error" class:loading disabled={loading} on:click={clear}>Delete data</button>
</div>

View File

@@ -0,0 +1,10 @@
<script>
import dj from 'dayjs'
import { Logs } from '../../shared/db.js'
export let rules = []
</script>
{#each rules as rule}
<div>{dj.duration(...rule.limit).humanize()} / {dj.duration(...rule.every).humanize()}</div>
{/each}

View File

@@ -0,0 +1,80 @@
<script>
import { createEventDispatcher } from 'svelte'
import { cloneDeep } from 'lodash'
import { Limits } from '../../shared/db'
const dispatch = createEventDispatcher()
const init = { limit: ['1', 'h'], every: [1, 'd'] }
export let limit = null
$: active = limit !== null
function add() {
limit.rules = [...limit.rules, cloneDeep(init)]
}
function del(i) {
return () => (limit.rules = limit.rules.filter((_, n) => n !== i))
}
function close() {
limit = null
}
async function save() {
await Limits.update({ host: limit.host }, limit, { upsert: true })
dispatch('update')
close()
}
</script>
<div class:active class="modal">
<a on:click={close} class="modal-overlay" aria-label="Close" />
<div class="modal-container">
<div class="modal-header">
<a on:click={close} class="btn btn-clear float-right" aria-label="Close" />
<div class="modal-title h5">{limit?._id ? 'Edit limit' : 'Create new limit'}</div>
</div>
<div class="modal-body">
<div class="content">
{#if limit}
<label class="form-label">
Host <input type="text" class="form-input" placeholder="google.com" bind:value={limit.host} />
</label>
<div class="form-label">Rules</div>
{#each limit.rules as { limit, every }, i}
<div class="input-group mb-3">
<input type="text" class="form-input" placeholder="1" bind:value={limit[0]} />
<select class="form-select" bind:value={limit[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
<span class="input-group-addon">every</span>
<input type="text" class="form-input" bind:value={every[0]} />
<select class="form-select" bind:value={every[1]}>
<option value="m">Minutes</option>
<option value="h">Hours</option>
<option value="d">Days</option>
<option value="w">Weeks</option>
<option value="M">Months</option>
</select>
<button class="btn btn-error input-group-btn" on:click={del(i)}>X</button>
</div>
{/each}
<button class="btn" on:click={add}>Add</button>
{/if}
</div>
</div>
<div class="modal-footer">
<div>
<button on:click={close} class="btn">Cancel</button>
<button on:click={save} class="btn btn-primary">Save</button>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,5 @@
import { each, groupBy, orderBy } from 'lodash'
import dj from 'dayjs'
import RelativeTime from 'dayjs/plugin/relativeTime'
import Duration from 'dayjs/plugin/duration'
dj.extend(Duration)
dj.extend(RelativeTime)
import { Logs } from '../shared/db'
@@ -32,3 +27,9 @@ export function countInGroup(grouped) {
})
return orderBy(counted, 'total', 'desc')
}
export function longPress(node, fn) {
let timeout
node.addEventListener('mousedown', () => (timeout = setTimeout(fn, 500)), false)
node.addEventListener('mouseup', () => clearTimeout(timeout), false)
}

View File

@@ -0,0 +1,73 @@
<script>
import { onMount } from 'svelte'
import dayjs from 'dayjs'
import DateInput from '../components/DateInput.svelte'
import Chart from '../components/Chart.svelte'
import RangeChooser from '../components/RangeChooser.svelte'
import { data, countInGroup } from '../lib'
let top = 15
let full = 50
let loading = true
let init = false
let counted = []
let start
let end
async function calculate() {
try {
loading = true
const logs = await data({
start,
end: dayjs(end).endOf('day'),
})
counted = countInGroup(logs)
} finally {
loading = false
}
}
$: if (init) {
start, end
calculate()
}
$: topData = counted.slice(0, top).map(({ total, host, human }) => ({ value: total, name: host, human }))
onMount(() => {
setTimeout(() => {
init = true
}, 25)
})
</script>
<style>
</style>
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl">Dashboard</h2>
<RangeChooser bind:start bind:end />
</div>
{#if loading}
<div class="loading loading-lg" />
{:else if counted}
<h2 class="text-lg">Top {top}</h2>
<Chart data={topData} />
<h2 class="text-lg mt-4">Top {full}</h2>
<table class="table">
<tr>
<th>Time Spent</th>
<th>Host</th>
</tr>
{#each counted.slice(0, 100) as { host, total, human }}
<tr>
<td>{human}</td>
<td class="link"><a href={'https://' + host}>{host}</a></td>
</tr>
{/each}
</table>
{/if}

View File

@@ -0,0 +1,72 @@
<script>
import { onMount } from 'svelte'
import RulesEditor from '../components/RulesEditor.svelte'
import Rules from '../components/Rules.svelte'
import { Limits } from '../../shared/db.js'
import { longPress } from '../lib'
let limits = null
let limit = null
function create() {
limit = { host: '', rules: [] }
}
function edit(id) {
limit = limits.find((limit) => limit._id === id)
}
async function load() {
limits = await Limits.find()
}
async function del(id) {
await Limits.remove({ _id: id })
await load()
}
onMount(load)
</script>
<style>
td {
vertical-align: top;
}
</style>
<RulesEditor bind:limit on:update={load} />
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl">Limits</h2>
<button class="btn btn-primary" on:click={create}>New Rule</button>
</div>
{#if Array.isArray(limits)}
<table class="table">
<tr>
<th>Host</th>
<th>Rules</th>
<th class="text-right">Actions</th>
</tr>
{#each limits as { host, rules, _id }}
<tr>
<td>{host}</td>
<td>
<Rules {rules} />
</td>
<td class="text-right">
<div class="btn-group">
<button class="btn btn-sm btn-primary" on:click={() => edit(_id)}>Edit</button>
<button class="btn btn-sm btn-error tooltip" data-tooltip="Hold to delete" use:longPress={() => del(_id)}>
Delete
</button>
</div>
</td>
</tr>
{/each}
</table>
{:else}
<div class="loading loading-lg" />
{/if}

View File

@@ -6,6 +6,15 @@ export const Logs = NeDB.create({
autoload: true,
})
export const Limits = NeDB.create({
filename: 'limits.db',
autoload: true,
})
export function clear() {
return Promise.all([Logs.remove({}, { multi: true }), Limits.remove({}, { multi: true })])
}
export function normalizeTimestamp(timestamp) {
// Normalize every dato to 15 minutes
const t = day(timestamp)