Compare commits
No commits in common. "ca805be243216f0ed4d80b65f15c7b9d71e14732" and "91ca53880b9c2120af2a3e22e9d6d4ab1fc2918b" have entirely different histories.
ca805be243
...
91ca53880b
7 changed files with 4 additions and 445 deletions
|
@ -68,8 +68,6 @@
|
|||
"@node-rs/argon2": "^2.0.2",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"mqtt": "^5.13.1",
|
||||
"postgres": "^3.4.5",
|
||||
|
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -17,12 +17,6 @@ importers:
|
|||
'@oslojs/encoding':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
d3-scale:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
d3-shape:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
drizzle-orm:
|
||||
specifier: ^0.43.1
|
||||
version: 0.43.1(postgres@3.4.5)
|
||||
|
|
|
@ -34,7 +34,7 @@ export const sensors = pgTable("sensors", {
|
|||
});
|
||||
|
||||
export const sensorData = pgTable("sensor_data", {
|
||||
uuid: uuid().primaryKey().defaultRandom(),
|
||||
uuid: uuid().primaryKey(),
|
||||
sensor: text("sensor")
|
||||
.references(() => sensors.id)
|
||||
.notNull(),
|
||||
|
@ -42,5 +42,4 @@ export const sensorData = pgTable("sensor_data", {
|
|||
humidity: real().notNull(),
|
||||
pressure: real().notNull(),
|
||||
altitude: real().notNull(),
|
||||
time: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
|
||||
});
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { Thermometer, Droplets, Gauge, Mountain, Cpu, DownloadIcon } from "@lucide/svelte";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Label } from "$lib/components/ui/label/index.js";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import { Thermometer, Droplets, Gauge, Mountain, Cpu } from "@lucide/svelte";
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
|
@ -124,129 +120,9 @@
|
|||
drawFloorplan(data.floor[0].json);
|
||||
}
|
||||
});
|
||||
|
||||
import * as Chart from "$lib/components/ui/chart/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Select from "$lib/components/ui/select/index.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import { scaleUtc } from "d3-scale";
|
||||
import { Area, AreaChart, ChartClipPath } from "layerchart";
|
||||
import { curveNatural } from "d3-shape";
|
||||
import ChartContainer from "$lib/components/ui/chart/chart-container.svelte";
|
||||
import { cubicInOut } from "svelte/easing";
|
||||
import { ChevronDownIcon, Columns2 } from "@lucide/svelte";
|
||||
|
||||
let chartData = $state([]);
|
||||
|
||||
let timeRange = $state("90d");
|
||||
|
||||
const selectedLabel = $derived.by(() => {
|
||||
switch (timeRange) {
|
||||
case "90d":
|
||||
return "Last 3 months";
|
||||
case "30d":
|
||||
return "Last 30 days";
|
||||
case "7d":
|
||||
return "Last 7 days";
|
||||
default:
|
||||
return "Last 3 months";
|
||||
}
|
||||
});
|
||||
|
||||
const filteredData = $derived(
|
||||
chartData.filter((item) => {
|
||||
const now = new Date();
|
||||
let daysToSubtract = 90;
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30;
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7;
|
||||
}
|
||||
|
||||
const cutoffDate = new Date(now.getTime() - daysToSubtract * 24 * 60 * 60 * 1000);
|
||||
return item.date >= cutoffDate;
|
||||
}),
|
||||
);
|
||||
|
||||
const chartConfig = {
|
||||
temperature: { label: "Temperature (°C)", color: "var(--chart-1)" },
|
||||
humidity: { label: "Humidity (%)", color: "var(--chart-2)" },
|
||||
altitude: { label: "Altitude (m)", color: "var(--chart-3)" },
|
||||
pressure: { label: "Pressure (kPa)", color: "var(--chart-4)" },
|
||||
} satisfies Chart.ChartConfig;
|
||||
|
||||
let selectedSensor = $state("temperature");
|
||||
|
||||
const sensorOptions = [
|
||||
{
|
||||
key: "temperature",
|
||||
label: chartConfig.temperature.label,
|
||||
color: chartConfig.temperature.color,
|
||||
},
|
||||
{
|
||||
key: "humidity",
|
||||
label: chartConfig.humidity.label,
|
||||
color: chartConfig.humidity.color,
|
||||
},
|
||||
{
|
||||
key: "pressure",
|
||||
label: chartConfig.pressure.label,
|
||||
color: chartConfig.pressure.color,
|
||||
},
|
||||
{
|
||||
key: "altitude",
|
||||
label: chartConfig.altitude.label,
|
||||
color: chartConfig.altitude.color,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedSensorConfig = $derived(
|
||||
sensorOptions.find((s) => s.key === selectedSensor) || sensorOptions[0],
|
||||
);
|
||||
|
||||
let isLoading = $state(false);
|
||||
let hasError = $state(false);
|
||||
|
||||
let onOpenChange = $state(async (open: boolean) => {
|
||||
console.log("onOpenChange called with:", open);
|
||||
if (open) {
|
||||
console.log("Starting to load statistics...");
|
||||
isLoading = true;
|
||||
hasError = false;
|
||||
try {
|
||||
console.log(`Fetching stats for floor: ${data.slug}`);
|
||||
const response = await fetch(`/${data.slug}/stats`);
|
||||
console.log("Stats response status:", response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
console.log("Raw stats data:", rawData);
|
||||
|
||||
if (!rawData || rawData.length === 0) {
|
||||
console.log("No data available for statistics");
|
||||
chartData = [];
|
||||
} else {
|
||||
chartData = rawData.map((obj: any) => {
|
||||
return { ...obj, date: new Date(obj.date) };
|
||||
});
|
||||
console.log("Processed chart data:", chartData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading statistics:", error);
|
||||
hasError = true;
|
||||
chartData = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-6">
|
||||
<!-- <div style="display: flex; justify-content: center; align-items: center; height: 100%;"> -->
|
||||
<h1 class="mb-6 text-center text-4xl font-bold">Floor {data.slug}</h1>
|
||||
|
||||
{#if data.hasConfig && data.floorConfig}
|
||||
|
@ -333,267 +209,4 @@
|
|||
<canvas bind:this={canvasEl} class="border" width="800" height="600"></canvas>
|
||||
</div>
|
||||
{/if}
|
||||
<Dialog.Root bind:onOpenChange>
|
||||
<Dialog.Trigger class={buttonVariants({ variant: "outline" })}>Statistics</Dialog.Trigger>
|
||||
<Dialog.Content class="sm:max-h-[90%] sm:max-w-[90%]">
|
||||
<Dialog.Header>
|
||||
<div class="grid flex-1 gap-1 text-center sm:text-left">
|
||||
<Card.Title>Statistics for floor {data.slug}</Card.Title>
|
||||
<Card.Description>Showing total data for the last 3 months</Card.Description>
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="rounded-lg">
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" size="sm" {...props}>
|
||||
<Columns2 />
|
||||
<span class="hidden lg:inline">{selectedSensorConfig.label}</span>
|
||||
<span class="lg:hidden">{selectedSensorConfig.label}</span>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-56">
|
||||
<DropdownMenu.RadioGroup bind:value={selectedSensor}>
|
||||
{#each sensorOptions as sensor}
|
||||
<DropdownMenu.RadioItem value={sensor.key} class="capitalize">
|
||||
{sensor.label}
|
||||
</DropdownMenu.RadioItem>
|
||||
{/each}
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<Select.Root type="single" bind:value={timeRange}>
|
||||
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a value">
|
||||
{selectedLabel}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="rounded-xl">
|
||||
<Select.Item value="90d" class="rounded-lg">Last 3 months</Select.Item>
|
||||
<Select.Item value="30d" class="rounded-lg">Last 30 days</Select.Item>
|
||||
<Select.Item value="7d" class="rounded-lg">Last 7 days</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<Button variant="outline" size="sm">
|
||||
<DownloadIcon />
|
||||
<span class="hidden lg:inline">Export Data</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex h-[400px] items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"
|
||||
></div>
|
||||
<p class="text-muted-foreground">Loading statistics...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasError}
|
||||
<div class="flex h-[400px] items-center justify-center">
|
||||
<div class="text-center">
|
||||
<p class="mb-2 text-red-600">Error loading statistics</p>
|
||||
<p class="text-muted-foreground text-sm">Please try again later</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if chartData.length === 0}
|
||||
<div class="flex h-[400px] items-center justify-center">
|
||||
<div class="text-center">
|
||||
<p class="mb-2 text-lg font-semibold">No data available</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Statistics will appear once sensor data is collected
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-xl">{selectedSensorConfig.label}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-4">
|
||||
{#if filteredData.length > 0}
|
||||
<!-- Statistics Summary -->
|
||||
<div class="grid grid-cols-3 gap-4 rounded border p-4 text-center">
|
||||
<div>
|
||||
<div class="text-muted-foreground text-sm font-medium">Latest</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{filteredData[filteredData.length - 1]?.[selectedSensor]?.toFixed(1)}
|
||||
{#if selectedSensor === "temperature"}°C
|
||||
{:else if selectedSensor === "humidity"}%
|
||||
{:else if selectedSensor === "pressure"}kPa
|
||||
{:else if selectedSensor === "altitude"}m
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground text-sm font-medium">Average</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{(
|
||||
filteredData.reduce((sum, item) => sum + item[selectedSensor], 0) /
|
||||
filteredData.length
|
||||
).toFixed(1)}
|
||||
{#if selectedSensor === "temperature"}°C
|
||||
{:else if selectedSensor === "humidity"}%
|
||||
{:else if selectedSensor === "pressure"}kPa
|
||||
{:else if selectedSensor === "altitude"}m
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground text-sm font-medium">Range</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{Math.min(...filteredData.map((d) => d[selectedSensor])).toFixed(1)} - {Math.max(
|
||||
...filteredData.map((d) => d[selectedSensor]),
|
||||
).toFixed(1)}
|
||||
{#if selectedSensor === "temperature"}°C
|
||||
{:else if selectedSensor === "humidity"}%
|
||||
{:else if selectedSensor === "pressure"}kPa
|
||||
{:else if selectedSensor === "altitude"}m
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple SVG Line Chart -->
|
||||
<div class="rounded border bg-white p-4">
|
||||
<svg viewBox="0 0 800 400" class="h-80 w-full">
|
||||
<!-- Chart area -->
|
||||
{#if filteredData.length > 1}
|
||||
{@const minValue = Math.min(...filteredData.map((d) => d[selectedSensor]))}
|
||||
{@const maxValue = Math.max(...filteredData.map((d) => d[selectedSensor]))}
|
||||
{@const valueRange = maxValue - minValue || 1}
|
||||
{@const padding = 30}
|
||||
{@const chartWidth = 800 - padding * 2}
|
||||
{@const chartHeight = 400 - padding * 2}
|
||||
|
||||
<!-- Background grid -->
|
||||
<defs>
|
||||
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
d="M 50 0 L 0 0 0 40"
|
||||
fill="none"
|
||||
stroke="#f0f0f0"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
x={padding}
|
||||
y={padding}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
fill="url(#grid)"
|
||||
/>
|
||||
|
||||
<!-- Y-axis labels -->
|
||||
<text x="25" y={padding + 5} class="fill-gray-600 text-xs" text-anchor="end"
|
||||
>{maxValue.toFixed(1)}</text
|
||||
>
|
||||
<text
|
||||
x="25"
|
||||
y={padding + chartHeight / 2 + 5}
|
||||
class="fill-gray-600 text-xs"
|
||||
text-anchor="end">{((minValue + maxValue) / 2).toFixed(1)}</text
|
||||
>
|
||||
<text
|
||||
x="25"
|
||||
y={padding + chartHeight + 5}
|
||||
class="fill-gray-600 text-xs"
|
||||
text-anchor="end">{minValue.toFixed(1)}</text
|
||||
>
|
||||
|
||||
<!-- X-axis labels -->
|
||||
{#if filteredData.length > 0}
|
||||
{@const firstDate = filteredData[0].date}
|
||||
{@const lastDate = filteredData[filteredData.length - 1].date}
|
||||
{@const midIndex = Math.floor(filteredData.length / 2)}
|
||||
{@const midDate = filteredData[midIndex].date}
|
||||
|
||||
<!-- Format dates based on time range -->
|
||||
{@const formatDate = (date) => {
|
||||
if (timeRange === "7d") {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} else if (timeRange === "30d") {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleDateString("en-US", { month: "short" });
|
||||
}
|
||||
}}
|
||||
|
||||
<text
|
||||
x={padding}
|
||||
y={padding + chartHeight + 20}
|
||||
class="fill-gray-600 text-xs"
|
||||
text-anchor="start">{formatDate(firstDate)}</text
|
||||
>
|
||||
<text
|
||||
x={padding + chartWidth / 2}
|
||||
y={padding + chartHeight + 20}
|
||||
class="fill-gray-600 text-xs"
|
||||
text-anchor="middle">{formatDate(midDate)}</text
|
||||
>
|
||||
<text
|
||||
x={padding + chartWidth}
|
||||
y={padding + chartHeight + 20}
|
||||
class="fill-gray-600 text-xs"
|
||||
text-anchor="end">{formatDate(lastDate)}</text
|
||||
>
|
||||
{/if}
|
||||
|
||||
<!-- Data line -->
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
points={filteredData
|
||||
.map((d, i) => {
|
||||
const x = padding + (i / (filteredData.length - 1)) * chartWidth;
|
||||
const y =
|
||||
padding +
|
||||
chartHeight -
|
||||
((d[selectedSensor] - minValue) / valueRange) * chartHeight;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ")}
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground text-center text-xs">
|
||||
Data from {filteredData[0]?.date?.toLocaleDateString()} to {filteredData[
|
||||
filteredData.length - 1
|
||||
]?.date?.toLocaleDateString()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-[400px] items-center justify-center">
|
||||
<div class="text-center">
|
||||
<p class="mb-2 text-lg font-semibold">No data available</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
No data found for the selected time period
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer></Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
import { db } from "$lib/server/db";
|
||||
import * as table from "$lib/server/db/schema"
|
||||
|
||||
export const GET = async () => {
|
||||
// Get all sensor data (like the original working version)
|
||||
const rawData = await db.select({
|
||||
altitude: table.sensorData.altitude,
|
||||
humidity: table.sensorData.humidity,
|
||||
pressure: table.sensorData.pressure,
|
||||
temperature: table.sensorData.temperature,
|
||||
date: table.sensorData.time,
|
||||
}).from(table.sensorData);
|
||||
|
||||
// Scale pressure values to be in a similar range as other sensors
|
||||
// Divide by 1000 to convert from Pa to kPa (more reasonable scale)
|
||||
const data = rawData.map(item => ({
|
||||
...item,
|
||||
pressure: Math.round((item.pressure / 1000) * 10) / 10 // Convert to kPa with 1 decimal place
|
||||
}));
|
||||
|
||||
console.log(`Returning ${data.length} data points`);
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
|
@ -81,9 +81,6 @@ export const actions = {
|
|||
|
||||
try {
|
||||
const deviceData = JSON.parse(devices);
|
||||
deviceData.forEach(async (dev) => {
|
||||
await db.insert(table.sensors).values({ id: dev.id, user: event.locals.session.userId });
|
||||
});
|
||||
|
||||
// Check if floor exists
|
||||
const exists = await db
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
// src/routes/mqtt/+server.js
|
||||
import { db } from "$lib/server/db";
|
||||
import * as table from "$lib/server/db/schema";
|
||||
import { connectedDevices, deviceSensorData, getCurrentDevices } from "$lib/server/mqtt-devices.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import mqtt from "mqtt";
|
||||
import * as mqtt from "mqtt";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
// A Svelte store to hold the latest MQTT message.
|
||||
|
@ -117,7 +114,7 @@ function connectMqtt() {
|
|||
});
|
||||
});
|
||||
|
||||
client.on("message", async (topic, message) => {
|
||||
client.on("message", (topic, message) => {
|
||||
const payload = message.toString();
|
||||
console.log(`Received message from topic "${topic}": ${payload}`);
|
||||
latestMessage.set(payload); // Update the Svelte store
|
||||
|
@ -133,17 +130,6 @@ function connectMqtt() {
|
|||
if (sensorData) {
|
||||
console.log(`Parsed sensor data:`, sensorData);
|
||||
updateDevice(deviceId, sensorData);
|
||||
const devices = await db.select().from(table.sensors).where(eq(table.sensors.id, deviceId));
|
||||
if (devices.length == 1)
|
||||
await db
|
||||
.insert(table.sensorData)
|
||||
.values({
|
||||
sensor: deviceId,
|
||||
temperature: sensorData.temperature,
|
||||
humidity: sensorData.humidity,
|
||||
altitude: sensorData.altitude,
|
||||
pressure: sensorData.pressure,
|
||||
});
|
||||
} else {
|
||||
// Still update device as online even if data parsing failed
|
||||
updateDevice(deviceId);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue