Compare commits

...

20 Commits

Author SHA1 Message Date
fc001b4dfb ci: update grafana dashboard
All checks were successful
Build and Push Docker Image / tests (push) Successful in 39s
Build and Push Docker Image / build (push) Successful in 4m16s
2024-11-12 23:18:17 +01:00
235c7c1ef4 feat(logger): get service name via env var, hide logs on tests
All checks were successful
Build and Push Docker Image / tests (push) Successful in 1m4s
Build and Push Docker Image / build (push) Successful in 4m15s
2024-11-12 23:08:44 +01:00
0d0c4c8469 chore: add missing validation 2024-11-12 23:07:19 +01:00
9847fdb70c chore(api): fix build command
Some checks failed
Build and Push Docker Image / tests (push) Successful in 41s
Build and Push Docker Image / build (push) Failing after 1m1s
2024-11-12 22:05:29 +01:00
82558ff06a chore(app): fix build errors 2024-11-12 22:05:11 +01:00
32e5d3bb8d feat(api): add log on authentication error
Some checks failed
Build and Push Docker Image / tests (push) Successful in 49s
Build and Push Docker Image / build (push) Failing after 1m6s
2024-11-12 21:56:49 +01:00
6c5f7c08b5 ci: extend grafana logs dashboard 2024-11-12 21:56:28 +01:00
0787122c25 ci: refactor docker-compose files 2024-11-12 21:34:56 +01:00
8309d3a92c chore(api): fix logger issue 2024-11-12 21:34:32 +01:00
f284f74734 feat(api): improve logging with loki and grafana 2024-11-12 21:08:40 +01:00
33cfe91461 chore: remove test cli 2024-11-12 21:08:10 +01:00
7f6a0f36e9 feat(api): add page tests
Some checks failed
Build and Push Docker Image / tests (push) Successful in 1m9s
Build and Push Docker Image / build (push) Failing after 1m15s
2024-11-12 18:51:30 +01:00
377942fe3c feat(api): add access-token tests 2024-11-12 17:33:20 +01:00
4046c31fbd ci: update docker-compose.yaml
All checks were successful
Build and Push Docker Image / tests (push) Successful in 54s
Build and Push Docker Image / build (push) Successful in 4m50s
2024-11-12 16:17:16 +01:00
fc125519b9 fix(api): user routes 2024-11-12 16:16:55 +01:00
e8d790d799 chore(api): remove console.logs 2024-11-12 16:16:31 +01:00
618a2f67a4 ci: update biome settings 2024-11-12 16:16:08 +01:00
5ae746346f feat(app): improve clerk auth 2024-11-12 16:15:33 +01:00
154df360ac feat(app): refactor page create/update forms
All checks were successful
Build and Push Docker Image / tests (push) Successful in 57s
Build and Push Docker Image / build (push) Successful in 4m30s
2024-11-11 21:58:38 +01:00
83b615d989 feat(app): refactor changelog create/update forms 2024-11-11 21:23:52 +01:00
38 changed files with 1683 additions and 481 deletions

View File

@ -2,7 +2,7 @@
"name": "@boring.tools/api",
"scripts": {
"dev": "bun run --hot src/index.ts",
"build": "bun build --entrypoints ./src/index.ts --outdir ../../build/api --target bun --splitting --sourcemap=linked",
"build": "bun build --entrypoints ./src/index.ts --outdir ../../build/api --target bun --splitting",
"test": "bun test --preload ./src/index.ts"
},
"dependencies": {

View File

@ -0,0 +1,151 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
import { access_token, db, user } from '@boring.tools/database'
import type {
AccessTokenCreateInput,
AccessTokenListOutput,
AccessTokenOutput,
ChangelogCreateInput,
ChangelogCreateOutput,
ChangelogListOutput,
ChangelogOutput,
UserOutput,
} from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import { fetch } from '../utils/testing/fetch'
describe('AccessToken', () => {
let testUser: z.infer<typeof UserOutput>
let testAccessToken: z.infer<typeof AccessTokenOutput>
let createdAccessToken: z.infer<typeof AccessTokenOutput>
let testChangelog: z.infer<typeof ChangelogOutput>
beforeAll(async () => {
const createdUser = await db
.insert(user)
.values({ email: 'access_token@test.local', providerId: 'test_000' })
.returning()
const tAccessToken = await db
.insert(access_token)
.values({
token: '1234567890',
userId: createdUser[0].id,
name: 'testtoken',
})
.returning()
testAccessToken = tAccessToken[0] as z.infer<typeof AccessTokenOutput>
testUser = createdUser[0] as z.infer<typeof UserOutput>
})
afterAll(async () => {
await db.delete(user).where(eq(user.email, 'access_token@test.local'))
})
describe('Create', () => {
test('Success', async () => {
const payload: z.infer<typeof AccessTokenCreateInput> = {
name: 'Test Token',
}
const res = await fetch(
{
path: '/v1/access-token',
method: 'POST',
body: payload,
},
testAccessToken.token as string,
)
const json = (await res.json()) as z.infer<typeof AccessTokenOutput>
createdAccessToken = json
expect(res.status).toBe(201)
})
})
// describe('By Id', () => {
// test('Success', async () => {
// const res = await fetch(
// {
// path: `/v1/changelog/${testChangelog.id}`,
// method: 'GET',
// },
// testAccessToken.token as string,
// )
// expect(res.status).toBe(200)
// })
// test('Not Found', async () => {
// const res = await fetch(
// {
// path: '/v1/changelog/635f4aa7-79fc-4d6b-af7d-6731999cc8bb',
// method: 'GET',
// },
// testAccessToken.token as string,
// )
// expect(res.status).toBe(404)
// })
// test('Invalid Id', async () => {
// const res = await fetch(
// {
// path: '/v1/changelog/some',
// method: 'GET',
// },
// testAccessToken.token as string,
// )
// expect(res.status).toBe(400)
// const json = (await res.json()) as { success: boolean }
// expect(json.success).toBeFalse()
// })
// })
describe('List', () => {
test('Success', async () => {
const res = await fetch(
{
path: '/v1/access-token',
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
const json = (await res.json()) as z.infer<typeof AccessTokenListOutput>
// Check if token is redacted
expect(json[0].token).toHaveLength(10)
expect(json).toHaveLength(2)
})
})
describe('Remove', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/access-token/${createdAccessToken.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
test('Not found', async () => {
const res = await fetch(
{
path: `/v1/access-token/${createdAccessToken.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(404)
})
})
})

View File

@ -29,7 +29,7 @@ export const registerAccessTokenDelete = (api: typeof accessTokenApi) => {
const id = c.req.param('id')
const userId = await verifyAuthentication(c)
const result = await db
const [result] = await db
.delete(access_token)
.where(and(eq(access_token.userId, userId), eq(access_token.id, id)))
.returning()

View File

@ -1,17 +1,19 @@
import type { UserOutput } from '@boring.tools/schema'
import { sentry } from '@hono/sentry'
// import { sentry } from '@hono/sentry'
import { OpenAPIHono, type z } from '@hono/zod-openapi'
import { apiReference } from '@scalar/hono-api-reference'
import { cors } from 'hono/cors'
import { requestId } from 'hono/request-id'
import changelog from './changelog'
import user from './user'
import { accessTokenApi } from './access-token'
import pageApi from './page'
import statisticApi from './statistic'
import userApi from './user'
import { authentication } from './utils/authentication'
import { handleError, handleZodError } from './utils/errors'
import { logger } from './utils/logger'
import { startup } from './utils/startup'
type User = z.infer<typeof UserOutput>
@ -31,9 +33,12 @@ export const app = new OpenAPIHono<{ Variables: Variables }>({
// dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080',
// }),
// )
app.onError(handleError)
app.use('*', cors())
app.use('/v1/*', authentication)
app.use('*', requestId())
app.use(logger())
app.openAPIRegistry.registerComponent('securitySchemes', 'AccessToken', {
type: 'http',
scheme: 'bearer',
@ -43,7 +48,7 @@ app.openAPIRegistry.registerComponent('securitySchemes', 'Clerk', {
scheme: 'bearer',
})
app.route('/v1/user', user)
app.route('/v1/user', userApi)
app.route('/v1/changelog', changelog)
app.route('/v1/page', pageApi)
app.route('/v1/access-token', accessTokenApi)

View File

@ -20,7 +20,7 @@ const route = createRoute({
},
},
responses: {
200: {
201: {
content: {
'application/json': {
schema: PageOutput,
@ -62,6 +62,6 @@ export const registerPageCreate = (api: typeof pageApi) => {
)
}
return c.json(PageOutput.parse(result), 200)
return c.json(PageOutput.parse(result), 201)
})
}

View File

@ -35,7 +35,7 @@ export const registerPageDelete = (api: typeof pageApi) => {
const userId = await verifyAuthentication(c)
const { id } = c.req.valid('param')
const result = await db
const [result] = await db
.delete(page)
.where(and(eq(page.userId, userId), eq(page.id, id)))
.returning()

View File

@ -0,0 +1,171 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
import { access_token, db, user } from '@boring.tools/database'
import type {
AccessTokenCreateInput,
AccessTokenListOutput,
AccessTokenOutput,
ChangelogCreateInput,
ChangelogCreateOutput,
ChangelogListOutput,
ChangelogOutput,
PageCreateInput,
PageListOutput,
PageOutput,
PageUpdateInput,
UserOutput,
} from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import { fetch } from '../utils/testing/fetch'
describe('Page', () => {
let testUser: z.infer<typeof UserOutput>
let testAccessToken: z.infer<typeof AccessTokenOutput>
let createdAccessToken: z.infer<typeof AccessTokenOutput>
let testPage: z.infer<typeof PageOutput>
beforeAll(async () => {
const createdUser = await db
.insert(user)
.values({ email: 'page@test.local', providerId: 'test_000' })
.returning()
const tAccessToken = await db
.insert(access_token)
.values({
token: '1234567890',
userId: createdUser[0].id,
name: 'testtoken',
})
.returning()
testAccessToken = tAccessToken[0] as z.infer<typeof AccessTokenOutput>
testUser = createdUser[0] as z.infer<typeof UserOutput>
})
afterAll(async () => {
await db.delete(user).where(eq(user.email, 'page@test.local'))
})
describe('Create', () => {
test('Success', async () => {
const payload: z.infer<typeof PageCreateInput> = {
title: 'Test Page',
changelogIds: [],
}
const res = await fetch(
{
path: '/v1/page',
method: 'POST',
body: payload,
},
testAccessToken.token as string,
)
const json = (await res.json()) as z.infer<typeof PageOutput>
testPage = json
expect(res.status).toBe(201)
})
})
describe('By Id', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
test('Not Found', async () => {
const res = await fetch(
{
path: '/v1/page/635f4aa7-79fc-4d6b-af7d-6731999cc8bb',
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(404)
})
})
describe('Update', () => {
test('Success', async () => {
const update: z.infer<typeof PageUpdateInput> = {
title: 'Test Update',
}
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'PUT',
body: update,
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
})
describe('Public', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}/public`,
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
})
describe('List', () => {
test('Success', async () => {
const res = await fetch(
{
path: '/v1/page',
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
const json = (await res.json()) as z.infer<typeof PageListOutput>
// Check if token is redacted
expect(json).toHaveLength(1)
})
})
describe('Remove', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
test('Not found', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(404)
})
})
})

View File

@ -91,6 +91,7 @@ export const registerPagePublic = (api: typeof pageApi) => {
}
redis.set(id, JSON.stringify(mappedResult), { EX: 60 })
return c.json(PagePublicOutput.parse(mappedResult), 200)
const asd = PagePublicOutput.parse(mappedResult)
return c.json(asd, 200)
})
}

View File

@ -4,6 +4,7 @@ import { createRoute } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import type { userApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
const route = createRoute({
@ -24,9 +25,9 @@ const route = createRoute({
export const registerUserGet = (api: typeof userApi) => {
return api.openapi(route, async (c) => {
const user = c.get('user')
const userId = await verifyAuthentication(c)
const result = await db.query.user.findFirst({
where: eq(userDb.id, user.id),
where: eq(userDb.id, userId),
})
if (!result) {

View File

@ -20,10 +20,7 @@ const route = createRoute({
},
},
responses: {
200: {
content: {
'application/json': { schema: UserOutput },
},
204: {
description: 'Return success',
},
...openApiErrorResponses,
@ -70,7 +67,11 @@ export const registerUserWebhook = (api: typeof userApi) => {
case 'user.created': {
const result = await userCreate({ payload: verifiedPayload })
logger.info('Clerk Webhook', result)
return c.json(UserOutput.parse(result), 200)
if (result) {
return c.json({}, 204)
}
return c.json({}, 404)
}
default:
throw new HTTPException(404, { message: 'Webhook type not supported' })

View File

@ -1,4 +1,5 @@
import { access_token, db, user } from '@boring.tools/database'
import { logger } from '@boring.tools/logger'
import { clerkMiddleware, getAuth } from '@hono/clerk-auth'
import { eq } from 'drizzle-orm'
import type { Context, Next } from 'hono'
@ -52,8 +53,9 @@ export const verifyAuthentication = async (c: Context) => {
.where(eq(user.providerId, auth.userId))
if (!userEntry) {
logger.error('User not found - Unauthorized', { providerId: auth.userId })
throw new HTTPException(401, { message: 'Unauthorized' })
}
// console.log(userEntry)
return userEntry.id
}

View File

@ -106,7 +106,6 @@ export function handleZodError(
},
c: Context,
) {
console.log('asdasasdas')
if (!result.success) {
const error = SchemaError.fromZod(result.error, c)
return c.json<z.infer<ReturnType<typeof createErrorSchema>>>(

View File

@ -0,0 +1,61 @@
import { db, user } from '@boring.tools/database'
import { logger as log } from '@boring.tools/logger'
import { eq } from 'drizzle-orm'
import type { MiddlewareHandler } from 'hono'
import { getPath } from 'hono/utils/url'
export const logger = (): MiddlewareHandler => {
return async function logga(c, next) {
const { method } = c.req
const clerkUser = c.get('clerkAuth')
const requestId = c.get('requestId')
const [dbUser] = await db
.select({ id: user.id, providerId: user.providerId })
.from(user)
.where(eq(user.providerId, clerkUser?.userId))
const path = getPath(c.req.raw)
log.info('Incoming', {
direction: 'in',
method,
path,
userId: dbUser?.id,
requestId,
})
await next()
if (c.res.status <= 399) {
log.info('Outgoing', {
direction: 'out',
method,
path,
status: c.res.status,
userId: dbUser?.id,
requestId,
})
}
if (c.res.status >= 400 && c.res.status < 499) {
log.warn('Outgoing', {
direction: 'out',
method,
path,
status: c.res.status,
userId: dbUser?.id,
requestId,
})
}
if (c.res.status >= 500) {
log.error('Outgoing', {
direction: 'out',
method,
path,
status: c.res.status,
userId: dbUser?.id,
requestId,
})
}
}
}

View File

@ -8,7 +8,11 @@ import {
Separator,
SidebarTrigger,
} from '@boring.tools/ui'
import { useAuth } from '@clerk/clerk-react'
import { Link } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useUser } from '../hooks/useUser'
type Breadcrumbs = {
name: string
@ -19,6 +23,15 @@ export const PageWrapper = ({
children,
breadcrumbs,
}: { children: React.ReactNode; breadcrumbs?: Breadcrumbs[] }) => {
const { error } = useUser()
const { signOut } = useAuth()
useEffect(() => {
if (error) {
signOut()
}
}, [error, signOut])
return (
<>
<header className="flex h-16 shrink-0 items-center gap-2">

View File

@ -0,0 +1,21 @@
import type { UserOutput } from '@boring.tools/schema'
import { useAuth } from '@clerk/clerk-react'
import { useQuery } from '@tanstack/react-query'
import type { z } from 'zod'
import { queryFetch } from '../utils/queryFetch'
type User = z.infer<typeof UserOutput>
export const useUser = () => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['user'],
queryFn: async (): Promise<Readonly<User>> =>
await queryFetch({
path: 'user',
method: 'get',
token: await getToken(),
}),
retry: false,
})
}

View File

@ -1,12 +1,12 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createFileRoute } from '@tanstack/react-router'
@ -39,26 +39,31 @@ const ChangelogIdEditLazyImport = createFileRoute('/changelog/$id/edit')()
// Create/Update Routes
const CliLazyRoute = CliLazyImport.update({
id: '/cli',
path: '/cli',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/cli.lazy').then((d) => d.Route))
const IndexLazyRoute = IndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const UserIndexLazyRoute = UserIndexLazyImport.update({
id: '/user/',
path: '/user/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/user/index.lazy').then((d) => d.Route))
const PageIndexLazyRoute = PageIndexLazyImport.update({
id: '/page/',
path: '/page/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.index.lazy').then((d) => d.Route))
const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
id: '/changelog/',
path: '/changelog/',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
@ -66,6 +71,7 @@ const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
)
const AccessTokensIndexLazyRoute = AccessTokensIndexLazyImport.update({
id: '/access-tokens/',
path: '/access-tokens/',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
@ -73,16 +79,19 @@ const AccessTokensIndexLazyRoute = AccessTokensIndexLazyImport.update({
)
const PageCreateLazyRoute = PageCreateLazyImport.update({
id: '/page/create',
path: '/page/create',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.create.lazy').then((d) => d.Route))
const PageIdLazyRoute = PageIdLazyImport.update({
id: '/page/$id',
path: '/page/$id',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.$id.lazy').then((d) => d.Route))
const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
id: '/changelog/create',
path: '/changelog/create',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
@ -90,11 +99,13 @@ const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
)
const ChangelogIdLazyRoute = ChangelogIdLazyImport.update({
id: '/changelog/$id',
path: '/changelog/$id',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/changelog.$id.lazy').then((d) => d.Route))
const AccessTokensNewLazyRoute = AccessTokensNewLazyImport.update({
id: '/access-tokens/new',
path: '/access-tokens/new',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
@ -102,6 +113,7 @@ const AccessTokensNewLazyRoute = AccessTokensNewLazyImport.update({
)
const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() =>
@ -109,6 +121,7 @@ const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
)
const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -116,12 +129,14 @@ const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
)
const PageIdEditLazyRoute = PageIdEditLazyImport.update({
id: '/edit',
path: '/edit',
getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() => import('./routes/page.$id.edit.lazy').then((d) => d.Route))
const ChangelogIdVersionCreateLazyRoute =
ChangelogIdVersionCreateLazyImport.update({
id: '/versionCreate',
path: '/versionCreate',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -129,6 +144,7 @@ const ChangelogIdVersionCreateLazyRoute =
)
const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
id: '/edit',
path: '/edit',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -137,6 +153,7 @@ const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
const ChangelogIdVersionVersionIdRoute =
ChangelogIdVersionVersionIdImport.update({
id: '/version/$versionId',
path: '/version/$versionId',
getParentRoute: () => ChangelogIdLazyRoute,
} as any)
@ -451,8 +468,6 @@ export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
/* ROUTE_MANIFEST_START
{
"routes": {

View File

@ -1,5 +1,9 @@
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Form,
FormControl,
@ -58,66 +62,118 @@ const Component = () => {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
className="flex flex-col gap-10 w-full max-w-screen-lg"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My changelog" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the changelog..."
{...field}
<div className="flex gap-10 w-full max-w-screen-lg">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 w-full">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="My changelog"
{...field}
autoFocus
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isSemver"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the changelog..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<div className="flex gap-5">
</CardContent>
</Card>
<Card className="w-full">
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full flex flex-col gap-5">
<FormField
control={form.control}
name="isSemver"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isConventional"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Conventional Commits</FormLabel>
<FormDescription>
If this changelog is using{' '}
<a
href="https://www.conventionalcommits.org/en/v1.0.0/"
className="text-emerald-700"
>
conventional commits
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
<div className="flex items-end justify-end gap-5">
<Button
type="button"
variant={'ghost'}
@ -127,7 +183,8 @@ const Component = () => {
>
Cancel
</Button>
<Button type="submit">Update</Button>
<Button type="submit">Save</Button>
</div>
</form>
</Form>

View File

@ -1,6 +1,10 @@
import { ChangelogCreateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Form,
FormControl,
@ -53,97 +57,131 @@ const Component = () => {
>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">New changelog</h1>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
className="flex flex-col gap-10 w-full max-w-screen-lg"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My changelog" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the changelog..."
{...field}
<div className="flex gap-10 w-full max-w-screen-lg">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 w-full">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="My changelog"
{...field}
autoFocus
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isSemver"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the changelog..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
</CardContent>
</Card>
<FormField
control={form.control}
name="isConventional"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
<Card className="w-full">
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full flex flex-col gap-5">
<FormField
control={form.control}
name="isSemver"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isConventional"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Conventional Commits</FormLabel>
<FormDescription>
If this changelog is using{' '}
<a
href="https://www.conventionalcommits.org/en/v1.0.0/"
className="text-emerald-700"
>
conventional commits
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Conventional Commits</FormLabel>
<FormDescription>
If this changelog is using{' '}
<a
href="https://www.conventionalcommits.org/en/v1.0.0/"
className="text-emerald-700"
>
conventional commits
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit">Create</Button>
</CardContent>
</Card>
</div>
<div className="flex items-end justify-end gap-5">
<Button
type="button"
variant={'ghost'}
onClick={() => navigate({ to: '/changelog' })}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</div>
</form>
</Form>
</div>

View File

@ -1,6 +1,10 @@
import { PageUpdateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command,
CommandEmpty,
CommandGroup,
@ -57,129 +61,147 @@ const Component = () => {
return (
<>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">Edit page</h1>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
className="flex flex-col gap-10 w-full max-w-screen-lg"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-10 w-full">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
value={field.value ?? ''}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<FormField
control={form.control}
name="changelogIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Changelogs</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-[200px] justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field?.value?.length === 1 &&
changelogList.data?.find((changelog) =>
field.value?.includes(changelog.id),
)?.title}
{field?.value &&
field.value.length <= 0 &&
'No changelog selected'}
{field?.value &&
field.value.length > 1 &&
`${field?.value?.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search changelogs..." />
<CommandList>
<CommandEmpty>No changelog found.</CommandEmpty>
<CommandGroup>
{changelogList.data?.map((changelog) => (
<CommandItem
value={changelog.title}
key={changelog.id}
onSelect={() => {
const getIds = () => {
if (!field.value) {
return [changelog.id]
}
if (field.value?.includes(changelog.id)) {
return field.value.filter(
(id) => id !== changelog.id,
)
}
return [
...(field?.value as string[]),
changelog.id,
]
}
form.setValue('changelogIds', getIds())
}}
<Card className="w-full">
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="changelogIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Changelogs</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-[200px] justify-between',
!field.value && 'text-muted-foreground',
)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
field.value?.includes(changelog.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
{changelog.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This changelogs are shown on this page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-5">
{field?.value?.length === 1 &&
changelogList.data?.find((changelog) =>
field.value?.includes(changelog.id),
)?.title}
{field?.value &&
field.value.length <= 0 &&
'No changelog selected'}
{field?.value &&
field.value.length > 1 &&
`${field?.value?.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search changelogs..." />
<CommandList>
<CommandEmpty>No changelog found.</CommandEmpty>
<CommandGroup>
{changelogList.data?.map((changelog) => (
<CommandItem
value={changelog.title}
key={changelog.id}
onSelect={() => {
const getIds = () => {
if (!field.value) {
return [changelog.id]
}
if (
field.value?.includes(changelog.id)
) {
return field.value.filter(
(id) => id !== changelog.id,
)
}
return [
...(field?.value as string[]),
changelog.id,
]
}
form.setValue('changelogIds', getIds())
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
field.value?.includes(changelog.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
{changelog.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This changelogs are shown on this page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
<div className="flex items-end justify-end gap-5">
<Button
type="button"
variant={'ghost'}
@ -187,7 +209,7 @@ const Component = () => {
>
Cancel
</Button>
<Button type="submit">Update</Button>
<Button type="submit">Save</Button>
</div>
</form>
</Form>

View File

@ -1,6 +1,10 @@
import { PageCreateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command,
CommandEmpty,
CommandGroup,
@ -69,115 +73,146 @@ const Component = () => {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
className="flex flex-col gap-10 w-full max-w-screen-lg"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-10 w-full">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="changelogIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Changelogs</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-[200px] justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value.length === 1 &&
changelogList.data?.find((changelog) =>
field.value?.includes(changelog.id),
)?.title}
{field.value.length <= 0 && 'No changelog selected'}
{field.value.length > 1 &&
`${field.value.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search changelogs..." />
<CommandList>
<CommandEmpty>No changelog found.</CommandEmpty>
<CommandGroup>
{changelogList.data?.map((changelog) => (
<CommandItem
value={changelog.title}
key={changelog.id}
onSelect={() => {
const getIds = () => {
if (field.value.includes(changelog.id)) {
const asd = field.value.filter(
(id) => id !== changelog.id,
)
return asd
}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
value={field.value ?? ''}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
return [...field.value, changelog.id]
}
form.setValue('changelogIds', getIds())
}}
<Card className="w-full">
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="changelogIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Changelogs</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-[200px] justify-between',
!field.value && 'text-muted-foreground',
)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
field.value.includes(changelog.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
{changelog.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This changelogs are shown on this page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Create</Button>
{field.value.length === 1 &&
changelogList.data?.find((changelog) =>
field.value?.includes(changelog.id),
)?.title}
{field.value.length <= 0 &&
'No changelog selected'}
{field.value.length > 1 &&
`${field.value.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search changelogs..." />
<CommandList>
<CommandEmpty>No changelog found.</CommandEmpty>
<CommandGroup>
{changelogList.data?.map((changelog) => (
<CommandItem
value={changelog.title}
key={changelog.id}
onSelect={() => {
const getIds = () => {
if (
field.value.includes(changelog.id)
) {
const asd = field.value.filter(
(id) => id !== changelog.id,
)
return asd
}
return [...field.value, changelog.id]
}
form.setValue('changelogIds', getIds())
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
field.value.includes(changelog.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
{changelog.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This changelogs are shown on this page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
<div className="flex items-end justify-end gap-5">
<Button
type="button"
variant={'ghost'}
onClick={() => navigate({ to: '/page' })}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</div>
</form>
</Form>
</div>

View File

@ -12,13 +12,17 @@ const url = import.meta.env.PROD
: 'http://localhost:3000'
export const queryFetch = async ({ path, method, data, token }: Fetch) => {
const response = await axios({
method,
url: `${url}/v1/${path}`,
data,
headers: {
Authorization: `Bearer ${token}`,
},
})
return response.data
try {
const response = await axios({
method,
url: `${url}/v1/${path}`,
data,
headers: {
Authorization: `Bearer ${token}`,
},
})
return response.data
} catch (error) {
throw new Error('Somethind went wrong.')
}
}

View File

@ -24,7 +24,6 @@
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSpacing": true,

BIN
bt-cli

Binary file not shown.

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,57 @@
services:
postgres:
image: postgres:17-alpine
container_name: boring_services_postgres
ports:
- 5432:5432
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
- POSTGRES_DB=postgres
cache:
container_name: boring_services_redis
image: redis:7.4.1-alpine
ports:
- '6379:6379'
command: redis-server --save 20 1 --loglevel warning --requirepass development
loki:
image: grafana/loki:latest
container_name: boring_services_loki
ports:
- "9100:3100"
command: -config.file=/etc/loki/local-config.yaml
grafana:
image: grafana/grafana:latest
container_name: boring_services_grafana
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- ./grafana/dashboard.yaml:/etc/grafana/provisioning/dashboards/main.yaml
- ./grafana/dashboards:/var/lib/grafana/dashboards
- ./grafana/datasources:/etc/grafana/provisioning/datasources
# entrypoint:
# - sh
# - -euc
# - |
# mkdir -p /etc/grafana/provisioning/datasources
# cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
# apiVersion: 1
# datasources:
# - name: Loki
# type: loki
# access: proxy
# orgId: 1
# url: http://loki:3100
# basicAuth: false
# isDefault: true
# version: 1
# editable: false
# EOF
# /run.sh
ports:
- "9000:3000"

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: "Dashboard provider"
orgId: 1
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View File

@ -0,0 +1,546 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 6,
"panels": [],
"title": "Service: API",
"type": "row"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "points",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": 5000,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "#73BF69",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 1
},
"id": 11,
"options": {
"legend": {
"calcs": ["sum"],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | status <= 399 [$__auto]))",
"legendFormat": "<= 300",
"queryType": "range",
"refId": "A"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "count(rate({service=\"api\"} | json | status >= 400 and status <= 499 [$__auto]))",
"hide": false,
"legendFormat": "400 - 499",
"queryType": "range",
"refId": "B"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | status >= 500 [$__auto]))",
"hide": false,
"legendFormat": ">= 500",
"queryType": "range",
"refId": "C"
}
],
"title": "Requests",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 0,
"y": 8
},
"id": 10,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | status <= 399 [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "<= 399",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "yellow",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 4,
"y": 8
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "code",
"expr": "count(rate({service=\"api\"} | json | status >= 400 and status <= 499 [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "400 - 499",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 8,
"y": 8
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "code",
"expr": "count(rate({service=\"api\"} | json | status >= 500 [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "400 - 499",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 0,
"y": 16
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | level = `info` [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Info",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "yellow",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 4,
"y": 16
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | level = `warn` [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Warning",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 8,
"y": 16
},
"id": 9,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | level = `error` [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Error",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 17,
"w": 24,
"x": 0,
"y": 24
},
"id": 5,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "{service=\"api\"} | json",
"queryType": "range",
"refId": "A"
}
],
"title": "Logs",
"type": "logs"
}
],
"preload": false,
"schemaVersion": 40,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Logging",
"uid": "be3r0x46iwlq8f",
"version": 2,
"weekStart": "monday"
}

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: "Dashboard provider"
orgId: 1
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View File

@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
orgId: 1
url: http://loki:3100
basicAuth: false
isDefault: true
version: 1
editable: false

View File

@ -1,58 +0,0 @@
services:
postgres:
image: postgres:17-alpine
container_name: boring_postgres
ports:
- 5432:5432
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
- POSTGRES_DB=postgres
cache:
container_name: boring_redis
image: redis:7.4.1-alpine
restart: always
ports:
- '6379:6379'
# command: redis-server --save 20 1 --loglevel warning --requirepass development
loki:
image: grafana/loki:latest
ports:
- "9100:3100"
command: -config.file=/etc/loki/local-config.yaml
promtail:
image: grafana/promtail:latest
volumes:
- /var/log:/var/log
command: -config.file=/etc/promtail/config.yml
grafana:
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
entrypoint:
- sh
- -euc
- |
mkdir -p /etc/grafana/provisioning/datasources
cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
orgId: 1
url: http://loki:3100
basicAuth: false
isDefault: true
version: 1
editable: false
EOF
/run.sh
image: grafana/grafana:latest
ports:
- "9000:3000"

View File

@ -2,5 +2,5 @@ pre-commit:
commands:
check:
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,astro}"
run: bunx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
run: /run/current-system/sw/bin/biome check --apply --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true

View File

@ -6,6 +6,9 @@
"check:apps": "bunx biome check --write --config-path ./biome.json ./apps",
"check:packages": "bunx biome check --write --config-path ./biome.json ./packages",
"dev": "bun --filter '*' dev",
"dev:docker:up": "docker compose -f ci/development/docker-compose.yaml up -d",
"dev:docker:down": "docker compose -f ci/development/docker-compose.yaml down",
"dev:docker:stop": "docker compose -f ci/development/docker-compose.yaml stop",
"build": "bun --filter '*' build",
"db:generate": "bun --filter '@boring.tools/database' db:generate",
"db:migrate": "bun --filter '@boring.tools/database' db:migrate",
@ -26,12 +29,9 @@
"docker:page": "bun docker:page:build && bun docker:page:push"
},
"packageManager": "bun@1.1.29",
"workspaces": [
"apps/*",
"packages/*"
],
"workspaces": ["apps/*", "packages/*"],
"devDependencies": {
"@biomejs/biome": "1.8.3",
"@biomejs/biome": "1.9.4",
"lefthook": "^1.7.15"
}
}

View File

@ -12,6 +12,7 @@
"@logtail/node": "^0.5.0",
"@logtail/winston": "^0.5.0",
"winston": "^3.14.2",
"winston-console-format": "^1.0.8",
"winston-loki": "^6.1.3"
}
}

View File

@ -1,20 +1,43 @@
import { Logtail } from '@logtail/node'
import { LogtailTransport } from '@logtail/winston'
import winston from 'winston'
import { createLogger, format, transports } from 'winston'
import { consoleFormat } from 'winston-console-format'
import LokiTransport from 'winston-loki'
// Create a Winston logger - passing in the Logtail transport
export const logger = winston.createLogger({
format: winston.format.json(),
export const logger = createLogger({
format: format.combine(
format.timestamp(),
format.ms(),
format.errors({ stack: true }),
format.splat(),
format.json(),
),
transports: [
new winston.transports.Console({
format: winston.format.json(),
new transports.Console({
silent: import.meta.env.NODE_ENV === 'test',
format: format.combine(
format.colorize({ all: true }),
format.padLevels(),
consoleFormat({
showMeta: true,
metaStrip: ['timestamp', 'service'],
inspectOptions: {
depth: Number.POSITIVE_INFINITY,
colors: true,
maxArrayLength: Number.POSITIVE_INFINITY,
breakLength: 120,
compact: Number.POSITIVE_INFINITY,
},
}),
),
}),
new LokiTransport({
silent: import.meta.env.NODE_ENV === 'test',
host: 'http://localhost:9100',
labels: { app: 'api' },
json: true,
format: winston.format.json(),
labels: { service: import.meta.env.SERVICE_NAME ?? 'unknown' },
format: format.json(),
replaceTimestamp: true,
onConnectionError: (err) => console.error(err),
}),

View File

@ -7,8 +7,8 @@ export const PageOutput = z
example: '',
}),
title: z.string(),
description: z.string().optional(),
icon: z.string(),
description: z.string().optional().nullable(),
icon: z.string().optional().nullable(),
changelogs: z.array(ChangelogOutput).optional(),
})
.openapi('Page')

View File

@ -5,10 +5,10 @@ export const PageCreateInput = z
title: z.string().min(3).openapi({
example: 'My page',
}),
description: z.string().optional().openapi({
description: z.string().optional().nullable().openapi({
example: '',
}),
icon: z.string().optional().openapi({
icon: z.string().optional().nullable().openapi({
example: 'base64...',
}),
changelogIds: z.array(z.string().uuid()),

View File

@ -2,21 +2,23 @@ import { z } from '@hono/zod-openapi'
export const PagePublicOutput = z.object({
title: z.string(),
description: z.string(),
description: z.string().nullable(),
icon: z.string(),
changelogs: z.array(
z.object({
title: z.string(),
description: z.string(),
versions: z.array(
z.object({
markdown: z.string(),
version: z.string(),
releasedAt: z.date(),
}),
),
}),
),
changelogs: z
.array(
z.object({
title: z.string(),
description: z.string(),
versions: z.array(
z.object({
markdown: z.string(),
version: z.string(),
releasedAt: z.date(),
}),
),
}),
)
.optional(),
})
export const PagePublicParams = z.object({

View File

@ -7,10 +7,10 @@ export const PageUpdateInput = z
title: z.string().min(3).optional().openapi({
example: 'My page',
}),
description: z.string().optional().openapi({
description: z.string().nullable().optional().openapi({
example: '',
}),
icon: z.string().optional().openapi({
icon: z.string().optional().nullable().openapi({
example: 'base64...',
}),
changelogIds: z.array(z.string().uuid()).optional(),