Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## Contract or migration changes

Complete this section only when the pull request changes an HTTP contract or PocketBase migration.

- [ ] Migration updated (if persistent storage changed)
- [ ] Generated artifacts refreshed with `bun run generate`
- [ ] Consumer verified
29 changes: 29 additions & 0 deletions .github/workflows/contracts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Contract pipeline

on:
pull_request:
push:
branches: [dev, release]

jobs:
generated-artifacts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.8
- run: bun install --frozen-lockfile
- run: bun run generate
- run: git diff --exit-code -- apps/web/public/openapi.json apps/web/src/lib/api packages/api-types/src/openapi.ts

contract-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.8
- run: bun install --frozen-lockfile
- run: bun run check
- run: bun run test:contract
62 changes: 1 addition & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,67 +34,7 @@ bun run dev
The PocketBase backend can be either run using the aio container (default method) or connected to an external database (not recommended, migrations may fail)

## Configuration
You can use the following environment variables for the all-in-one container - default values will also work:

### Core Settings
| Name | Required | Default Value | Description |
| --- | --- | --- | --- |
| INSTANCE_NAME / NEXT_PUBLIC_INSTANCE_NAME | No | Dashwise | The dashboard's display name |
| PB_URL / NEXT_PUBLIC_PB_URL | No (if start pocketbase is true) | `http://127.0.0.1:8090` | PocketBase URL. Backend uses `PB_URL`, frontend uses `NEXT_PUBLIC_PB_URL` |
| START_POCKETBASE | No | `true` | Start the bundled PocketBase process; set to `false` to use an external instance |
| PB_BINARY_PATH | No | - | Path to PocketBase binary (default: `pocketbase/pocketbase`) |
| PORT | No | `3000` | HTTP port for the backend server |

### Authentication
| Name | Required | Default Value | Description |
| --- | --- | --- | --- |
| PB_ADMIN_EMAIL | Yes | `default@dashwise.local` | Email of the PocketBase admin user |
| PB_ADMIN_PASSWORD | Yes | `DashwiseIsAwesome` | Password of the PocketBase admin user |

### URLs
| Name | Required | Default Value | Description |
| --- | --- | --- | --- |
| NEXT_PUBLIC_APP_URL / APP_BASE_URL | No | `http://localhost:3000` | Public URL of the application |
| NEXT_PUBLIC_BACKEND_URL | No | - | Backend URL for frontend API calls (fallback: window.location.origin in production) |
| DASHWISE_URL | No | - | Internal Dashwise URL for jobs container communication |

### Appearance & Features
| Name | Required | Default Value | Description |
| --- | --- | --- | --- |
| NEXT_PUBLIC_DEFAULT_BG_URL / DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL for new users |
| NEXT_PUBLIC_ENABLE_SSO / ENABLE_SSO | No | `false` | Enable Single Sign-On (SSO) via OIDC |
| NEXT_PUBLIC_DISABLE_USER_SIGNUP / DISABLE_USER_SIGNUP | No | `false` | Disable user self-registration |

### SSL/TLS
| Name | Required | Default Value | Description |
| --- | --- | --- | --- |
| NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL / ALLOW_INSECURE_CERTS_FOR_INTEGRATION_URLS | No | `false` | Allow insecure SSL certificates for integration URLs |
| ALLOW_SSL | No | `false` | Enable SSL for internal service communication |
| LOG_LEVEL / BACKEND_LOG_LEVEL | No | - | Backend log level (debug, info, warn, error) |

### Jobs & Background Processing
| Name | Required | Default Value | Description |
| --- | --- | --- | --- |
| JOBS_URL / NEXT_PUBLIC_JOBS_URL | No | `http://127.0.0.1:3001` | URL of the jobs service |
| JOBS_WEBHOOK_ENABLE / NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE | No | `false` | Explicitly enable the jobs webhook. Set to `1` or `true` to force-enable |
| JOBS_WEBHOOK_URL | No | `http://jobs:3000/api/forward-notifications` | Webhook URL for forwarding notifications to jobs |
| JOBS_MONITORING_RETRY_AFTER | No | `5000` | Time in milliseconds to wait before retrying a failed monitoring ping |

### Scheduled Jobs (Cron Expressions)
| Name | Required | Default Value | Description |
| --- | --- | --- | --- |
| SEARCHITEMS_SCHEDULE | No | `*/10 * * * *` | Interval for search item indexing job |
| ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job |
| PULL_ICONS_SCHEDULE | No | `0 */6 * * *` | How often the icons refresh job runs |
| MONITORING_INDEXER_SCHEDULE | No | `*/10 * * * *` | How often the monitoring indexer runs |
| MONITORING_RUNNER_SCHEDULE | No | `*/1 * * * *` | How often the monitoring runner executes |
| UPDATE_CHECK_SCHEDULE | No | `0 2 * * *` | Schedule for update check job |
| FEED_BUILDING_SCHEDULE | No | `*/30 * * * *` | Schedule for news feed building job |
| NOTIFICATION_FORWARDER_SCHEDULE | No | `* * * * *` | Schedule for notification forwarder job |
| DEFAULT_INTEGRATIONS_SCHEDULE | No | `0 4 * * *` | Schedule for default integrations sync |
| PAGECONFIG_CLEANUP_SCHEDULE | No | `0 5 * * *` | Schedule for page config cleanup |
| MONITORING_OUTLIER_THRESHOLD_TYPE | No | `relative` | Threshold type for monitoring outliers (`absolute` or `relative`) |
| MONITORING_OUTLIER_THRESHOLD_VALUE | No | `50` | Threshold value for monitoring outliers |
See the [configuration reference](docs/Configuration.md) for all supported environment variables.

## Tech Stack
Frontend: React SPA bundled with Bun
Expand Down
28 changes: 26 additions & 2 deletions apps/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,46 @@ tags:
- name: widgets
- name: weather
paths:
# DO NOT EDIT — generated by bun run generate from system.contract.ts
# BEGIN GENERATED SYSTEM CONTRACTS
/appConfig:
get:
tags:
- app
summary: Get application config
responses:
"200":
$ref: "#/components/responses/JsonOk"
description: OK
content:
application/json:
schema:
type: object
additionalProperties: {}
/appInfo:
get:
tags:
- app
summary: Get application info
responses:
"200":
$ref: "#/components/responses/JsonOk"
description: OK
content:
application/json:
schema:
type: object
properties:
updateAvailable:
type: boolean
currentAppVersion:
type: string
userSignupDisabled:
type: boolean
required:
- updateAvailable
- currentAppVersion
- userSignupDisabled
additionalProperties: false
# END GENERATED SYSTEM CONTRACTS
/auth/callback:
get:
tags:
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun src/index.ts",
"generate:openapi": "bun ../../scripts/generate-openapi-json.ts && bunx openapi-typescript openapi.yaml -o ../../packages/api-types/src/openapi.ts",
"generate": "bun --cwd ../.. run generate",
"generate:openapi": "bun run generate",
"build": "bunx -p typescript@5.9.3 tsc -p tsconfig.json",
"lint": "eslint . --quiet",
"lint:all": "eslint ."
Expand Down
21 changes: 21 additions & 0 deletions apps/backend/src/features/system/system.contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from "zod";

import { defineGetContract, emptyQuerySchema } from "../../lib/http/contract";

export const appConfigContract = defineGetContract({
summary: "Get application config",
tags: ["app"],
query: emptyQuerySchema,
response: z.record(z.unknown()),
});

export const appInfoContract = defineGetContract({
summary: "Get application info",
tags: ["app"],
query: emptyQuerySchema,
response: z.object({
updateAvailable: z.boolean(),
currentAppVersion: z.string(),
userSignupDisabled: z.boolean(),
}),
});
4 changes: 3 additions & 1 deletion apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ app.use("*", async (c, next) => {
await next();
} finally {
const processingTime = Math.round(performance.now() - startedAt);
logger.info(`[${timestamp}] ${c.req.method} ${c.req.path} ${processingTime}ms`);
if (config.LOG_LEVEL?.trim().toLowerCase() === "info") {
logger.info(`[${timestamp}] ${c.req.method} ${c.req.path} ${processingTime}ms`);
}
}
});

Expand Down
34 changes: 34 additions & 0 deletions apps/backend/src/lib/http/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { z, type ZodTypeAny } from "zod";

export type GetContract<Response extends ZodTypeAny> = {
summary: string;
tags: string[];
query: ZodTypeAny;
response: Response;
};

export function defineGetContract<Response extends ZodTypeAny>(
contract: GetContract<Response>,
) {
return contract;
}

export function shouldValidateContractResponses() {
return Bun.env.NODE_ENV !== "production";
}

export function validateContractResponse<Response extends ZodTypeAny>(
contract: GetContract<Response>,
value: unknown,
) {
return shouldValidateContractResponses() ? contract.response.parse(value) : value;
}

export function validateContractQuery(
contract: GetContract<ZodTypeAny>,
value: unknown,
) {
return contract.query.parse(value);
}

export const emptyQuerySchema = z.object({}).strict();
19 changes: 18 additions & 1 deletion apps/backend/src/routes/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import type { Context } from "hono";
import { ApiActionError, requireUserAuth } from "../lib/data/auth";
import { z } from "zod";

import {
type GetContract,
validateContractQuery,
validateContractResponse,
} from "../lib/http/contract";

import { config } from "../lib/config";
import { defaultHomeConfig } from "@dashwise/assets";

Expand Down Expand Up @@ -77,6 +83,17 @@ export function withJson<C extends (import("hono").Context<any, any, any>) = imp
};
}

/** Validate request input at the route boundary and responses outside production. */
export function withGetContract<Response extends z.ZodTypeAny>(
contract: GetContract<Response>,
handler: (c: Context) => Promise<unknown> | unknown,
) {
return withJson(async (c) => {
validateContractQuery(contract, c.req.query());
return validateContractResponse(contract, await handler(c));
});
}

export function readAuthToken(c: Context) {
const authorization = c.req.header("authorization") ?? c.req.header("Authorization");
if (authorization?.toLowerCase().startsWith("bearer ")) {
Expand Down Expand Up @@ -111,4 +128,4 @@ export function routeRedirectTarget(c: Context) {
}

return config.DASHWISE_URL;
}
}
10 changes: 7 additions & 3 deletions apps/backend/src/routes/system.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { getLocations } from "../lib/data/misc";
import { runPullIcons } from "../lib/data/jobs";

import { jobsApi, validateJobsBasicAuth } from "../jobs/index";
import { readAuthToken, requireAuth, withJson } from "./shared";
import { readAuthToken, requireAuth, withGetContract, withJson } from "./shared";
import {
appConfigContract,
appInfoContract,
} from "../features/system/system.contract";

const systemRoute = new Hono();

systemRoute.get("/api/v1/appConfig", withJson(() => getAppConfig()));
systemRoute.get("/api/v1/appInfo", withJson(() => getAppInfo()));
systemRoute.get("/api/v1/appConfig", withGetContract(appConfigContract, () => getAppConfig()));
systemRoute.get("/api/v1/appInfo", withGetContract(appInfoContract, () => getAppInfo()));

systemRoute.get(
"/api/v1/locations",
Expand Down
9 changes: 5 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
"private": true,
"scripts": {
"dev": "bunx vite --host 0.0.0.0 --port 5173",
"build": "bun run generate:openapi && bun run generate:api-sdk && bunx vite build",
"build": "bunx vite build",
"start": "bunx vite preview --host 0.0.0.0 --port 4173",
"lint": "eslint . --quiet",
"lint:all": "eslint .",
"generate:openapi": "bun ../../scripts/generate-openapi-json.ts && bunx openapi-typescript ../../apps/backend/openapi.yaml -o ../../packages/api-types/src/openapi.ts",
"generate:api-sdk": "bunx openapi-ts -f openapi-ts.config.ts",
"generate:api-types": "bun run generate:openapi"
"generate": "bun --cwd ../.. run generate",
"generate:openapi": "bun run generate",
"generate:api-sdk": "bun run generate",
"generate:api-types": "bun run generate"
},
"dependencies": {
"@dashwise/api-types": "workspace:*",
Expand Down
36 changes: 34 additions & 2 deletions apps/web/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,15 @@
"summary": "Get application config",
"responses": {
"200": {
"$ref": "#/components/responses/JsonOk"
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {}
}
}
}
}
}
}
Expand All @@ -82,7 +90,31 @@
"summary": "Get application info",
"responses": {
"200": {
"$ref": "#/components/responses/JsonOk"
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"updateAvailable": {
"type": "boolean"
},
"currentAppVersion": {
"type": "string"
},
"userSignupDisabled": {
"type": "boolean"
}
},
"required": [
"updateAvailable",
"currentAppVersion",
"userSignupDisabled"
],
"additionalProperties": false
}
}
}
}
}
}
Expand Down
23 changes: 13 additions & 10 deletions apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,16 +517,19 @@ export default function DashboardLayoutTemplate({
} else if (typeof h === "number") {
heightStyle = `${h}px`;
}
return renderWidgetMenuWrapper({
baseKey,
wrapperClass,
style: heightStyle ? { height: heightStyle } : undefined,
children: renderWidget({
type: "placeholder",
params: cfg.params,
className: "h-full w-full",
}),
});
return (
<div
key={baseKey}
className={wrapperClass}
style={heightStyle ? { height: heightStyle } : undefined}
>
{renderWidget({
type: "placeholder",
params: cfg.params,
className: "h-full w-full",
})}
</div>
);
}
case "main-clock": {
const ref = getHeightRefCallback("main-clock");
Expand Down
Loading
Loading