feat(web): basic version of Turbo monorepo with SvelteKit app for backend and dashboard
This commit is contained in:
parent
34393a6c63
commit
90b2f9d559
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
4
.env
Normal file
@ -0,0 +1,4 @@
|
||||
DB_HOST=localhost
|
||||
DB_USER=aqs-api
|
||||
DB_PASS=localPassword
|
||||
DB_NAME=aqs
|
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
DB_HOST=localhost
|
||||
DB_USER=aqs-api
|
||||
DB_PASS=localPassword
|
||||
DB_NAME=aqs
|
4
.env.prod
Normal file
4
.env.prod
Normal file
@ -0,0 +1,4 @@
|
||||
DB_HOST=aqs-db
|
||||
DB_USER=aqs-api
|
||||
DB_PASS=52135gtfw3tk46ky029q309jg
|
||||
DB_NAME=aqs
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -28,3 +28,5 @@ yarn-error.log*
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
.volumes
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal 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
12
.idea/aqs.iml
generated
Normal 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
12
.idea/dataSources.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
8
.idea/prettier.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -1,7 +0,0 @@
|
||||
{
|
||||
"eslint.workingDirectories": [
|
||||
{
|
||||
"mode": "auto"
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['@repo/eslint-config/index.js']
|
||||
};
|
10
apps/docs/.gitignore
vendored
10
apps/docs/.gitignore
vendored
@ -1,10 +0,0 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
@ -1 +0,0 @@
|
||||
engine-strict=true
|
@ -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-*
|
@ -1,9 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
@ -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.
|
@ -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"
|
||||
}
|
||||
}
|
@ -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;
|
12
apps/docs/src/app.d.ts
vendored
12
apps/docs/src/app.d.ts
vendored
@ -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 {};
|
@ -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>
|
@ -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);
|
||||
});
|
||||
});
|
@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
@ -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>
|
BIN
apps/docs/static/favicon.png
vendored
BIN
apps/docs/static/favicon.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB |
@ -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;
|
@ -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();
|
||||
});
|
@ -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
|
||||
}
|
@ -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
21
apps/web/Dockerfile
Normal 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
|
@ -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",
|
||||
|
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
3
apps/web/src/app.css
Normal file
3
apps/web/src/app.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -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>
|
||||
|
9
apps/web/src/hooks.server.ts
Normal file
9
apps/web/src/hooks.server.ts
Normal 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;
|
||||
};
|
234
apps/web/src/lib/components/chart.svelte
Normal file
234
apps/web/src/lib/components/chart.svelte
Normal 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>
|
18
apps/web/src/lib/components/charts.svelte
Normal file
18
apps/web/src/lib/components/charts.svelte
Normal 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>
|
23
apps/web/src/lib/components/filter.svelte
Normal file
23
apps/web/src/lib/components/filter.svelte
Normal 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>
|
2
apps/web/src/lib/components/index.ts
Normal file
2
apps/web/src/lib/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export {default as Filter} from './filter.svelte';
|
||||
export {default as Charts} from './charts.svelte';
|
211
apps/web/src/lib/components/models.ts
Normal file
211
apps/web/src/lib/components/models.ts
Normal 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'
|
||||
}
|
||||
];
|
@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
47
apps/web/src/lib/stores/data.ts
Normal file
47
apps/web/src/lib/stores/data.ts
Normal 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);
|
||||
}
|
1
apps/web/src/lib/stores/index.ts
Normal file
1
apps/web/src/lib/stores/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './data'
|
5
apps/web/src/routes/+layout.svelte
Normal file
5
apps/web/src/routes/+layout.svelte
Normal file
@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import "../app.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
@ -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>
|
||||
|
34
apps/web/src/routes/api/aqs/+server.ts
Normal file
34
apps/web/src/routes/api/aqs/+server.ts
Normal 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 });
|
||||
};
|
@ -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;
|
||||
|
8
apps/web/tailwind.config.js
Normal file
8
apps/web/tailwind.config.js
Normal 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
17
dev.compose.yml
Normal 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
|
@ -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
21
packages/database/env.ts
Normal 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
|
||||
}
|
||||
};
|
23
packages/database/index.ts
Normal file
23
packages/database/index.ts
Normal 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
7
packages/database/lib.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import postgres from 'postgres';
|
||||
import { env } from './env';
|
||||
|
||||
export const sql = postgres({
|
||||
...env.db,
|
||||
transform: postgres.camel
|
||||
});
|
73
packages/database/migrate.ts
Normal file
73
packages/database/migrate.ts
Normal 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();
|
9
packages/database/migrations/20240128_0838_init.sql
Normal file
9
packages/database/migrations/20240128_0838_init.sql
Normal 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()
|
||||
)
|
22
packages/database/package.json
Normal file
22
packages/database/package.json
Normal 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"
|
||||
}
|
||||
}
|
45
packages/database/purge.ts
Normal file
45
packages/database/purge.ts
Normal 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();
|
22
packages/database/tsconfig.json
Normal file
22
packages/database/tsconfig.json
Normal 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"]
|
||||
}
|
3
packages/shared-types/.eslintrc.cjs
Normal file
3
packages/shared-types/.eslintrc.cjs
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@repo/eslint-config/index.js']
|
||||
};
|
37
packages/shared-types/index.ts
Normal file
37
packages/shared-types/index.ts
Normal 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>;
|
17
packages/shared-types/package.json
Normal file
17
packages/shared-types/package.json
Normal 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
1153
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
72
prod.compose.yml
Normal file
72
prod.compose.yml
Normal 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
11
prod.deploy.files.txt
Normal 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
|
Loading…
Reference in New Issue
Block a user