Initial commit — energy dashboard frontend (TimescaleDB + Vue/Chart.js)
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Energy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1438
frontend/package-lock.json
generated
Normal file
1438
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "energy-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
14
frontend/src/App.vue
Normal file
14
frontend/src/App.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const { authenticated } = useAuth()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<nav v-if="authenticated" class="navbar">
|
||||
<div class="nav-brand">Energy</div>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
72
frontend/src/api/client.ts
Normal file
72
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
const BASE = '/energy/api'
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
if (window.location.pathname !== '/energy/login') {
|
||||
window.location.href = '/energy/login'
|
||||
}
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(body.error || res.statusText)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface EnergyBar {
|
||||
time: string
|
||||
buy: number
|
||||
sell: number
|
||||
produce: number
|
||||
consume: number
|
||||
}
|
||||
|
||||
export interface PowerPoint {
|
||||
time: string
|
||||
pv: number
|
||||
house: number
|
||||
barn: number
|
||||
}
|
||||
|
||||
export interface BatteryPoint {
|
||||
time: string
|
||||
level: number
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login(username: string, password: string) {
|
||||
return request<{ status: string }>('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
},
|
||||
|
||||
logout() {
|
||||
return request<{ status: string }>('/logout', { method: 'POST' })
|
||||
},
|
||||
|
||||
checkAuth() {
|
||||
return request<{ status: string }>('/auth/check')
|
||||
},
|
||||
|
||||
getBars(period: 'day' | 'week' | 'month', count: number) {
|
||||
return request<{ bars: EnergyBar[] }>(`/bars?period=${period}&count=${count}`)
|
||||
},
|
||||
|
||||
getPower(range: string) {
|
||||
return request<{ points: PowerPoint[] }>(`/power?range=${range}`)
|
||||
},
|
||||
|
||||
getBattery(range: string) {
|
||||
return request<{ points: BatteryPoint[] }>(`/battery?range=${range}`)
|
||||
},
|
||||
}
|
||||
27
frontend/src/composables/useAuth.ts
Normal file
27
frontend/src/composables/useAuth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
const authenticated = ref(false)
|
||||
|
||||
export function useAuth() {
|
||||
async function login(username: string, password: string) {
|
||||
await api.login(username, password)
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.logout()
|
||||
authenticated.value = false
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
await api.checkAuth()
|
||||
authenticated.value = true
|
||||
} catch {
|
||||
authenticated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated, login, logout, checkAuth }
|
||||
}
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
46
frontend/src/router/index.ts
Normal file
46
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/energy'),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/EnergyLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
redirect: '/live',
|
||||
children: [
|
||||
{
|
||||
path: 'live',
|
||||
name: 'live',
|
||||
component: () => import('@/views/EnergyView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'statistics',
|
||||
name: 'statistics',
|
||||
component: () => import('@/views/StatisticsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (!to.meta.requiresAuth) return true
|
||||
|
||||
const { authenticated, checkAuth } = useAuth()
|
||||
if (!authenticated.value) {
|
||||
await checkAuth()
|
||||
}
|
||||
if (!authenticated.value) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
116
frontend/src/style.css
Normal file
116
frontend/src/style.css
Normal file
@@ -0,0 +1,116 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--bg-card: #1a1d27;
|
||||
--bg-input: #252830;
|
||||
--border: #2e3140;
|
||||
--text: #e4e6eb;
|
||||
--text-muted: #8b8fa3;
|
||||
--accent: #4f8ff7;
|
||||
--accent-hover: #3a7ae0;
|
||||
--green: #4caf50;
|
||||
--orange: #ff9800;
|
||||
--red: #f44336;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
height: 56px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
input {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
132
frontend/src/views/EnergyLayout.vue
Normal file
132
frontend/src/views/EnergyLayout.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { logout } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const pages = [
|
||||
{ to: '/live', label: 'Live' },
|
||||
{ to: '/statistics', label: 'Statistics' },
|
||||
]
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<RouterLink
|
||||
v-for="p in pages"
|
||||
:key="p.to"
|
||||
:to="p.to"
|
||||
class="nav-link"
|
||||
>{{ p.label }}</RouterLink>
|
||||
|
||||
<div class="sidebar-spacer" />
|
||||
<button class="nav-logout" @click="handleLogout">Logout</button>
|
||||
</aside>
|
||||
|
||||
<main class="page">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 0;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.nav-link.router-link-active {
|
||||
color: var(--accent);
|
||||
background: rgba(79, 143, 247, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-logout {
|
||||
display: block;
|
||||
width: calc(100% - 1rem);
|
||||
margin: 0 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.nav-logout:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-spacer { display: none; }
|
||||
|
||||
.nav-logout {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
318
frontend/src/views/EnergyView.vue
Normal file
318
frontend/src/views/EnergyView.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
Chart,
|
||||
LineController, LineElement, PointElement,
|
||||
CategoryScale, LinearScale,
|
||||
Tooltip, Legend, Filler,
|
||||
} from 'chart.js'
|
||||
import { api, type PowerPoint, type BatteryPoint } from '@/api/client'
|
||||
|
||||
Chart.register(
|
||||
LineController, LineElement, PointElement,
|
||||
CategoryScale, LinearScale,
|
||||
Tooltip, Legend, Filler,
|
||||
)
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const selectedRange = ref<'1h' | '6h' | '24h' | '7d'>('24h')
|
||||
const ranges = ['1h', '6h', '24h', '7d'] as const
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const powerPts = ref<PowerPoint[]>([])
|
||||
const battPts = ref<BatteryPoint[]>([])
|
||||
|
||||
// ── Chart instances ───────────────────────────────────────────────────────────
|
||||
|
||||
const powerCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const battCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let powerChart: Chart | null = null
|
||||
let battChart: Chart | null = null
|
||||
|
||||
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const COLORS = {
|
||||
pv: { bg: 'rgba(255,213,79,0.15)', border: '#ffd54f' },
|
||||
house: { bg: 'rgba(66,165,245,0.15)', border: '#42a5f5' },
|
||||
barn: { bg: 'rgba(239,83,80,0.15)', border: '#ef5350' },
|
||||
battery: { bg: 'rgba(66,165,245,0.2)', border: '#42a5f5' },
|
||||
}
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
color: '#8b8fa3',
|
||||
borderColor: '#2e3140',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
}
|
||||
|
||||
// ── Formatting ────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(t: string, range: string) {
|
||||
const d = new Date(t)
|
||||
if (range === '7d') {
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) +
|
||||
' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// ── Chart builders ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildPowerChart(canvas: HTMLCanvasElement, pts: PowerPoint[], range: string): Chart {
|
||||
const labels = pts.map(p => fmtTime(p.time, range))
|
||||
return new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'PV Production', data: pts.map(p => p.pv), borderColor: COLORS.pv.border, backgroundColor: COLORS.pv.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 },
|
||||
{ label: 'House', data: pts.map(p => p.house), borderColor: COLORS.house.border, backgroundColor: COLORS.house.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 },
|
||||
{ label: 'Barn', data: pts.map(p => p.barn), borderColor: COLORS.barn.border, backgroundColor: COLORS.barn.bg, borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3' } },
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: { label: (ctx: any) => ` ${ctx.dataset.label}: ${Math.round(ctx.parsed.y)} W` },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8fa3', maxTicksLimit: 12 }, grid: { color: '#2e3140' } },
|
||||
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2e3140' }, title: { display: true, text: 'W', color: '#8b8fa3' } },
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
function buildBattChart(canvas: HTMLCanvasElement, pts: BatteryPoint[], range: string): Chart {
|
||||
const labels = pts.map(p => fmtTime(p.time, range))
|
||||
return new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Battery',
|
||||
data: pts.map(p => p.level),
|
||||
borderColor: COLORS.battery.border,
|
||||
backgroundColor: COLORS.battery.bg,
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: { label: (ctx: any) => ` Battery: ${ctx.parsed.y.toFixed(1)} %` },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8fa3', maxTicksLimit: 12 }, grid: { color: '#2e3140' } },
|
||||
y: { min: 0, max: 100, ticks: { color: '#8b8fa3' }, grid: { color: '#2e3140' }, title: { display: true, text: '%', color: '#8b8fa3' } },
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadTimeSeries() {
|
||||
const range = selectedRange.value
|
||||
const [pw, bt] = await Promise.all([
|
||||
api.getPower(range),
|
||||
api.getBattery(range),
|
||||
])
|
||||
powerPts.value = pw.points
|
||||
battPts.value = bt.points
|
||||
}
|
||||
|
||||
function latestBattery(): number | null {
|
||||
const pts = battPts.value
|
||||
if (!pts.length) return null
|
||||
return Math.round(pts[pts.length - 1].level)
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshLive() {
|
||||
await loadTimeSeries()
|
||||
await nextTick()
|
||||
powerChart?.destroy(); powerChart = null
|
||||
battChart?.destroy(); battChart = null
|
||||
if (powerCanvas.value && powerPts.value.length) powerChart = buildPowerChart(powerCanvas.value, powerPts.value, selectedRange.value)
|
||||
if (battCanvas.value && battPts.value.length) battChart = buildBattChart(battCanvas.value, battPts.value, selectedRange.value)
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let liveTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await loadTimeSeries()
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
if (powerCanvas.value && powerPts.value.length) powerChart = buildPowerChart(powerCanvas.value, powerPts.value, selectedRange.value)
|
||||
if (battCanvas.value && battPts.value.length) battChart = buildBattChart(battCanvas.value, battPts.value, selectedRange.value)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
loading.value = false
|
||||
}
|
||||
liveTimer = setInterval(refreshLive, 30 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (liveTimer) clearInterval(liveTimer)
|
||||
powerChart?.destroy()
|
||||
battChart?.destroy()
|
||||
})
|
||||
|
||||
watch(selectedRange, refreshLive)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="loading">Loading…</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else class="live">
|
||||
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Live</h2>
|
||||
<div class="range-tabs">
|
||||
<button
|
||||
v-for="r in ranges"
|
||||
:key="r"
|
||||
:class="['range-btn', { active: selectedRange === r }]"
|
||||
@click="selectedRange = r"
|
||||
>{{ r }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-stack">
|
||||
<div class="card chart-card">
|
||||
<div class="chart-header">Power (W)</div>
|
||||
<div class="chart-wrap"><canvas ref="powerCanvas" /></div>
|
||||
</div>
|
||||
<div class="card chart-card">
|
||||
<div class="chart-header">
|
||||
Battery
|
||||
<span v-if="latestBattery() !== null" class="badge">{{ latestBattery() }}%</span>
|
||||
</div>
|
||||
<div class="chart-wrap chart-wrap-sm"><canvas ref="battCanvas" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.live {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.charts-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.chart-wrap-sm {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.range-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.range-btn {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.range-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.range-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
63
frontend/src/views/LoginView.vue
Normal file
63
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { login } = useAuth()
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await login(username.value, password.value)
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.push(redirect)
|
||||
} catch (e: any) {
|
||||
error.value = e.message === 'Unauthorized' ? 'Invalid credentials' : e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<form class="login-card card" @submit.prevent="handleLogin">
|
||||
<h1>Energy</h1>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<input v-model="username" type="text" placeholder="Username" autocomplete="username" required />
|
||||
<input v-model="password" type="password" placeholder="Password" autocomplete="current-password" required />
|
||||
<button class="btn" type="submit" :disabled="loading">
|
||||
{{ loading ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
237
frontend/src/views/StatisticsView.vue
Normal file
237
frontend/src/views/StatisticsView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import {
|
||||
Chart,
|
||||
BarController, BarElement,
|
||||
CategoryScale, LinearScale,
|
||||
Tooltip, Legend,
|
||||
type ChartDataset,
|
||||
} from 'chart.js'
|
||||
import { api, type EnergyBar } from '@/api/client'
|
||||
|
||||
Chart.register(
|
||||
BarController, BarElement,
|
||||
CategoryScale, LinearScale,
|
||||
Tooltip, Legend,
|
||||
)
|
||||
|
||||
// ── Selectors ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Period = 'day' | 'week' | 'month'
|
||||
|
||||
const periods: { value: Period; label: string }[] = [
|
||||
{ value: 'day', label: 'Day' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
]
|
||||
|
||||
const countOptions: Record<Period, number[]> = {
|
||||
day: [7, 14, 30, 90],
|
||||
week: [4, 8, 13, 26],
|
||||
month: [3, 6, 12, 24],
|
||||
}
|
||||
|
||||
const selectedPeriod = ref<Period>('day')
|
||||
const selectedCount = ref(30)
|
||||
|
||||
function selectPeriod(p: Period) {
|
||||
selectedPeriod.value = p
|
||||
selectedCount.value = countOptions[p][2] // third option as default
|
||||
refresh()
|
||||
}
|
||||
|
||||
function selectCount(c: number) {
|
||||
selectedCount.value = c
|
||||
refresh()
|
||||
}
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const bars = ref<EnergyBar[]>([])
|
||||
|
||||
// ── Chart ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
let chart: Chart | null = null
|
||||
|
||||
const COLORS = {
|
||||
buy: { bg: 'rgba(239,83,80,0.8)', border: '#ef5350' },
|
||||
sell: { bg: 'rgba(66,165,245,0.8)', border: '#42a5f5' },
|
||||
produce: { bg: 'rgba(102,187,106,0.8)', border: '#66bb6a' },
|
||||
consume: { bg: 'rgba(255,167,38,0.8)', border: '#ffa726' },
|
||||
}
|
||||
|
||||
function labelFor(t: string): string {
|
||||
const d = new Date(t)
|
||||
switch (selectedPeriod.value) {
|
||||
case 'month': return d.toLocaleDateString('en-GB', { month: 'short', year: '2-digit' })
|
||||
case 'week': return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||||
default: return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
}
|
||||
|
||||
function datasets(data: EnergyBar[]): ChartDataset<'bar'>[] {
|
||||
return [
|
||||
{ label: 'Buy', data: data.map(b => b.buy), backgroundColor: COLORS.buy.bg, borderColor: COLORS.buy.border, borderWidth: 1 },
|
||||
{ label: 'Sell', data: data.map(b => b.sell), backgroundColor: COLORS.sell.bg, borderColor: COLORS.sell.border, borderWidth: 1 },
|
||||
{ label: 'Produce', data: data.map(b => b.produce), backgroundColor: COLORS.produce.bg, borderColor: COLORS.produce.border, borderWidth: 1 },
|
||||
{ label: 'Consume', data: data.map(b => b.consume), backgroundColor: COLORS.consume.bg, borderColor: COLORS.consume.border, borderWidth: 1 },
|
||||
]
|
||||
}
|
||||
|
||||
function buildChart() {
|
||||
chart?.destroy()
|
||||
chart = null
|
||||
if (!canvas.value || !bars.value.length) return
|
||||
chart = new Chart(canvas.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: bars.value.map(b => labelFor(b.time)),
|
||||
datasets: datasets(bars.value),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3' } },
|
||||
tooltip: { callbacks: { label: (ctx: any) => ` ${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)} kWh` } },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2e3140' } },
|
||||
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2e3140' }, title: { display: true, text: 'kWh', color: '#8b8fa3' } },
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await api.getBars(selectedPeriod.value, selectedCount.value)
|
||||
bars.value = res.bars
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
buildChart()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
timer = setInterval(refresh, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
chart?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="statistics">
|
||||
|
||||
<div class="controls">
|
||||
<div class="tab-group">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="['tab-btn', { active: selectedPeriod === p.value }]"
|
||||
@click="selectPeriod(p.value)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-group">
|
||||
<button
|
||||
v-for="c in countOptions[selectedPeriod]"
|
||||
:key="c"
|
||||
:class="['tab-btn', { active: selectedCount === c }]"
|
||||
@click="selectCount(c)"
|
||||
>{{ c }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading…</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else class="card chart-card">
|
||||
<div class="chart-header">Energy (kWh)</div>
|
||||
<div class="chart-wrap"><canvas ref="canvas" /></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.statistics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 340px;
|
||||
}
|
||||
</style>
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.ts","./src/api/client.ts","./src/composables/useAuth.ts","./src/router/index.ts","./src/App.vue","./src/views/DashboardView.vue","./src/views/LoginView.vue"],"version":"5.7.3"}
|
||||
28
frontend/vite.config.ts
Normal file
28
frontend/vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
base: '/energy/',
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user