Initial commit — energy dashboard frontend (TimescaleDB + Vue/Chart.js)
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Copy to /srv/energy/frontend/.env
|
||||||
|
ENERGY_AUTH_USER=admin
|
||||||
|
ENERGY_AUTH_PASSWORD=changeme
|
||||||
|
ENERGY_AUTH_SECRET=changeme-random-secret
|
||||||
|
ENERGY_INFLUX_TOKEN=your_influxdb_token_here
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Go binary
|
||||||
|
FROM golang:1.24-alpine AS backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /app/dist ./dist
|
||||||
|
RUN CGO_ENABLED=0 go build -o energy-frontend .
|
||||||
|
|
||||||
|
# Stage 3: Minimal runtime
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /app/energy-frontend .
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nobody:nobody
|
||||||
|
ENTRYPOINT ["./energy-frontend"]
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
energy-frontend:
|
||||||
|
build: .
|
||||||
|
container_name: energy-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8383:8080"
|
||||||
|
env_file: /srv/energy/frontend/.env
|
||||||
|
environment:
|
||||||
|
ENERGY_PORT: 8080
|
||||||
|
ENERGY_DB_DSN: postgres://energy:changeme@timescaledb:5432/energy
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
- fitmonitor-network
|
||||||
|
|
||||||
6
frontend.go
Normal file
6
frontend.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var frontendDist embed.FS
|
||||||
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
|
||||||
|
}
|
||||||
|
})
|
||||||
15
go.mod
Normal file
15
go.mod
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module energy-frontend
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.5
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
26
go.sum
Normal file
26
go.sum
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
113
internal/api/auth.go
Normal file
113
internal/api/auth.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"energy-frontend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cookieName = "energy_session"
|
||||||
|
cookieMaxAge = 7 * 24 * 3600 // 7 days
|
||||||
|
cookiePath = "/energy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authHandler struct {
|
||||||
|
cfg config.AuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthHandler(cfg config.AuthConfig) *authHandler {
|
||||||
|
return &authHandler{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) loginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.cfg.Username)) != 1 ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.cfg.Password)) != 1 {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, h.createSessionCookie(req.Username))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: cookiePath,
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) checkHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(cookieName)
|
||||||
|
if err != nil || !h.validateSession(cookie.Value) {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) createSessionCookie(username string) *http.Cookie {
|
||||||
|
expires := time.Now().Add(time.Duration(cookieMaxAge) * time.Second)
|
||||||
|
payload := fmt.Sprintf("%s|%d", username, expires.Unix())
|
||||||
|
sig := h.sign(payload)
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: fmt.Sprintf("%s|%s", payload, sig),
|
||||||
|
Path: cookiePath,
|
||||||
|
MaxAge: cookieMaxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) validateSession(value string) bool {
|
||||||
|
parts := strings.SplitN(value, "|", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
payload := parts[0] + "|" + parts[1]
|
||||||
|
if !hmac.Equal([]byte(h.sign(payload)), []byte(parts[2])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
exp, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().Unix() < exp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) sign(payload string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(h.cfg.Secret))
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
260
internal/api/energy.go
Normal file
260
internal/api/energy.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnergyBar struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Buy float64 `json:"buy"`
|
||||||
|
Sell float64 `json:"sell"`
|
||||||
|
Produce float64 `json:"produce"`
|
||||||
|
Consume float64 `json:"consume"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PowerPoint struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
PV float64 `json:"pv"`
|
||||||
|
Barn float64 `json:"barn"`
|
||||||
|
House float64 `json:"house"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatteryPoint struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Level float64 `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var periodDefaults = map[string]int{
|
||||||
|
"day": 30,
|
||||||
|
"week": 12,
|
||||||
|
"month": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewForRange picks the right continuous aggregate views for the given time range.
|
||||||
|
func viewForRange(timeRange string) (invView, meterView, interval string) {
|
||||||
|
switch timeRange {
|
||||||
|
case "1h":
|
||||||
|
return "inverter_10m", "power_meter_10m", "1 hour"
|
||||||
|
case "6h":
|
||||||
|
return "inverter_10m", "power_meter_10m", "6 hours"
|
||||||
|
case "7d":
|
||||||
|
return "inverter_1h", "power_meter_1h", "7 days"
|
||||||
|
default: // "24h" and anything else
|
||||||
|
return "inverter_10m", "power_meter_10m", "24 hours"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedUnion(maps ...map[time.Time]float64) []time.Time {
|
||||||
|
seen := make(map[time.Time]bool)
|
||||||
|
for _, m := range maps {
|
||||||
|
for t := range m {
|
||||||
|
seen[t] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
times := make([]time.Time, 0, len(seen))
|
||||||
|
for t := range seen {
|
||||||
|
times = append(times, t)
|
||||||
|
}
|
||||||
|
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
|
||||||
|
return times
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPower(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]PowerPoint, error) {
|
||||||
|
invView, meterView, interval := viewForRange(timeRange)
|
||||||
|
|
||||||
|
type colRow struct {
|
||||||
|
bucket time.Time
|
||||||
|
value float64
|
||||||
|
}
|
||||||
|
queryCol := func(sql string) (map[time.Time]float64, error) {
|
||||||
|
rows, err := pool.Query(ctx, sql)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
m := make(map[time.Time]float64)
|
||||||
|
for rows.Next() {
|
||||||
|
var r colRow
|
||||||
|
if err := rows.Scan(&r.bucket, &r.value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m[r.bucket] = r.value
|
||||||
|
}
|
||||||
|
return m, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
pvSQL := fmt.Sprintf(
|
||||||
|
`SELECT bucket, COALESCE(pv_power, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||||
|
invView, interval)
|
||||||
|
houseSQL := fmt.Sprintf(
|
||||||
|
`SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'house' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||||
|
meterView, interval)
|
||||||
|
barnSQL := fmt.Sprintf(
|
||||||
|
`SELECT bucket, COALESCE(total_power, 0) FROM %s WHERE device = 'barn' AND bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||||
|
meterView, interval)
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
m map[time.Time]float64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
pvCh := make(chan result, 1)
|
||||||
|
houseCh := make(chan result, 1)
|
||||||
|
barnCh := make(chan result, 1)
|
||||||
|
|
||||||
|
go func() { m, e := queryCol(pvSQL); pvCh <- result{m, e} }()
|
||||||
|
go func() { m, e := queryCol(houseSQL); houseCh <- result{m, e} }()
|
||||||
|
go func() { m, e := queryCol(barnSQL); barnCh <- result{m, e} }()
|
||||||
|
|
||||||
|
pvRes := <-pvCh
|
||||||
|
houseRes := <-houseCh
|
||||||
|
barnRes := <-barnCh
|
||||||
|
|
||||||
|
if pvRes.err != nil {
|
||||||
|
return nil, pvRes.err
|
||||||
|
}
|
||||||
|
if houseRes.err != nil {
|
||||||
|
return nil, houseRes.err
|
||||||
|
}
|
||||||
|
if barnRes.err != nil {
|
||||||
|
return nil, barnRes.err
|
||||||
|
}
|
||||||
|
|
||||||
|
times := sortedUnion(pvRes.m, houseRes.m, barnRes.m)
|
||||||
|
pts := make([]PowerPoint, 0, len(times))
|
||||||
|
for _, t := range times {
|
||||||
|
pts = append(pts, PowerPoint{
|
||||||
|
Time: t,
|
||||||
|
PV: pvRes.m[t],
|
||||||
|
House: houseRes.m[t],
|
||||||
|
Barn: barnRes.m[t],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBattery(ctx context.Context, pool *pgxpool.Pool, timeRange string) ([]BatteryPoint, error) {
|
||||||
|
invView, _, interval := viewForRange(timeRange)
|
||||||
|
|
||||||
|
sql := fmt.Sprintf(
|
||||||
|
`SELECT bucket, COALESCE(battery_soc, 0) FROM %s WHERE bucket >= NOW() - '%s'::interval ORDER BY bucket`,
|
||||||
|
invView, interval)
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, sql)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var pts []BatteryPoint
|
||||||
|
for rows.Next() {
|
||||||
|
var p BatteryPoint
|
||||||
|
if err := rows.Scan(&p.Time, &p.Level); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pts = append(pts, p)
|
||||||
|
}
|
||||||
|
return pts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBars(ctx context.Context, pool *pgxpool.Pool, period string, count int) ([]EnergyBar, error) {
|
||||||
|
var periodTrunc, rangeInterval string
|
||||||
|
switch period {
|
||||||
|
case "day":
|
||||||
|
periodTrunc = "day"
|
||||||
|
rangeInterval = fmt.Sprintf("%d days", count+1)
|
||||||
|
case "week":
|
||||||
|
periodTrunc = "week"
|
||||||
|
rangeInterval = fmt.Sprintf("%d weeks", count+1)
|
||||||
|
case "month":
|
||||||
|
periodTrunc = "month"
|
||||||
|
rangeInterval = fmt.Sprintf("%d months", count+1)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown period %q", period)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DISTINCT ON picks the last-written daily row for each period bucket,
|
||||||
|
// then LAG() computes the delta (energy consumed/produced/traded) over
|
||||||
|
// that period. Fetching count+1 rows ensures LAG has a previous value
|
||||||
|
// for the first bar; we trim to count at the end.
|
||||||
|
sql := fmt.Sprintf(`
|
||||||
|
WITH inv_last AS (
|
||||||
|
SELECT DISTINCT ON (date_trunc('%[1]s', bucket))
|
||||||
|
date_trunc('%[1]s', bucket) AS period,
|
||||||
|
grid_import_kwh, grid_export_kwh, pv_energy_kwh
|
||||||
|
FROM inverter_daily
|
||||||
|
WHERE bucket >= NOW() - '%[2]s'::interval
|
||||||
|
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
|
||||||
|
),
|
||||||
|
house_last AS (
|
||||||
|
SELECT DISTINCT ON (date_trunc('%[1]s', bucket))
|
||||||
|
date_trunc('%[1]s', bucket) AS period,
|
||||||
|
import_kwh
|
||||||
|
FROM power_meter_daily
|
||||||
|
WHERE device = 'house' AND bucket >= NOW() - '%[2]s'::interval
|
||||||
|
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
|
||||||
|
),
|
||||||
|
barn_last AS (
|
||||||
|
SELECT DISTINCT ON (date_trunc('%[1]s', bucket))
|
||||||
|
date_trunc('%[1]s', bucket) AS period,
|
||||||
|
import_kwh
|
||||||
|
FROM power_meter_daily
|
||||||
|
WHERE device = 'barn' AND bucket >= NOW() - '%[2]s'::interval
|
||||||
|
ORDER BY date_trunc('%[1]s', bucket), bucket DESC
|
||||||
|
),
|
||||||
|
joined AS (
|
||||||
|
SELECT
|
||||||
|
i.period,
|
||||||
|
i.grid_import_kwh,
|
||||||
|
LAG(i.grid_import_kwh) OVER (ORDER BY i.period) AS prev_import,
|
||||||
|
i.grid_export_kwh,
|
||||||
|
LAG(i.grid_export_kwh) OVER (ORDER BY i.period) AS prev_export,
|
||||||
|
i.pv_energy_kwh,
|
||||||
|
LAG(i.pv_energy_kwh) OVER (ORDER BY i.period) AS prev_pv,
|
||||||
|
h.import_kwh AS house_kwh,
|
||||||
|
LAG(h.import_kwh) OVER (ORDER BY i.period) AS prev_house,
|
||||||
|
b.import_kwh AS barn_kwh,
|
||||||
|
LAG(b.import_kwh) OVER (ORDER BY i.period) AS prev_barn
|
||||||
|
FROM inv_last i
|
||||||
|
LEFT JOIN house_last h ON h.period = i.period
|
||||||
|
LEFT JOIN barn_last b ON b.period = i.period
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
period,
|
||||||
|
GREATEST(0, grid_import_kwh - prev_import) AS buy,
|
||||||
|
GREATEST(0, grid_export_kwh - prev_export) AS sell,
|
||||||
|
GREATEST(0, pv_energy_kwh - prev_pv) AS produce,
|
||||||
|
GREATEST(0, COALESCE(house_kwh - prev_house, 0) + COALESCE(barn_kwh - prev_barn, 0)) AS consume
|
||||||
|
FROM joined
|
||||||
|
WHERE prev_import IS NOT NULL
|
||||||
|
ORDER BY period
|
||||||
|
`, periodTrunc, rangeInterval)
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, sql)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var bars []EnergyBar
|
||||||
|
for rows.Next() {
|
||||||
|
var b EnergyBar
|
||||||
|
if err := rows.Scan(&b.Time, &b.Buy, &b.Sell, &b.Produce, &b.Consume); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bars = append(bars, b)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim to the requested count (the LAG window requires one extra row)
|
||||||
|
if len(bars) > count {
|
||||||
|
bars = bars[len(bars)-count:]
|
||||||
|
}
|
||||||
|
return bars, nil
|
||||||
|
}
|
||||||
115
internal/api/router.go
Normal file
115
internal/api/router.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"energy-frontend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRouter(cfg config.Config, frontendFS fs.FS) http.Handler {
|
||||||
|
pool, err := pgxpool.New(context.Background(), cfg.DB.DSN)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db pool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
auth := newAuthHandler(cfg.Auth)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/login", auth.loginHandler)
|
||||||
|
mux.HandleFunc("POST /api/logout", auth.logoutHandler)
|
||||||
|
|
||||||
|
protected := http.NewServeMux()
|
||||||
|
protected.HandleFunc("GET /api/auth/check", auth.checkHandler)
|
||||||
|
protected.HandleFunc("GET /api/bars", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
period := r.URL.Query().Get("period")
|
||||||
|
if period == "" {
|
||||||
|
period = "day"
|
||||||
|
}
|
||||||
|
count, err := strconv.Atoi(r.URL.Query().Get("count"))
|
||||||
|
if err != nil || count <= 0 {
|
||||||
|
count = periodDefaults[period]
|
||||||
|
}
|
||||||
|
bars, err := getBars(r.Context(), pool, period, count)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"bars": bars})
|
||||||
|
})
|
||||||
|
protected.HandleFunc("GET /api/power", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tr := r.URL.Query().Get("range")
|
||||||
|
if tr == "" {
|
||||||
|
tr = "24h"
|
||||||
|
}
|
||||||
|
pts, err := getPower(r.Context(), pool, tr)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"points": pts})
|
||||||
|
})
|
||||||
|
protected.HandleFunc("GET /api/battery", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tr := r.URL.Query().Get("range")
|
||||||
|
if tr == "" {
|
||||||
|
tr = "24h"
|
||||||
|
}
|
||||||
|
pts, err := getBattery(r.Context(), pool, tr)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"points": pts})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.Handle("/api/", auth.middleware(protected))
|
||||||
|
|
||||||
|
if frontendFS != nil {
|
||||||
|
mux.Handle("/", spaHandler(frontendFS))
|
||||||
|
}
|
||||||
|
|
||||||
|
return corsMiddleware(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func spaHandler(frontendFS fs.FS) http.Handler {
|
||||||
|
fileServer := http.FileServer(http.FS(frontendFS))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(frontendFS, path); err == nil {
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.URL.Path = "/"
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
58
internal/config/config.go
Normal file
58
internal/config/config.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig
|
||||||
|
Auth AuthConfig
|
||||||
|
DB DBConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DBConfig struct {
|
||||||
|
DSN string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
Port: getEnvInt("ENERGY_PORT", 8080),
|
||||||
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
Username: getEnv("ENERGY_AUTH_USER", "admin"),
|
||||||
|
Password: getEnv("ENERGY_AUTH_PASSWORD", ""),
|
||||||
|
Secret: getEnv("ENERGY_AUTH_SECRET", "change-me-in-production"),
|
||||||
|
},
|
||||||
|
DB: DBConfig{
|
||||||
|
DSN: getEnv("ENERGY_DB_DSN", "postgres://energy:changeme@localhost:5433/energy"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if i, err := strconv.Atoi(v); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
49
main.go
Normal file
49
main.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"energy-frontend/internal/api"
|
||||||
|
"energy-frontend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
frontendFS, _ := fs.Sub(frontendDist, "dist")
|
||||||
|
router := api.NewRouter(*cfg, frontendFS)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("starting server", "addr", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigChan
|
||||||
|
|
||||||
|
slog.Info("shutting down")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user