feat(web): basic version of Turbo monorepo with SvelteKit app for backend and dashboard

This commit is contained in:
spy4x 2024-03-21 17:06:15 +08:00
parent 34393a6c63
commit 90b2f9d559
66 changed files with 2207 additions and 340 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
# 1. Ignore everything
**/*
# 2. Add files and directories that should be included
!apps/**
!packages/**
!.npmrc
!package.json
!pnpm-lock.yaml
!pnpm-workspace.yaml
!turbo.json
# 3. Bonus step: ignore any unnecessary files that may be inside those allowed directories in 2
**/.DS_Store
**/Thumbs.db

4
.env Normal file
View File

@ -0,0 +1,4 @@
DB_HOST=localhost
DB_USER=aqs-api
DB_PASS=localPassword
DB_NAME=aqs

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
DB_HOST=localhost
DB_USER=aqs-api
DB_PASS=localPassword
DB_NAME=aqs

4
.env.prod Normal file
View File

@ -0,0 +1,4 @@
DB_HOST=aqs-db
DB_USER=aqs-api
DB_PASS=52135gtfw3tk46ky029q309jg
DB_NAME=aqs

2
.gitignore vendored
View File

@ -28,3 +28,5 @@ yarn-error.log*
# turbo
.turbo
.volumes

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/aqs.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

12
.idea/dataSources.xml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="aqs-prod" uuid="a36498ea-bc41-4d59-8676-1817ed5d70c2">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://192.168.0.140:5432/aqs</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/aqs.iml" filepath="$PROJECT_DIR$/.idea/aqs.iml" />
</modules>
</component>
</project>

8
.idea/prettier.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
<option name="myFilesPattern" value="{**/*,*}.{js,ts,jsx,tsx,vue,astro,svelte,json,html,css}" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,7 +0,0 @@
{
"eslint.workingDirectories": [
{
"mode": "auto"
}
]
}

View File

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View File

@ -1,3 +0,0 @@
module.exports = {
extends: ['@repo/eslint-config/index.js']
};

10
apps/docs/.gitignore vendored
View File

@ -1,10 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@ -1 +0,0 @@
engine-strict=true

View File

@ -1,14 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
vite.config.*.timestamp-*

View File

@ -1,9 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -1,38 +0,0 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

View File

@ -1,38 +0,0 @@
{
"name": "docs",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"dependencies": {
"@repo/ui": "workspace:*"
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@sveltejs/adapter-auto": "^3.1.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"eslint": "^8.57.0",
"@repo/eslint-config": "workspace:*",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"svelte": "^4.2.12",
"svelte-check": "^3.6.6",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vitest": "^1.3.1"
}
}

View File

@ -1,12 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

View File

@ -1,12 +0,0 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -1,7 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -1,7 +0,0 @@
<script lang="ts">
import { MyCounterButton } from '@repo/ui';
</script>
<h1>Docs</h1>
<MyCounterButton />
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,17 +0,0 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@ -1,6 +0,0 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Docs' })).toBeVisible();
});

View File

@ -1,17 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

View File

@ -1,14 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
},
build: {
commonjsOptions: {
include: [/@repo-ui/, /node_modules/],
},
},
});

21
apps/web/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=20.4.0
ARG PNPM_VERSION=8.6.7
FROM node:${NODE_VERSION}-alpine as base
WORKDIR /app
RUN npm install -g pnpm@${PNPM_VERSION}
COPY . .
RUN pnpm install
RUN pnpm build
USER node
ENV NODE_ENV production
CMD pnpm --filter ./packages/database migrate && node apps/web/build/index.js

View File

@ -1,5 +1,5 @@
{
"name": "web",
"name": "web",
"version": "0.0.1",
"private": true,
"type": "module",
@ -15,21 +15,28 @@
"test:unit": "vitest"
},
"dependencies": {
"@repo/ui": "workspace:*"
"@repo/db": "workspace:*",
"@repo/shared-types": "workspace:*",
"@repo/ui": "workspace:*",
"chart.js": "^4.4.2",
"postgres": "^3.4.3"
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@sveltejs/adapter-auto": "^3.1.1",
"@repo/eslint-config": "workspace:*",
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"autoprefixer": "^10.4.18",
"eslint": "^8.57.0",
"@repo/eslint-config": "workspace:*",
"postcss": "^8.4.36",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"svelte": "^4.2.12",
"svelte-check": "^3.6.6",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.4",

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

3
apps/web/src/app.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,12 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<!doctype html>
<html lang="en" class="h-full bg-gray-900">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="hover" class="h-full">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,9 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const start = Date.now();
console.log(event.request.method, event.request.url);
const result = await resolve(event);
console.log(event.request.method, event.request.url, result.status, Date.now() - start + 'ms');
return result;
};

View File

@ -0,0 +1,234 @@
<script lang="ts">
import {chartReferenceBoxTransparency, type Model} from "./models";
import Chart from 'chart.js/auto';
import type {Measurement} from "@repo/shared-types";
export let model: Model;
export let measurements: Measurement[];
let chartCanvas: HTMLCanvasElement;
let chart: null | Chart;
const tension = 0.5;
const defaultColor = 'rgb(75, 192, 192)';
const pointRadius = 3;
const pointHoverRadius = 6;
function getMeasurements(field: string): { value: number, createdAt: Date }[] {
return measurements.map(m => ({value: m[field as keyof Measurement], createdAt: m.createdAt}))
}
$: data = measurements && getMeasurements(model.field)
$: datalines = {
labels: data.map(d => d.createdAt),
datasets: [
{
label: (model.fieldTitle) + ', ' + model.unit,
borderColor: model.color || 'rgb(75, 192, 192)',
backgroundColor: model.color || 'rgb(75, 192, 192)',
tension,
pointRadius,
pointHoverRadius,
data: data.map(d => d.value),
},
],
}
$: {
measurements.toString(); // TODO: remove this hack
if (chart) {
// update
console.log('update', model.field);
chart.data = datalines;
chart.update();
} else if (chartCanvas) {
// create
console.log('create', model.field);
const ctx = chartCanvas.getContext('2d');
ctx && (chart = new Chart(ctx, {
type: 'line',
data: datalines,
width: 400,
height: 400,
}))
}
}
function getColorByValue(value: number) {
if (!model.references) {
return 'grey';
}
const reference = model.references.find(r => r.min <= value && value <= r.max);
if (!reference) {
return 'grey';
}
return typeof reference.color === 'string' || reference.color instanceof String
? reference.color
: `rgb(${reference.color.join(',')}`;
}
//
// function getReferenceBoxColor(colorCode: number[]) {
// return `rgba(${colorCode.join(', ')}, ${chartReferenceBoxTransparency})`;
// }
//
// function capitalize(string: string): string {
// return string.charAt(0).toUpperCase() + string.slice(1);
// }
//
// function referenceToAnnotation(reference) {
// return {
// type: 'box',
// backgroundColor:
// typeof reference.color === 'string' || reference.color instanceof String
// ? reference.color
// : getReferenceBoxColor(reference.color),
// borderWidth: 0,
// yMax: reference.max,
// yMin: reference.min,
// label: {
// drawTime: 'afterDraw',
// display: true,
// content: reference.title,
// position: {
// x: 'center',
// y: reference.placement || 'center',
// },
// },
// };
// }
//
// function syncTooltips(chart) {
// let activeTooltip = null;
// return () => {
// const tooltipItem = chart.tooltip._tooltipItems[0];
// if (tooltipItem) {
// activeTooltip = tooltipItem;
// } else if (activeTooltip) {
// // clear all tooltips
// activeTooltip = null;
// removeTooltips();
// return;
// } else {
// return;
// }
//
// getModels().forEach(m => {
// if (m.chart === chart) {
// return; // skip itself
// }
// m.chart.tooltip.setActiveElements([
// { datasetIndex: activeTooltip.datasetIndex, index: activeTooltip.dataIndex },
// ]);
// m.chart.setActiveElements([{ datasetIndex: activeTooltip.datasetIndex, index: activeTooltip.dataIndex }]);
// m.chart.update();
// });
// };
// }
// function removeTooltips(exceptChart) {
// getModels().forEach(m => {
// if (m.chart === exceptChart) {
// return; // skip itself
// }
// m.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
// m.chart.setActiveElements([], { x: 0, y: 0 });
// m.chart.update();
// });
// }
//
// getModels().forEach(m => {
// const data = {
// labels: [],
// datasets: [
// {
// label: (m.fieldTitle || capitalize(m.field)) + ', ' + m.unit,
// borderColor: m.color || defaultColor,
// backgroundColor: m.color || defaultColor,
// tension,
// pointRadius,
// pointHoverRadius,
// data: [],
// },
// ],
// };
//
// const config = {
// type: 'line',
// data: data,
// options: {
// interaction: {
// mode: 'index',
// intersect: false,
// },
// plugins: {
// legend: false,
// annotation: {
// common: {
// drawTime: 'beforeDraw',
// },
// annotations: m.references && m.references.map(r => referenceToAnnotation(r)),
// },
// tooltip: {
// displayColors: false,
// callbacks: {
// label: (tooltip:any) => `${tooltip.parsed.y} ${m.unit}`,
// },
// },
// },
// scales: {
// x: {
// type: 'time',
// time: {
// tooltipFormat: 'HH:mm',
// displayFormats: {
// hour: `HH:mm`,
// },
// },
// },
// y: {
// suggestedMin: m.yMin,
// suggestedMax: m.yMax,
// title: {
// display: true,
// text: m.unit,
// },
// },
// },
// },
// };
//
// const element = document.getElementById(`chart-${m.field || m.title}`) as null | HTMLCanvasElement;
// if(!element) {
// console.error(`Element with id 'chart-${m.field || m.title}' not found`);
// return;
// }
// const ctx = element.getContext('2d');
// if(!ctx) {
// console.error(`Context for element with id 'chart-${m.field || m.title}' not found`);
// return;
// }
// m.chart = new Chart(ctx, config);
// m.chart.canvas.onmousemove = syncTooltips(m.chart);
// m.chart.canvas.onmouseout = removeTooltips;
// });
</script>
<div class="flex items-center flex-col">
<h2 class="text-2xl font-bold flex justify-center gap-2 items-center"
>
<span>{model.title}</span>
<!-- Tag: -->
<span class="text-sm border-0 rounded-full px-2 py-1" style="background-color: {getColorByValue(
data[data.length - 1]?.value
)}"
>{data[data.length - 1]?.value} {model.unit}</span>
</h2>
<div style="max-width: 400px; max-height: 400px">
<canvas bind:this={chartCanvas} height="400" id="canvas-{model.field}" width="400"></canvas>
</div>
<!--<pre>{-->
<!-- JSON.stringify(data, null, 2)-->
<!--}</pre>-->
</div>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import Chart from './chart.svelte';
import type {Measurement} from "@repo/shared-types";
import {models} from "./models";
import {browser} from "$app/environment";
export let measurements: Measurement[] = [];
let isDebugParamInURL = browser && new URLSearchParams(window.location.search).has('debug');
</script>
<div class="container mt-10 pb-10 flex flex-wrap gap-10 align-center justify-center">
<!-- <pre>{JSON.stringify(measurements, null, 4)}</pre>-->
{#each models as model}
{#if !model.isForDebugOnly || isDebugParamInURL}
<Chart {measurements} model={model}/>
{/if}
{/each}
</div>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import {createEventDispatcher} from "svelte";
export let hours = 1;
const dispatch = createEventDispatcher();
const filterHoursOptions = [1, 3, 6, 12, 24];
function onChange(e: Event) {
dispatch("set", {hours: (e.target as HTMLSelectElement).value});
}
</script>
<div class="flex align-center justify-center">
<select
class="mt-2 block rounded-md border-0 py-1.5 pl-3 pr-5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 text-lg sm:leading-6"
on:change={onChange}
>
{#each filterHoursOptions as filterHoursOption}
<option value={filterHoursOption} selected={filterHoursOption === hours}>{filterHoursOption}h</option>
{/each}
</select>
</div>

View File

@ -0,0 +1,2 @@
export {default as Filter} from './filter.svelte';
export {default as Charts} from './charts.svelte';

View File

@ -0,0 +1,211 @@
import type { Measurement } from '@repo/shared-types';
export const chartReferenceBoxTransparency = 0.2;
const colorCodes = {
GOOD: [33, 166, 5],
OK: [239, 200, 2],
BAD: [255, 153, 0],
VERY_BAD: [255, 68, 0],
DANGER: [253, 0, 0]
};
export interface ChartReference {
title: string;
color: string | number[];
max: number;
min: number;
placement?: 'start' | 'end';
}
export interface Model {
title: string;
field: keyof Measurement;
fieldTitle?: string;
color: string;
yMin: number;
yMax?: number;
unit?: string;
references?: ChartReference[];
isForDebugOnly?: boolean;
}
export const models: Model[] = [
{
title: `Freshness, CO2`,
field: 'co2',
fieldTitle: 'CO2',
color: 'rgb(75, 192, 192)',
yMin: 400,
yMax: 1200,
unit: 'ppm',
references: [
{
title: 'Headache',
color: colorCodes.VERY_BAD,
max: 2000,
min: 1500,
placement: 'end'
},
{
title: 'Low productivity, Laziness',
color: colorCodes.BAD,
max: 1500,
min: 1200,
placement: 'end'
},
{
title: 'Time to refresh air',
color: colorCodes.OK,
max: 1200,
min: 800
},
{
title: 'Fresh air',
color: colorCodes.GOOD,
max: 800,
min: 400
}
]
},
{
title: 'Pollution, PM 2.5',
field: 'pm2p5',
fieldTitle: 'PM 2.5',
color: 'orange',
yMin: 0,
yMax: 35,
unit: 'μg/m3',
references: [
{
title: 'Hazardous',
color: colorCodes.DANGER,
max: 500,
min: 100,
placement: 'end'
},
{
title: 'Very Unhealthy',
color: colorCodes.VERY_BAD,
max: 100,
min: 55,
placement: 'end'
},
{
title: 'Unhealthy',
color: colorCodes.BAD,
max: 55,
min: 35,
placement: 'end'
},
{
title: 'Moderate',
color: colorCodes.OK,
max: 35,
min: 12
},
{
title: 'Clean',
color: colorCodes.GOOD,
max: 12,
min: 0
}
]
},
{
title: 'Temperature',
field: 'temperature',
fieldTitle: '',
color: 'red',
unit: '°C',
yMin: 20,
yMax: 30,
references: [
{
title: 'Too hot',
color: colorCodes.DANGER,
max: 50,
min: 32,
placement: 'end'
},
{
title: 'Hot',
color: colorCodes.OK,
max: 32,
min: 27
},
{
title: 'Optimum',
color: colorCodes.GOOD,
max: 27,
min: 22
},
{
title: 'Chill',
color: `rgba(0,255,233,${chartReferenceBoxTransparency})`,
max: 22,
min: 15,
placement: 'start'
},
{
title: 'Cold',
color: `rgba(0,33,255,${chartReferenceBoxTransparency})`,
max: 15,
min: -50,
placement: 'start'
}
]
},
{
title: 'Humidity',
field: 'humidity',
fieldTitle: '',
color: 'blue',
unit: '%',
yMin: 25,
yMax: 70,
references: [
{
title: 'Too humid',
color: colorCodes.BAD,
max: 100,
min: 70,
placement: 'end'
},
{
title: 'Moderate',
color: colorCodes.OK,
max: 70,
min: 60
},
{
title: 'Optimum',
color: colorCodes.GOOD,
max: 60,
min: 40
},
{
title: 'Moderate',
color: colorCodes.OK,
max: 40,
min: 25
},
{
title: 'Too dry',
color: colorCodes.BAD,
max: 25,
min: 0,
placement: 'start'
}
]
},
{
title: 'Time alive',
isForDebugOnly: true,
field: 'uptime',
fieldTitle: '',
color: 'forestgreen',
yMin: 0,
unit: 'Sec'
}
];

View File

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -0,0 +1,47 @@
import type {Measurement} from "@repo/shared-types";
import {get, writable} from "svelte/store";
import {browser} from "$app/environment";
interface Store {
fetchedAt: null | Date;
measurements: Measurement[];
filterHours: number;
isLoading: boolean;
error: null | string;
}
const initialStore: Store = {
fetchedAt: null,
measurements: [],
filterHours: 1,
isLoading: false,
error: null,
}
const store = writable<Store>(initialStore);
async function load(filterHours?: number) {
if(!filterHours) {
filterHours = get(store).filterHours;
}
store.update(state => ({...state, filterHours: filterHours || 1, isLoading: true, error: null}));
try {
const response = await fetch(`/api/aqs?hours=${filterHours}`);
const measurements = await response.json();
store.update(state => ({...state, measurements, isLoading: false, error: null, fetchedAt: new Date()}));
} catch (e: unknown) {
store.update(state => ({...state, isLoading: false, error: (e as Error).message}));
}
}
export const data = {
subscribe: store.subscribe,
setFilterHours: async (hours: number): Promise<void> => {
await load(hours);
}
}
if (browser) {
void load(initialStore.filterHours);
setInterval(() => void load(), 2 * 60 * 1000);
}

View File

@ -0,0 +1 @@
export * from './data'

View File

@ -0,0 +1,5 @@
<script>
import "../app.css";
</script>
<slot />

View File

@ -1,7 +1,30 @@
<script lang="ts">
import { MyCounterButton } from '@repo/ui';
import {Filter, Charts} from '@components';
import {data} from '@stores'
import {onMount} from "svelte";
let secondsAgoTimer: NodeJS.Timeout;
let updatedSecondsAgo = 0;
onMount(() => {
secondsAgoTimer = setInterval(() => {
updatedSecondsAgo = $data.fetchedAt ? Math.round((Date.now() - $data.fetchedAt.getTime()) / 1000) : 0;
}, 1000);
});
</script>
<h1>Web</h1>
<MyCounterButton />
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<div class="container mx-auto text-white my-10" style="max-width: 1024px">
<h1 class="text-5xl font-bold text-center ">Air Quality</h1>
{#if $data.isLoading}
<p>Loading...</p>
{:else if $data.error}
<p>Error: {$data.error}</p>
{:else}
<p class="text-center mt-1">Last updated {updatedSecondsAgo} seconds ago</p>
{/if}
<Filter hours={$data.filterHours} on:set={e => data.setFilterHours(e.detail.hours)} />
<Charts measurements={$data.measurements} />
<!-- <pre>Store: {JSON.stringify($data, null, 2)}</pre>-->
</div>

View File

@ -0,0 +1,34 @@
import { type RequestHandler, json } from '@sveltejs/kit';
import { SensorDataSchema } from '@repo/shared-types';
import { db } from '@repo/db';
export const GET: RequestHandler = async ({ url }) => {
const hours = Number(url.searchParams.get('hours')) || 1;
const measurements = await db.measurements.findMany(hours);
return json(measurements);
};
export const POST: RequestHandler = async ({ request }) => {
const jsonPayload = await request.json();
const parseResult = SensorDataSchema.safeParse(jsonPayload);
if (!parseResult.success) {
return jsonPayload(parseResult.error.formErrors, 400);
}
const payload = parseResult.data;
const originalUptimeMoment = payload.u * 1000;
const now = Date.now();
await db.measurements.createMany(
payload.d.map((v) => ({
co2: v.c,
pm2p5: v.pm2p5,
temperature: v.t,
humidity: v.h,
uptime: v.u,
createdAt: new Date(now - (originalUptimeMoment - v.u * 1000))
}))
);
return json({ success: true });
};

View File

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
@ -10,8 +10,16 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
},
adapter: adapter(),
alias: {
'@components': './src/lib/components',
'@stores': './src/lib/stores'
},
env: {
dir: '../..'
}
}
};
export default config;

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: []
};

17
dev.compose.yml Normal file
View File

@ -0,0 +1,17 @@
version: '3.8'
name: aqs
services:
aqs-db:
container_name: aqs-db
image: postgres:15-alpine
restart: always
environment:
POSTGRES_USER: aqs-api
POSTGRES_PASSWORD: localPassword
POSTGRES_DB: aqs
ports:
- 5432:5432
volumes:
- ./.volumes/postgres:/var/lib/postgresql/data

View File

@ -4,7 +4,10 @@
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"format": "prettier --write ."
"format": "prettier --write .",
"prod:deploy": "pnpm prod:deploy:copy && pnpm prod:deploy:run",
"prod:deploy:copy": "rsync -avz -e 'ssh' . homelab:~/apps/aqs --include-from='prod.deploy.files.txt' --exclude '*'",
"prod:deploy:run": "ssh homelab 'cd apps/aqs && docker compose -f prod.compose.yml build && docker compose --env-file=.env.prod --env-file=../.env -f prod.compose.yml down && docker compose --env-file=.env.prod --env-file=../.env -f prod.compose.yml up -d'"
},
"devDependencies": {
"eslint": "^8.57.0",

21
packages/database/env.ts Normal file
View File

@ -0,0 +1,21 @@
import { config } from 'dotenv';
const isProd = process.env.NODE_ENV === 'prod' || process.env.NODE_ENV === 'production';
const isEnvLoadedAlready =
process.env.DB_HOST && process.env.DB_USER && process.env.DB_PASS && process.env.DB_NAME;
if (!isEnvLoadedAlready) {
const basePath = `../..`;
config({ path: isProd ? `${basePath}/.env.prod` : `${basePath}/.env` });
}
export const env = {
isProd,
db: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
pass: process.env.DB_PASS,
db: process.env.DB_NAME
}
};

View File

@ -0,0 +1,23 @@
import type { Measurement, MeasurementBase } from '@repo/shared-types';
import { sql } from './lib';
// Uncomment for debugging queries
sql.options.debug = (_, query, parameters) =>
console.log(query, parameters.length ? parameters : '');
// TODO: automatically log if query takes too long
export const db = {
$connect: async (): Promise<void> => {
await sql`SELECT 1`;
},
$shutdown: async (): Promise<void> => sql.end({ timeout: 5 }),
measurements: {
createMany: async (measurements: MeasurementBase[]): Promise<void> => {
await sql`INSERT INTO measurements ${sql(measurements)}`;
},
findMany: async (hours: number): Promise<Measurement[]> => {
const createdAtGt = new Date(Date.now() - hours * 60 * 60 * 1000);
return await sql`SELECT * FROM measurements WHERE created_at > ${createdAtGt} ORDER BY created_at ASC`;
}
}
};

7
packages/database/lib.ts Normal file
View File

@ -0,0 +1,7 @@
import postgres from 'postgres';
import { env } from './env';
export const sql = postgres({
...env.db,
transform: postgres.camel
});

View File

@ -0,0 +1,73 @@
import { readdirSync, readFileSync } from 'fs';
import { extname, join } from 'path';
import { sql } from './lib';
const migrationsFolder: string = './migrations';
const fileExtension: string = '.sql';
async function applyMigrations(): Promise<void> {
try {
const tableExists: { exists: boolean }[] =
await sql`SELECT exists (SELECT FROM information_schema.tables WHERE table_name = 'migrations')`;
if (!tableExists[0].exists) {
await sql`
CREATE TABLE migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`;
console.log('Migrations table was created.\n');
}
} catch (err) {
console.error('❌ Failed to create migrations table', err);
process.exit(1);
}
let migrations: string[] = [];
try {
const migrationsResult: any = await sql`SELECT name FROM migrations`;
migrations = migrationsResult.map(({ name }: { name: string }) => name);
} catch (err) {
console.error('❌ Failed to get migrations from database', err);
process.exit(1);
}
try {
const files: string[] = readdirSync(migrationsFolder);
const migrationsToApply: string[] = files
.filter((file: string) => extname(file) === fileExtension)
.map((file: string) => file.replace(fileExtension, ''))
.filter((file: string) => !migrations.includes(file))
.sort();
if (!migrationsToApply.length) {
console.log('✅ No new migrations to apply.');
process.exit(0);
}
console.log(`Applying ${migrationsToApply.length} migration(s):`);
for (let migration of migrationsToApply) {
console.log('- ' + migration);
const sqlScript: string = readFileSync(
join(migrationsFolder, migration + fileExtension),
'utf8'
);
await sql.begin(async (tx: any) => {
await tx.unsafe(sqlScript);
await tx`INSERT INTO migrations (name) VALUES (${migration})`;
});
}
console.log('\n✅ Migrations successfully applied.');
process.exit(0);
} catch (err) {
console.error('\n❌ Failed to apply migrations\n', err);
process.exit(1);
}
}
void applyMigrations();

View File

@ -0,0 +1,9 @@
CREATE TABLE measurements (
id SERIAL PRIMARY KEY,
temperature FLOAT NOT NULL,
humidity FLOAT NOT NULL,
co2 INT2 NOT NULL,
pm2p5 INT2 NOT NULL,
uptime INT4 NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)

View File

@ -0,0 +1,22 @@
{
"name": "@repo/db",
"version": "0.0.0",
"type": "module",
"module": "index.ts",
"main": "index.ts",
"scripts": {
"lint": "eslint .",
"migrate": "tsx ./migrate.ts",
"purge": "tsx ./purge.ts"
},
"dependencies": {
"@repo/shared-types": "workspace:*",
"dotenv": "^16.4.5",
"postgres": "^3.4.3"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@types/node": "^20.11.30",
"tsx": "^4.7.1"
}
}

View File

@ -0,0 +1,45 @@
import { sql } from './lib';
import { env } from './env';
/** Drops all tables from the database.
* if NODE_ENV!='prod' || 'production' and not --prod - then abort */
async function purge(): Promise<void> {
if (env.isProd && !process.argv.includes('--prod')) {
console.error('❌ Cannot purge database in production');
process.exit(1);
}
let tables: string[] = [];
try {
console.log('Fetching table names...');
const result = await sql<{ tableName: string }[]>`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
`;
tables = result.map(({ tableName }) => tableName);
} catch (err) {
console.error('❌ Failed to fetch table names', err);
process.exit(1);
}
if (!tables.length) {
console.log('\n✅ No tables to purge.');
process.exit(0);
}
try {
console.log(`Purging ${tables.length} tables:`);
for (let table of tables) {
console.log('- ' + table);
await sql`DROP TABLE ${sql(table)} CASCADE`;
}
console.log('\n✅ Database purged.');
process.exit(0);
} catch (err) {
console.error('\n❌ Failed to purge database\n', err);
process.exit(1);
}
}
void purge();

View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"outDir": "dist",
"lib": ["esnext"],
"types": ["node"],
"baseUrl": "./",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
},
"exclude": ["node_modules"],
"include": ["./*.ts"]
}

View File

@ -0,0 +1,3 @@
module.exports = {
extends: ['@repo/eslint-config/index.js']
};

View File

@ -0,0 +1,37 @@
import { z } from 'zod';
export const SensorDataSchema = z.object({
u: z.number(),
d: z.array(
z.object({
u: z.number(),
c: z.number(),
pm2p5: z.number(),
t: z.number(),
h: z.number()
})
)
});
export type SensorData = z.infer<typeof SensorDataSchema>;
export const IdSchema = z.object({
id: z.number().int().positive()
});
export const CreatedAtSchema = z.object({
createdAt: z.date()
});
export const BaseSchema = IdSchema.merge(CreatedAtSchema);
export const MeasurementBaseSchema = CreatedAtSchema.merge(
z.object({
co2: z.number().int().positive(),
pm2p5: z.number().int().positive(),
temperature: z.number().min(-273.15).max(100),
humidity: z.number().min(0).max(100),
uptime: z.number().int().positive()
})
);
export type MeasurementBase = z.infer<typeof MeasurementBaseSchema>;
export const MeasurementSchema = MeasurementBaseSchema.merge(BaseSchema);
export type Measurement = z.infer<typeof MeasurementSchema>;

View File

@ -0,0 +1,17 @@
{
"name": "@repo/shared-types",
"version": "0.0.0",
"type": "module",
"module": "index.ts",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"lint": "eslint ."
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"@repo/eslint-config": "*"
}
}

1153
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

72
prod.compose.yml Normal file
View File

@ -0,0 +1,72 @@
version: '3.8'
name: aqs
networks:
proxy:
external: true
services:
aqs-app:
container_name: aqs-app
build:
context: .
dockerfile: apps/web/Dockerfile
restart: unless-stopped
networks:
- proxy
- default
env_file:
- .env.prod
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
depends_on:
aqs-db:
condition: service_healthy
security_opt:
- no-new-privileges:true
labels:
- 'traefik.enable=true'
- "traefik.docker.network=proxy"
- 'traefik.http.routers.aqs-app.rule=Host(`home.antonshubin.com`) || Host(`aqs.antonshubin.com`)'
- 'traefik.http.routers.aqs-app.entrypoints=websecure'
- 'traefik.http.routers.aqs-app.tls=true'
- 'traefik.http.routers.aqs-app.tls.certresolver=myresolver'
- 'traefik.http.services.aqs-app.loadbalancer.server.port=3000'
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
aqs-db:
container_name: aqs-db
image: postgres:15-alpine
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.25'
memory: 512M
environment:
- PGUSER=${DB_USER}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
- POSTGRES_DB=${DB_NAME}
ports:
- 5432:5432
volumes:
- ../.volumes/aqs/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", '-d', '${DB_NAME}', '-U', '${DB_USER}']
interval: 10s
timeout: 5s
retries: 5
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'

11
prod.deploy.files.txt Normal file
View File

@ -0,0 +1,11 @@
apps/***
packages/***
.npmrc
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.dockerignore
prod.dockerfile
prod.compose.yml
.env.prod
turbo.json