Compare commits

..

3 Commits

Author SHA1 Message Date
39ced53900 refactor: remove backend changes from dashboard PR
Backend CORS/CSRF changes moved to #92.
Source registry removed (sources hardcoded in frontend).

Co-authored-by: Ona <no-reply@ona.com>
2026-03-23 00:23:11 +00:00
c1d9ec9399 fix: use useQuery instead of getQueryData
Co-authored-by: Ona <no-reply@ona.com>
2026-03-23 00:17:33 +00:00
34214f5f3e feat: add admin dashboard app
- React + Vite + TanStack Router + TanStack Query
- Auth with better-auth (login, session, admin guard)
- Source config management (WeatherKit credentials, user config)
- Feed query panel
- Location push card
- General settings with health check
- CORS middleware for cross-origin auth
- Disable CSRF check in dev mode
- Sonner toasts for mutation feedback

Co-authored-by: Ona <no-reply@ona.com>
2026-03-23 00:14:59 +00:00
13 changed files with 57 additions and 285 deletions

View File

@@ -11,7 +11,7 @@
"dockerfile": "Dockerfile" "dockerfile": "Dockerfile"
}, },
"postCreateCommand": "bun install", "postCreateCommand": "bun install",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh && ./scripts/setup-tailscale.sh", "postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh",
// Features add additional features to your environment. See https://containers.dev/features // Features add additional features to your environment. See https://containers.dev/features
// Beware: features are not supported on all platforms and may have unintended side-effects. // Beware: features are not supported on all platforms and may have unintended side-effects.
"features": { "features": {

View File

@@ -17,23 +17,3 @@ services:
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||') FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443" echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983 cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
aelis-backend:
name: Aelis Backend
description: Hono API server for aelis-backend (port 3000)
triggeredBy:
- manual
commands:
start: |
gitpod --context environment environment port open 3000 --name "Aelis Backend" --protocol http
cd apps/aelis-backend && bun run dev
admin-dashboard:
name: Admin Dashboard
description: Vite dev server for admin-dashboard (port 5174)
triggeredBy:
- manual
commands:
start: |
gitpod --context environment environment port open 5174 --name "Admin Dashboard" --protocol http
cd apps/admin-dashboard && bun run dev --host

View File

@@ -47,15 +47,10 @@ export const Route = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
id: "dashboard", id: "dashboard",
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
let session: Awaited<ReturnType<typeof getSession>> | null = null const session = await context.queryClient.ensureQueryData({
try { queryKey: ["session"],
session = await context.queryClient.ensureQueryData({ queryFn: getSession,
queryKey: ["session"], })
queryFn: getSession,
})
} catch {
throw redirect({ to: "/login" })
}
if (!session?.user) { if (!session?.user) {
throw redirect({ to: "/login" }) throw redirect({ to: "/login" })
} }

View File

@@ -16,9 +16,6 @@ export function createAuth(db: Database) {
provider: "pg", provider: "pg",
schema, schema,
}), }),
advanced: {
disableCSRFCheck: process.env.NODE_ENV !== "production",
},
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },

View File

@@ -46,7 +46,7 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
type: "json_schema" as const, type: "json_schema" as const,
jsonSchema: { jsonSchema: {
name: "enhancement_result", name: "enhancement_result",
strict: false, strict: true,
schema: enhancementResultJsonSchema, schema: enhancementResultJsonSchema,
}, },
}, },

View File

@@ -166,12 +166,11 @@ describe("schema sync", () => {
expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull() expect(parseEnhancementResult(JSON.stringify(bad))).toBeNull()
// JSON Schema only allows string or null for slot values // JSON Schema only allows string or null for slot values
const slotValueSchema = const slotValueTypes =
enhancementResultJsonSchema.properties.slotFills.additionalProperties enhancementResultJsonSchema.properties.slotFills.additionalProperties
.additionalProperties .additionalProperties.type
expect(slotValueSchema.anyOf).toEqual([ expect(slotValueTypes).toContain("string")
{ type: "string" }, expect(slotValueTypes).toContain("null")
{ type: "null" }, expect(slotValueTypes).not.toContain("number")
])
}) })
}) })

View File

@@ -31,7 +31,7 @@ export const enhancementResultJsonSchema = {
additionalProperties: { additionalProperties: {
type: "object", type: "object",
additionalProperties: { additionalProperties: {
anyOf: [{ type: "string" }, { type: "null" }], type: ["string", "null"],
}, },
}, },
}, },

View File

@@ -1,5 +1,4 @@
import { Hono } from "hono" import { Hono } from "hono"
import { cors } from "hono/cors"
import { registerAdminHttpHandlers } from "./admin/http.ts" import { registerAdminHttpHandlers } from "./admin/http.ts"
import { createRequireAdmin } from "./auth/admin-middleware.ts" import { createRequireAdmin } from "./auth/admin-middleware.ts"
@@ -51,34 +50,6 @@ function main() {
const app = new Hono() const app = new Hono()
const isDev = process.env.NODE_ENV !== "production"
const allowedOrigins = process.env.CORS_ORIGINS?.split(",").map((o) => o.trim()) ?? []
function resolveOrigin(origin: string): string | undefined {
if (isDev) return origin
return allowedOrigins.includes(origin) ? origin : undefined
}
app.use(
"/api/auth/*",
cors({
origin: resolveOrigin,
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["POST", "GET", "OPTIONS"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
credentials: true,
}),
)
app.use(
"*",
cors({
origin: resolveOrigin,
credentials: true,
}),
)
app.get("/health", (c) => c.json({ status: "ok" })) app.get("/health", (c) => c.json({ status: "ok" }))
const authSessionMiddleware = createRequireSession(auth) const authSessionMiddleware = createRequireSession(auth)

View File

@@ -32,7 +32,7 @@ export interface CurrentWeatherFeedItem extends FeedItem<
CurrentWeatherData CurrentWeatherData
> {} > {}
export type HourlyWeatherEntry = { export type HourlyWeatherData = {
forecastTime: Date forecastTime: Date
conditionCode: ConditionCode conditionCode: ConditionCode
daylight: boolean daylight: boolean
@@ -48,16 +48,12 @@ export type HourlyWeatherEntry = {
windSpeed: number windSpeed: number
} }
export type HourlyWeatherData = {
hours: HourlyWeatherEntry[]
}
export interface HourlyWeatherFeedItem extends FeedItem< export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Hourly, typeof WeatherFeedItemType.Hourly,
HourlyWeatherData HourlyWeatherData
> {} > {}
export type DailyWeatherEntry = { export type DailyWeatherData = {
forecastDate: Date forecastDate: Date
conditionCode: ConditionCode conditionCode: ConditionCode
maxUvIndex: number maxUvIndex: number
@@ -71,10 +67,6 @@ export type DailyWeatherEntry = {
temperatureMin: number temperatureMin: number
} }
export type DailyWeatherData = {
days: DailyWeatherEntry[]
}
export interface DailyWeatherFeedItem extends FeedItem< export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.Daily, typeof WeatherFeedItemType.Daily,
DailyWeatherData DailyWeatherData

View File

@@ -8,10 +8,8 @@ export {
type CurrentWeatherData, type CurrentWeatherData,
type HourlyWeatherFeedItem, type HourlyWeatherFeedItem,
type HourlyWeatherData, type HourlyWeatherData,
type HourlyWeatherEntry,
type DailyWeatherFeedItem, type DailyWeatherFeedItem,
type DailyWeatherData, type DailyWeatherData,
type DailyWeatherEntry,
type WeatherAlertFeedItem, type WeatherAlertFeedItem,
type WeatherAlertData, type WeatherAlertData,
} from "./feed-items" } from "./feed-items"

View File

@@ -4,10 +4,10 @@ import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit" import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json" import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items" import { WeatherFeedItemType } from "./feed-items"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey, type Weather } from "./weather-context"
import { WeatherSource, Units } from "./weather-source" import { WeatherSource, Units } from "./weather-source"
@@ -131,125 +131,8 @@ describe("WeatherSource", () => {
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly) const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily) const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
expect(hourlyItems.length).toBe(1) expect(hourlyItems.length).toBe(3)
expect((hourlyItems[0]!.data as HourlyWeatherData).hours.length).toBe(3) expect(dailyItems.length).toBe(2)
expect(dailyItems.length).toBe(1)
expect((dailyItems[0]!.data as DailyWeatherData).days.length).toBe(2)
})
test("produces a single hourly item with hours array", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.Hourly)
expect(hourlyItems.length).toBe(1)
const hourlyData = hourlyItems[0]!.data as HourlyWeatherData
expect(Array.isArray(hourlyData.hours)).toBe(true)
expect(hourlyData.hours.length).toBeGreaterThan(0)
expect(hourlyData.hours.length).toBeLessThanOrEqual(12)
})
test("averages urgency across hours with mixed conditions", async () => {
const mildHour: HourlyForecast = {
forecastStart: "2026-01-17T01:00:00Z",
conditionCode: "Clear",
daylight: true,
humidity: 0.5,
precipitationAmount: 0,
precipitationChance: 0,
precipitationType: "clear",
pressure: 1013,
snowfallIntensity: 0,
temperature: 20,
temperatureApparent: 20,
temperatureDewPoint: 10,
uvIndex: 3,
visibility: 20000,
windDirection: 180,
windGust: 10,
windSpeed: 5,
}
const severeHour: HourlyForecast = {
...mildHour,
forecastStart: "2026-01-17T02:00:00Z",
conditionCode: "SevereThunderstorm",
}
const mixedResponse: WeatherKitResponse = {
forecastHourly: { hours: [mildHour, severeHour] },
}
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourlyItem = items.find((i) => i.type === WeatherFeedItemType.Hourly)
expect(hourlyItem).toBeDefined()
// Mild urgency = 0.3, severe urgency = 0.6, average = 0.45
expect(hourlyItem!.signals!.urgency).toBeCloseTo(0.45, 5)
// Worst-case: SevereThunderstorm → Imminent
expect(hourlyItem!.signals!.timeRelevance).toBe("imminent")
})
test("produces a single daily item with days array", async () => {
const source = new WeatherSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItems.length).toBe(1)
const dailyData = dailyItems[0]!.data as DailyWeatherData
expect(Array.isArray(dailyData.days)).toBe(true)
expect(dailyData.days.length).toBeGreaterThan(0)
expect(dailyData.days.length).toBeLessThanOrEqual(7)
})
test("averages urgency across days with mixed conditions", async () => {
const mildDay: DailyForecast = {
forecastStart: "2026-01-17T00:00:00Z",
forecastEnd: "2026-01-18T00:00:00Z",
conditionCode: "Clear",
maxUvIndex: 3,
moonPhase: "firstQuarter",
precipitationAmount: 0,
precipitationChance: 0,
precipitationType: "clear",
snowfallAmount: 0,
sunrise: "2026-01-17T07:00:00Z",
sunriseCivil: "2026-01-17T06:30:00Z",
sunriseNautical: "2026-01-17T06:00:00Z",
sunriseAstronomical: "2026-01-17T05:30:00Z",
sunset: "2026-01-17T17:00:00Z",
sunsetCivil: "2026-01-17T17:30:00Z",
sunsetNautical: "2026-01-17T18:00:00Z",
sunsetAstronomical: "2026-01-17T18:30:00Z",
temperatureMax: 15,
temperatureMin: 5,
}
const severeDay: DailyForecast = {
...mildDay,
forecastStart: "2026-01-18T00:00:00Z",
forecastEnd: "2026-01-19T00:00:00Z",
conditionCode: "SevereThunderstorm",
}
const mixedResponse: WeatherKitResponse = {
forecastDaily: { days: [mildDay, severeDay] },
}
const source = new WeatherSource({ client: createMockClient(mixedResponse) })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const dailyItem = items.find((i) => i.type === WeatherFeedItemType.Daily)
expect(dailyItem).toBeDefined()
// Mild urgency = 0.2, severe urgency = 0.5, average = 0.35
expect(dailyItem!.signals!.urgency).toBeCloseTo(0.35, 5)
// Worst-case: SevereThunderstorm → Imminent
expect(dailyItem!.signals!.timeRelevance).toBe("imminent")
}) })
test("sets timestamp from context.time", async () => { test("sets timestamp from context.time", async () => {

View File

@@ -3,7 +3,7 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core" import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items"
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt" import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey, type Weather } from "./weather-context"
import { import {
@@ -174,15 +174,21 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
if (response.forecastHourly?.hours) { if (response.forecastHourly?.hours) {
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit) const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
if (hours.length > 0) { for (let i = 0; i < hours.length; i++) {
items.push(createHourlyForecastFeedItem(hours, timestamp, this.units, this.id)) const hour = hours[i]
if (hour) {
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units, this.id))
}
} }
} }
if (response.forecastDaily?.days) { if (response.forecastDaily?.days) {
const days = response.forecastDaily.days.slice(0, this.dailyLimit) const days = response.forecastDaily.days.slice(0, this.dailyLimit)
if (days.length > 0) { for (let i = 0; i < days.length; i++) {
items.push(createDailyForecastFeedItem(days, timestamp, this.units, this.id)) const day = days[i]
if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
}
} }
} }
@@ -317,18 +323,24 @@ function createCurrentWeatherFeedItem(
} }
} }
function createHourlyForecastFeedItem( function createHourlyWeatherFeedItem(
hourlyForecasts: HourlyForecast[], hourly: HourlyForecast,
index: number,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string, sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const hours: HourlyWeatherEntry[] = [] const signals: FeedItemSignals = {
let totalUrgency = 0 urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
}
for (const hourly of hourlyForecasts) { return {
hours.push({ id: `weather-hourly-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Hourly,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart), forecastTime: new Date(hourly.forecastStart),
conditionCode: hourly.conditionCode, conditionCode: hourly.conditionCode,
daylight: hourly.daylight, daylight: hourly.daylight,
@@ -342,43 +354,29 @@ function createHourlyForecastFeedItem(
windDirection: hourly.windDirection, windDirection: hourly.windDirection,
windGust: convertSpeed(hourly.windGust, units), windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units), windSpeed: convertSpeed(hourly.windSpeed, units),
}) },
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode)
const rel = timeRelevanceForCondition(hourly.conditionCode)
if (rel === TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Imminent
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Upcoming
}
}
const signals: FeedItemSignals = {
urgency: totalUrgency / hours.length,
timeRelevance: worstTimeRelevance,
}
return {
id: `weather-hourly-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Hourly,
timestamp,
data: { hours },
signals, signals,
} }
} }
function createDailyForecastFeedItem( function createDailyWeatherFeedItem(
dailyForecasts: DailyForecast[], daily: DailyForecast,
index: number,
timestamp: Date, timestamp: Date,
units: Units, units: Units,
sourceId: string, sourceId: string,
): WeatherFeedItem { ): WeatherFeedItem {
const days: DailyWeatherEntry[] = [] const signals: FeedItemSignals = {
let totalUrgency = 0 urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
let worstTimeRelevance: TimeRelevance = TimeRelevance.Ambient timeRelevance: timeRelevanceForCondition(daily.conditionCode),
}
for (const daily of dailyForecasts) { return {
days.push({ id: `weather-daily-${timestamp.getTime()}-${index}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart), forecastDate: new Date(daily.forecastStart),
conditionCode: daily.conditionCode, conditionCode: daily.conditionCode,
maxUvIndex: daily.maxUvIndex, maxUvIndex: daily.maxUvIndex,
@@ -390,27 +388,7 @@ function createDailyForecastFeedItem(
sunset: new Date(daily.sunset), sunset: new Date(daily.sunset),
temperatureMax: convertTemperature(daily.temperatureMax, units), temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units), temperatureMin: convertTemperature(daily.temperatureMin, units),
}) },
totalUrgency += adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode)
const rel = timeRelevanceForCondition(daily.conditionCode)
if (rel === TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Imminent
} else if (rel === TimeRelevance.Upcoming && worstTimeRelevance !== TimeRelevance.Imminent) {
worstTimeRelevance = TimeRelevance.Upcoming
}
}
const signals: FeedItemSignals = {
urgency: totalUrgency / days.length,
timeRelevance: worstTimeRelevance,
}
return {
id: `weather-daily-${timestamp.getTime()}`,
sourceId,
type: WeatherFeedItemType.Daily,
timestamp,
data: { days },
signals, signals,
} }
} }

View File

@@ -1,21 +0,0 @@
#!/bin/bash
# Tailscale setup script
# Authenticates with Tailscale if TS_AUTH_KEY is set and Tailscale is not already logged in
set -e
if [ -z "$TS_AUTH_KEY" ]; then
echo "TS_AUTH_KEY is not set, skipping Tailscale login."
exit 0
fi
STATUS=$(tailscale status 2>&1 || true)
if echo "$STATUS" | grep -qi "logged out\|stopped"; then
echo "Tailscale is not authenticated. Logging in..."
sudo tailscale up --accept-routes --auth-key="$TS_AUTH_KEY"
echo "Tailscale login complete."
else
echo "Tailscale is already authenticated, skipping."
fi