├── .dockerignore ├── .drone.jsonnet ├── .editorconfig ├── .env.dist ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── generate-release.yml ├── .gitignore ├── .opencommitignore ├── .prettierignore ├── .prettierrc.json ├── .release-please-manifest.json ├── .vscode ├── settings.json └── tasks.json ├── .yarn └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.dev ├── FUNDING.yml ├── LICENSE ├── Makefile ├── README.md ├── __tests__ ├── factories │ └── prismaFactories.ts ├── lib │ ├── generateUpdatedUrl.test.ts │ ├── gitProvider.test.ts │ └── processError.test.ts └── pages │ └── home.test.tsx ├── app ├── _actions.ts ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── completion │ │ └── route.ts │ ├── hc │ │ └── route.ts │ └── v3 │ │ └── notices │ │ └── route.ts ├── bookmarks │ └── page.tsx ├── globals.css ├── icon.svg ├── layout.tsx ├── loading.tsx ├── not-found.tsx ├── notices │ └── [notice_id] │ │ └── page.tsx ├── occurrences │ └── [occurrence_id] │ │ └── page.tsx ├── page.tsx ├── projects │ ├── [project_id] │ │ ├── Filter.tsx │ │ ├── Sort.tsx │ │ ├── edit │ │ │ └── page.tsx │ │ └── page.tsx │ ├── new │ │ └── page.tsx │ └── page.tsx ├── robots.txt └── signin │ ├── SignInPageClient.tsx │ └── page.tsx ├── components ├── Background.tsx ├── BookmarksTable.tsx ├── CodeBlock.tsx ├── ConfirmationDialog.tsx ├── CounterLabel.tsx ├── CreateProjectProposal.tsx ├── CustomTimeAgo.tsx ├── DashboardShell.tsx ├── EnvironmentLabel.tsx ├── FooterCredits.tsx ├── Gravatar.tsx ├── IntegrationsGrid.tsx ├── NoticesTable.tsx ├── OccurrenceChart.tsx ├── OccurrenceChartWrapper.tsx ├── OccurrencesChartBackground.tsx ├── OccurrencesTable.tsx ├── PingDot.tsx ├── Search.tsx ├── SessionButtons.tsx ├── SidebarButtons.tsx ├── SidebarDesktop.tsx ├── SidebarLink.tsx ├── SidebarLinks.tsx ├── SidebarMobile.tsx ├── SidebarProvider.tsx ├── SourceRepoProviderIcon.tsx ├── TestZone.tsx ├── UserMenu.tsx ├── occurrence │ ├── Backtrace.tsx │ ├── BacktraceLine.tsx │ ├── BookmarkButton.tsx │ ├── ClipboardButton.tsx │ ├── Context.tsx │ ├── Environment.tsx │ ├── Params.tsx │ ├── ResolveButton.tsx │ ├── Session.tsx │ ├── Toolbox.tsx │ └── toolbox │ │ ├── AI.tsx │ │ ├── Curl.tsx │ │ └── Replay.tsx └── project │ ├── CreateForm.tsx │ ├── EditForm.tsx │ ├── OccurrencesChartWrapper.tsx │ ├── Overview.tsx │ └── cards │ ├── DangerZone.tsx │ └── ToggleIntake.tsx ├── docker-compose.yml ├── docker-yarn-entrypoint.sh ├── eslint.config.mjs ├── lib ├── actions │ ├── airbrakeActions.ts │ ├── index.ts │ ├── occurrenceActions.ts │ └── projectActions.ts ├── auth.ts ├── configTemplates.ts ├── db.ts ├── environmentStyles.ts ├── generateUpdatedUrl.ts ├── generateUpdatedUrlWithRemovals.tsx ├── gitProvider.ts ├── integrationsData.ts ├── occurrenceUtils.ts ├── parseNotice.ts ├── processError.ts └── queries │ ├── notices.ts │ ├── occurrenceBookmarks.ts │ ├── occurrences.ts │ └── projects.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20230512122737_initial │ │ └── migration.sql │ ├── 20230512122920_add_counters │ │ └── migration.sql │ ├── 20230522001622_add_repo_url │ │ └── migration.sql │ ├── 20230525035805_drop_not_nulls │ │ └── migration.sql │ ├── 20230526014657_refactor │ │ └── migration.sql │ ├── 20230526024011_refactor2 │ │ └── migration.sql │ ├── 20230526040245_rebuild_functions │ │ └── migration.sql │ ├── 20230526042154_remove_id │ │ └── migration.sql │ ├── 20230529041233_add_paused_to_projects │ │ └── migration.sql │ ├── 20230606090635_make_repo_branch_optional │ │ └── migration.sql │ ├── 20230608180606_add_resolved_at │ │ └── migration.sql │ ├── 20250109051724_remove_exts │ │ └── migration.sql │ ├── 20250113060539_add_message_hash │ │ └── migration.sql │ ├── 20250113061142_message_hash_not_null │ │ └── migration.sql │ └── migration_lock.toml ├── pg-init-scripts │ ├── create-multiple-postgres-databases.sh │ └── readme.txt ├── schema.prisma └── seed.ts ├── public ├── aidemo.gif ├── icoretech-logo.png ├── logo-full.png ├── logo.png ├── logo.svg ├── screenshot.png └── screenshot2.png ├── release-please-config.json ├── render.yaml ├── renovate.json ├── tailwind.config.ts ├── tsconfig.json ├── types ├── airbroke.d.ts └── next-auth.d.ts ├── vitest.config.mjs └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | Dockerfile.dev 3 | .dockerignore 4 | node_modules 5 | npm-debug.log 6 | README.md 7 | .next 8 | .git 9 | .vscode 10 | .drone.jsonnet 11 | .env.dist 12 | Makefile 13 | prisma/pg-init-scripts 14 | prisma/seed.ts 15 | release-please-config.json 16 | -------------------------------------------------------------------------------- /.drone.jsonnet: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | kind: 'pipeline', 4 | type: 'kubernetes', 5 | name: 'next', 6 | clone: { 7 | depth: 1, 8 | }, 9 | trigger: { 10 | branch: { 11 | exclude: ['release-please--branches--main--components--airbroke'], 12 | }, 13 | event: { 14 | include: ['push'], 15 | }, 16 | }, 17 | steps: [ 18 | { 19 | name: 'tag', 20 | image: 'alpine/git', 21 | commands: [ 22 | "export CUSTOM_BRANCH_NAME=$(basename \"${DRONE_SOURCE_BRANCH}\" | tr '[:upper:]' '[:lower:]' | sed 's/_/-/g')", 23 | 'echo -n "$CUSTOM_BRANCH_NAME-$SHORT_SHA-$(date +%s)" > .tags', 24 | ], 25 | environment: { 26 | SHORT_SHA: '${DRONE_COMMIT_SHA:0:8}', 27 | }, 28 | }, 29 | { 30 | name: 'build-and-push', 31 | image: 'thegeeklab/drone-docker-buildx', 32 | privileged: true, 33 | depends_on: ['tag'], 34 | settings: { 35 | debug: true, 36 | purge: true, 37 | no_cache: true, 38 | platforms: ['linux/amd64'], 39 | repo: 'ghcr.io/icoretech/airbroke', 40 | registry: 'ghcr.io/icoretech', 41 | username: { 42 | from_secret: 'github_packages_username', 43 | }, 44 | password: { 45 | from_secret: 'github_packages_pat', 46 | }, 47 | build_args: [ 48 | 'DEBUG_TOOLS=true', 49 | ], 50 | }, 51 | when: { 52 | branch: ['main'], 53 | }, 54 | }, 55 | { 56 | name: 'build-no-push', 57 | image: 'thegeeklab/drone-docker-buildx', 58 | privileged: true, 59 | depends_on: ['tag'], 60 | settings: { 61 | dry_run: true, 62 | debug: true, 63 | purge: true, 64 | no_cache: true, 65 | platforms: ['linux/amd64'], 66 | repo: 'ghcr.io/icoretech/airbroke', 67 | registry: 'ghcr.io/icoretech', 68 | username: { 69 | from_secret: 'github_packages_username', 70 | }, 71 | password: { 72 | from_secret: 'github_packages_pat', 73 | }, 74 | }, 75 | when: { 76 | branch: { 77 | exclude: ['main'], 78 | }, 79 | }, 80 | 81 | }, 82 | ], 83 | }, 84 | ] 85 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # Sample .env.dist file for Airbroke 2 | # Please rename this file to .env and fill in the appropriate values. 3 | 4 | # no need to set these if using docker-compose, unless you want to go pgbouncer 5 | # DATABASE_URL="postgresql://airbroke:airbroke@localhost:6432/airbroke-development?schema=public&pgbouncer=true" 6 | # DIRECT_URL="postgresql://airbroke:airbroke@localhost:5432/airbroke-development?schema=public" 7 | 8 | # When self-hosting your Next.js application across multiple servers, 9 | # each server instance may end up with a different encryption key, leading to potential inconsistencies. 10 | # Next.js applications deployed to Vercel automatically handle this. 11 | # This variable **must** be AES-GCM encrypted. 12 | # Generate one with `openssl rand -base64 32` 13 | NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replaceme 14 | 15 | # Endpoint protection 16 | AIRBROKE_CORS_ORIGINS="http://localhost:3000,https://my.browserapp.tld" 17 | 18 | # AI toolbox 19 | AIRBROKE_OPENAI_API_KEY="sk-xxx" 20 | AIRBROKE_OPENAI_ORGANIZATION="" 21 | AIRBROKE_OPENAI_ENGINE="gpt-4o" 22 | 23 | # Authentication 24 | AUTH_SECRET="replaceme" # A random string used to hash tokens, sign cookies and generate cryptographic keys. 25 | AUTH_URL="http://localhost:3000" # The URL of your application, used for signing cookies and OAuth secrets, defaults to http://localhost:3000 26 | AUTH_DEBUG="false" 27 | 28 | AIRBROKE_GITHUB_ID="" 29 | AIRBROKE_GITHUB_SECRET="" 30 | AIRBROKE_GITHUB_ORGS="" # optional, if you want to restrict access to specific organization(s), comma separated 31 | 32 | AIRBROKE_ATLASSIAN_ID="" 33 | AIRBROKE_ATLASSIAN_SECRET="" 34 | 35 | AIRBROKE_GOOGLE_ID="" 36 | AIRBROKE_GOOGLE_SECRET="" 37 | AIRBROKE_GOOGLE_DOMAINS="" # optional, if you want to restrict access to specific domain(s), comma separated 38 | 39 | AIRBROKE_COGNITO_ID="" 40 | AIRBROKE_COGNITO_SECRET="" 41 | AIRBROKE_COGNITO_ISSUER="" # a URL, that looks like this: https://cognito-idp.{region}.amazonaws.com/{PoolId} 42 | 43 | AIRBROKE_GITLAB_ID="" 44 | AIRBROKE_GITLAB_SECRET="" 45 | 46 | AIRBROKE_APPLE_ID="" 47 | AIRBROKE_APPLE_SECRET="" 48 | 49 | AIRBROKE_SLACK_ID="" 50 | AIRBROKE_SLACK_SECRET="" 51 | 52 | AIRBROKE_KEYCLOAK_ID="" 53 | AIRBROKE_KEYCLOAK_SECRET="" 54 | AIRBROKE_KEYCLOAK_ISSUER="" # issuer should include the realm e.g. https://my-keycloak-domain.com/realms/My_Realm 55 | 56 | AIRBROKE_OKTA_ID="" 57 | AIRBROKE_OKTA_SECRET="" 58 | AIRBROKE_OKTA_ISSUER="" # https://{yourOktaDomain}/oauth2/default 59 | 60 | AIRBROKE_AUTHENTIK_ID="" 61 | AIRBROKE_AUTHENTIK_SECRET="" 62 | AIRBROKE_AUTHENTIK_ISSUER="" # issuer should include the slug without a trailing slash e.g., https://my-authentik-domain.com/application/o/My_Slug 63 | 64 | AIRBROKE_MICROSOFT_ENTRA_ID_CLIENT_ID="" 65 | AIRBROKE_MICROSOFT_ENTRA_ID_CLIENT_SECRET="" 66 | AIRBROKE_MICROSOFT_ENTRA_ID_ISSUER="" 67 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v3 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | 19 | - name: Extract metadata (tags, labels) for Docker 20 | id: meta 21 | uses: docker/metadata-action@v5 22 | with: 23 | images: ghcr.io/icoretech/airbroke 24 | labels: | 25 | io.artifacthub.package.category=monitoring-logging 26 | io.artifacthub.package.keywords=error,catcher,react,web,app,open,source,modern 27 | io.artifacthub.package.license=MIT 28 | io.artifacthub.package.logo-url=https://icoretech.github.io/helm/charts/airbroke/logo.png 29 | io.artifacthub.package.maintainers=[{"name":"Claudio Poli","email":"claudio@icorete.ch"}] 30 | io.artifacthub.package.readme-url=https://github.com/icoretech/airbroke/blob/main/README.md 31 | org.opencontainers.image.description=Airbroke is a a modern, React-based open source error catcher web application 32 | org.opencontainers.image.source=https://github.com/icoretech/airbroke 33 | org.opencontainers.image.title=Airbroke 34 | org.opencontainers.image.vendor=iCoreTech, Inc. 35 | org.opencontainers.image.documentation=https://github.com/icoretech/airbroke/blob/main/README.md 36 | org.opencontainers.image.url=https://airbroke.icorete.ch 37 | org.opencontainers.image.authors=Claudio Poli 38 | org.opencontainers.image.licenses=MIT 39 | org.opencontainers.image.revision=${{ github.sha }} 40 | org.opencontainers.image.version={{version}} 41 | tags: | 42 | type=semver,pattern={{version}} 43 | type=semver,pattern={{major}}.{{minor}} 44 | type=raw,value=latest 45 | 46 | - name: Login to GitHub Container Registry 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ghcr.io 50 | username: ${{ github.actor }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Build and push Docker image 54 | uses: docker/build-push-action@v6 55 | with: 56 | platforms: linux/amd64,linux/arm64 57 | push: ${{ github.event_name != 'pull_request' }} 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | cache-from: type=gha 61 | cache-to: type=gha,mode=max 62 | provenance: false 63 | -------------------------------------------------------------------------------- /.github/workflows/generate-release.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: googleapis/release-please-action@v4 18 | with: 19 | token: ${{ secrets.PACKAGES_PAT }} 20 | config-file: release-please-config.json 21 | manifest-file: .release-please-manifest.json 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | .DS_Store 38 | .env 39 | 40 | scripts/* 41 | docker-compose.override.yml 42 | 43 | .pnp.* 44 | .yarn/* 45 | !.yarn/patches 46 | !.yarn/plugins 47 | !.yarn/releases 48 | !.yarn/sdks 49 | !.yarn/versions 50 | -------------------------------------------------------------------------------- /.opencommitignore: -------------------------------------------------------------------------------- 1 | **/*.zip 2 | **/*.svg 3 | **/*.cjs 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | build 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "printWidth": 120, 8 | "plugins": [ 9 | "prettier-plugin-tailwindcss" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.1.80" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.enablePromptUseWorkspaceTsdk": true, 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit" 5 | }, 6 | "typescript.tsdk": "node_modules/typescript/lib", 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "makefile.configureOnOpen": false 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "lint-watch", 6 | "type": "shell", 7 | "command": "yarn", 8 | "args": ["lint:eslint:vscode:watch"], 9 | "problemMatcher": "$eslint-stylish", 10 | "options": { 11 | "cwd": "${workspaceFolder}" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableTelemetry: false 2 | 3 | nodeLinker: node-modules 4 | 5 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build --no-cache -t icoretech/airbroke:latest --progress=plain . 2 | # docker run -p 3000:3000 icoretech/airbroke:latest 3 | FROM --platform=$BUILDPLATFORM node:22.14-alpine AS base 4 | ARG DEBUG_TOOLS 5 | ENV NEXT_TELEMETRY_DISABLED=1 6 | ENV CHECKPOINT_DISABLE=1 7 | ENV NODE_ENV=production 8 | RUN apk add --no-cache libc6-compat dumb-init 9 | RUN [ "${DEBUG_TOOLS}" = "true" ] && apk add --no-cache inotify-tools htop net-tools lsof psmisc strace tcpdump || true 10 | RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs 11 | 12 | FROM base AS builder 13 | WORKDIR /app 14 | COPY . . 15 | RUN yarn install --immutable 16 | RUN yarn build 17 | 18 | FROM base AS runner 19 | USER nextjs 20 | WORKDIR /app 21 | COPY --from=builder --chown=1001:1001 /app/.next/standalone ./ 22 | COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static 23 | COPY --from=builder --chown=1001:1001 /app/prisma ./prisma 24 | EXPOSE 3000 25 | CMD ["dumb-init", "node", "server.js"] 26 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:22.14-alpine 2 | 3 | ENV NODE_ROOT=/app 4 | 5 | ARG NODE_ENV=development 6 | ENV NODE_ENV=${NODE_ENV} 7 | ENV NEXT_TELEMETRY_DISABLED=1 8 | ENV CHECKPOINT_DISABLE=1 9 | 10 | WORKDIR $NODE_ROOT 11 | 12 | RUN apk add --no-cache git 13 | 14 | COPY docker-yarn-entrypoint.sh /usr/local/bin/ 15 | RUN chmod +x /usr/local/bin/docker-yarn-entrypoint.sh 16 | 17 | EXPOSE 3000 18 | 19 | ENTRYPOINT ["docker-yarn-entrypoint.sh"] 20 | CMD ["yarn", "dev"] 21 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://paypal.me/cpoli', 'http://revolut.me/kain'] 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 iCoreTech, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: stop 2 | docker compose up $(filter-out $@,$(MAKECMDGOALS)) --remove-orphans --no-attach caddy 3 | 4 | stop: 5 | docker compose down --remove-orphans 6 | 7 | build: 8 | COMPOSE_BAKE=true docker compose build --pull 9 | 10 | resetdb: stop 11 | docker compose run --rm web npx prisma migrate reset --force 12 | 13 | # dummy rule that prevents make from printing an error message for 14 | # any targets that were not defined 15 | %: 16 | @: 17 | -------------------------------------------------------------------------------- /__tests__/factories/prismaFactories.ts: -------------------------------------------------------------------------------- 1 | // __tests__/factories/prismaFactories.ts 2 | 3 | import { prisma } from '@/lib/db'; 4 | import { faker } from '@faker-js/faker'; 5 | import type { HourlyOccurrence, Notice, Occurrence, Prisma, Project } from '@prisma/client'; 6 | 7 | export const createProject = async (overrides?: Partial): Promise => { 8 | const defaultProject: Prisma.ProjectCreateInput = { 9 | name: `${faker.company.name()} - ${faker.string.uuid()}`, 10 | organization: faker.company.name(), 11 | }; 12 | const projectData = { ...defaultProject, ...overrides }; 13 | return await prisma.project.create({ data: projectData }); 14 | }; 15 | 16 | export const createNotice = async ( 17 | project_id: string, 18 | overrides?: Partial 19 | ): Promise => { 20 | const defaultNotice: Prisma.NoticeUncheckedCreateInput = { 21 | project_id: project_id, 22 | env: faker.lorem.word(), 23 | kind: faker.lorem.word(), 24 | }; 25 | 26 | const noticeData = { ...defaultNotice, ...overrides }; 27 | return await prisma.notice.create({ data: noticeData }); 28 | }; 29 | 30 | export const createOccurrence = async ( 31 | notice_id: string, 32 | overrides?: Partial 33 | ): Promise => { 34 | const defaultOccurrence: Prisma.OccurrenceUncheckedCreateInput = { 35 | notice_id: notice_id, 36 | message: faker.lorem.sentence(5), 37 | }; 38 | 39 | const occurrenceData = { ...defaultOccurrence, ...overrides }; 40 | return await prisma.occurrence.create({ data: occurrenceData }); 41 | }; 42 | 43 | export const createOccurrenceSummary = async ( 44 | occurrence_id: string, 45 | overrides?: Partial 46 | ): Promise => { 47 | const defaultOccurrenceSummary: Prisma.HourlyOccurrenceUncheckedCreateInput = { 48 | occurrence_id: occurrence_id, 49 | interval_start: faker.date.between({ from: '2000-01-01T00:00:00.000Z', to: '2022-12-31T23:59:59.999Z' }), 50 | interval_end: faker.date.between({ from: '2023-01-01T00:00:00.000Z', to: '2030-12-31T23:59:59.999Z' }), 51 | }; 52 | 53 | const occurrenceSummaryData = { ...defaultOccurrenceSummary, ...overrides }; 54 | return await prisma.hourlyOccurrence.create({ data: occurrenceSummaryData }); 55 | }; 56 | -------------------------------------------------------------------------------- /__tests__/lib/generateUpdatedUrl.test.ts: -------------------------------------------------------------------------------- 1 | // __tests__/lib/generateUpdatedUrl.test.ts 2 | 3 | import { generateUpdatedURL } from '@/lib/generateUpdatedUrl'; 4 | import { describe, expect, it } from 'vitest'; 5 | 6 | /** 7 | * Helper to simulate ReadonlyURLSearchParams 8 | * (in practice, just using `new URLSearchParams(...)` is enough, 9 | * because we only need the `.toString()` behavior in the code). 10 | */ 11 | function makeParams(object: Record) { 12 | const params = new URLSearchParams(); 13 | for (const [key, value] of Object.entries(object)) { 14 | params.set(key, value); 15 | } 16 | return params; 17 | } 18 | 19 | describe('generateUpdatedURL', () => { 20 | it('should return the same URL if no new params are provided', () => { 21 | const currentParams = makeParams({ foo: 'bar', baz: 'qux' }); 22 | const newParams = {}; 23 | const result = generateUpdatedURL('/test', currentParams, newParams); 24 | expect(result).toBe('/test?foo=bar&baz=qux'); 25 | }); 26 | 27 | it('should override an existing param if present in new params', () => { 28 | const currentParams = makeParams({ foo: 'bar', baz: 'qux' }); 29 | const newParams = { foo: 'updated' }; 30 | const result = generateUpdatedURL('/test', currentParams, newParams); 31 | // 'foo' gets updated to 'updated'; 'baz' remains 32 | expect(result).toBe('/test?foo=updated&baz=qux'); 33 | }); 34 | 35 | it('should add new param if it does not exist in current params', () => { 36 | const currentParams = makeParams({ foo: 'bar' }); 37 | const newParams = { baz: 'qux' }; 38 | const result = generateUpdatedURL('/test', currentParams, newParams); 39 | // 'baz' is newly added 40 | expect(result).toBe('/test?foo=bar&baz=qux'); 41 | }); 42 | 43 | it('should add multiple new or override multiple existing params', () => { 44 | const currentParams = makeParams({ foo: 'bar', beep: 'boop' }); 45 | const newParams = { foo: 'updated', extra: 'value' }; 46 | const result = generateUpdatedURL('/test', currentParams, newParams); 47 | // 'foo' overwritten, 'extra' added, 'beep' stays as is 48 | expect(result).toBe('/test?foo=updated&beep=boop&extra=value'); 49 | }); 50 | 51 | it('should handle empty existing params gracefully', () => { 52 | const currentParams = makeParams({}); 53 | const newParams = { foo: 'bar' }; 54 | const result = generateUpdatedURL('/test', currentParams, newParams); 55 | // Only 'foo=bar' is present 56 | expect(result).toBe('/test?foo=bar'); 57 | }); 58 | 59 | it('should work if newParams is empty, returning the original query', () => { 60 | const currentParams = makeParams({ existing: 'param' }); 61 | const newParams = {}; 62 | const result = generateUpdatedURL('/test', currentParams, newParams); 63 | expect(result).toBe('/test?existing=param'); 64 | }); 65 | 66 | it('should handle a path that has no leading slash (not recommended but feasible)', () => { 67 | const currentParams = makeParams({ foo: 'bar' }); 68 | const newParams = { extra: '123' }; 69 | // Notice the pathname is just "test" without slash 70 | const result = generateUpdatedURL('test', currentParams, newParams); 71 | expect(result).toBe('test?foo=bar&extra=123'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /__tests__/lib/processError.test.ts: -------------------------------------------------------------------------------- 1 | // __tests__/lib/processError.test.ts 2 | 3 | import { NoticeError } from '@/lib/parseNotice'; 4 | import { processError } from '@/lib/processError'; 5 | import { describe, expect, test } from 'vitest'; 6 | import { createProject } from '../factories/prismaFactories'; 7 | 8 | describe('processError', () => { 9 | test('updates', async () => { 10 | const project = await createProject(); 11 | const errorData: NoticeError = { 12 | type: 'Error', 13 | message: 'Error: deadlock detected', 14 | backtrace: [], 15 | }; 16 | 17 | const contextData = { environment: 'test' }; 18 | const environmentData = {}; 19 | const sessionData = {}; 20 | const requestParamsData = {}; 21 | 22 | // run processError sequentially 23 | const sequentialRequests = 2; 24 | 25 | for (let i = 0; i < sequentialRequests; i++) { 26 | await expect( 27 | processError(project, errorData, contextData, environmentData, sessionData, requestParamsData) 28 | ).resolves.not.toThrow(); 29 | } 30 | }); 31 | 32 | test('handles deadlocks', async () => { 33 | const project = await createProject(); 34 | 35 | const errorData: NoticeError = { 36 | type: 'Error', 37 | message: 'Error: deadlock detected', 38 | backtrace: [], 39 | }; 40 | 41 | const contextData = { environment: 'test' }; 42 | const environmentData = {}; 43 | const sessionData = {}; 44 | const requestParamsData = {}; 45 | 46 | // run processError in parallel 47 | const parallelRequests = 5; 48 | const promises = []; 49 | 50 | for (let i = 0; i < parallelRequests; i++) { 51 | promises.push(processError(project, errorData, contextData, environmentData, sessionData, requestParamsData)); 52 | } 53 | 54 | // With the new concurrency safe code, we shouldn't get P2025 or P2002 55 | await expect(Promise.all(promises)).resolves.not.toThrow(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /__tests__/pages/home.test.tsx: -------------------------------------------------------------------------------- 1 | // __tests__/pages/home.test.tsx 2 | 3 | import { render, screen } from '@testing-library/react'; 4 | import { describe, expect, test } from 'vitest'; 5 | import HomePage from '../../app/page'; 6 | 7 | describe('Home', () => { 8 | test('renders a heading', () => { 9 | render(); 10 | 11 | const heading = screen.getByRole('heading', { 12 | name: /Hello, welcome to Airbroke/i, 13 | }); 14 | 15 | expect(heading).toBeDefined(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /app/_actions.ts: -------------------------------------------------------------------------------- 1 | // app/_actions.ts 2 | 3 | export * from '@/lib/actions'; 4 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/auth/[...nextauth]/route.ts 2 | 3 | import { handlers } from '@/lib/auth'; 4 | 5 | export const { GET, POST } = handlers; 6 | -------------------------------------------------------------------------------- /app/api/completion/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/completion/route.ts 2 | 3 | import { auth } from '@/lib/auth'; 4 | import { prisma } from '@/lib/db'; 5 | import { type OpenAIProviderSettings, createOpenAI } from '@ai-sdk/openai'; 6 | import { CoreMessage, streamText } from 'ai'; 7 | 8 | export const dynamic = 'force-dynamic'; 9 | export const maxDuration = 30; 10 | 11 | export async function POST(request: Request) { 12 | const session = await auth(); 13 | if (!session) { 14 | return new Response('You are not logged in', { status: 401 }); 15 | } 16 | 17 | const body = await request.json(); 18 | const { isDetailMode, occurrenceId } = body; 19 | 20 | if (!process.env.AIRBROKE_OPENAI_API_KEY) { 21 | return new Response('Unauthorized', { status: 401 }); 22 | } 23 | 24 | if (!occurrenceId) { 25 | return new Response('Missing occurrence', { status: 400 }); 26 | } 27 | 28 | const occurrenceWithRelations = await prisma.occurrence.findFirst({ 29 | where: { id: occurrenceId }, 30 | include: { notice: { include: { project: true } } }, 31 | }); 32 | 33 | if (!occurrenceWithRelations) { 34 | return new Response('Occurrence not found', { status: 404 }); 35 | } 36 | 37 | const { notice, ...occurrence } = occurrenceWithRelations; 38 | 39 | const errorType = notice.kind; 40 | const errorMessage = occurrence.message; 41 | 42 | let prompt = `I encountered an error of type "${errorType}" with the following message: "${errorMessage}". Explain what this error means and suggest possible solutions.`; 43 | 44 | if (isDetailMode) { 45 | const backtraceString = JSON.stringify(occurrence.backtrace, null, 2); 46 | prompt += ` The backtrace of the error is as follows: ${backtraceString}`; 47 | } 48 | 49 | // Prepare the messages array for the AI model 50 | const messages: CoreMessage[] = [ 51 | { 52 | role: 'user', 53 | content: prompt, 54 | }, 55 | ]; 56 | 57 | const openAISettings: OpenAIProviderSettings = { 58 | apiKey: process.env.AIRBROKE_OPENAI_API_KEY ?? '', 59 | compatibility: 'strict', 60 | ...(process.env.AIRBROKE_OPENAI_ORGANIZATION ? { organization: process.env.AIRBROKE_OPENAI_ORGANIZATION } : {}), 61 | }; 62 | 63 | const openaiProvider = createOpenAI(openAISettings); 64 | const model = openaiProvider.responses(process.env.AIRBROKE_OPENAI_ENGINE || 'gpt-4o'); 65 | 66 | // Stream the AI's response using streamText 67 | const result = streamText({ 68 | model, 69 | messages, 70 | }); 71 | 72 | // Return the streamed response 73 | return result.toDataStreamResponse(); 74 | } 75 | -------------------------------------------------------------------------------- /app/api/hc/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/hc/route.ts 2 | 3 | import { prisma } from '@/lib/db'; 4 | import { NextRequest, NextResponse } from 'next/server'; 5 | 6 | // GET /api/hc 7 | // Health Check API Endpoint 8 | export async function GET(request: NextRequest) { 9 | // Perform a check to ensure the database is working 10 | 11 | // Extract the value of the 'source' query parameter from the URL 12 | const source = request.nextUrl.searchParams.get('source'); 13 | 14 | // Retrieve a project from the database using Prisma 15 | await prisma.project.findFirst(); 16 | 17 | // Create a response containing information about the request method, URL pathname, and 'source' query parameter 18 | return new NextResponse(`${request.method} ${request.nextUrl.pathname} ${source}`); 19 | } 20 | -------------------------------------------------------------------------------- /app/api/v3/notices/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/v3/notices/route.ts 2 | 3 | import { prisma } from '@/lib/db'; 4 | import parseNotice, { NoticeData } from '@/lib/parseNotice'; 5 | import { processError } from '@/lib/processError'; 6 | import { customAlphabet, urlAlphabet } from 'nanoid'; 7 | import { NextRequest, NextResponse } from 'next/server'; 8 | 9 | interface ProjectKeyInfo { 10 | projectKey: string; 11 | requestType: 'params' | 'headers' | 'unauthenticated'; 12 | } 13 | 14 | function extractProjectKeyFromRequest(request: NextRequest): ProjectKeyInfo { 15 | const clientKey = request.nextUrl.searchParams.get('key'); 16 | const authorization = request.headers.get('Authorization'); 17 | const airbrakeToken = request.headers.get('X-Airbrake-Token'); 18 | 19 | if (clientKey) { 20 | return { projectKey: clientKey, requestType: 'params' }; 21 | } else if (authorization) { 22 | const [, token] = authorization.split(' '); 23 | return { projectKey: token, requestType: 'headers' }; 24 | } else if (airbrakeToken) { 25 | return { projectKey: airbrakeToken, requestType: 'headers' }; 26 | } 27 | 28 | return { projectKey: '', requestType: 'unauthenticated' }; 29 | } 30 | 31 | async function parseRequestBody(request: NextRequest) { 32 | let whitelisted; 33 | 34 | const contentType = request.headers.get('content-type') || ''; 35 | 36 | if (contentType === 'text/plain' || contentType === '') { 37 | // older clients, airbrake-js, etc. 38 | const rawBody = await request.text(); 39 | const parsedBody = JSON.parse(rawBody) as NoticeData; 40 | whitelisted = parseNotice(parsedBody); 41 | } else { 42 | const jsonBody = (await request.json()) as NoticeData; 43 | whitelisted = parseNotice(jsonBody); 44 | } 45 | 46 | return whitelisted; 47 | } 48 | 49 | function generateCorsHeaders() { 50 | const corsOrigins = process.env.AIRBROKE_CORS_ORIGINS?.split(',') || []; 51 | 52 | return { 53 | 'Access-Control-Allow-Methods': 'POST, OPTIONS', 54 | 'Access-Control-Allow-Headers': 'origin, accept, content-type, authorization', 55 | 'Access-Control-Allow-Origin': corsOrigins.length > 0 ? corsOrigins.join(', ') : '*', 56 | }; 57 | } 58 | 59 | function getServerHostname(request: NextRequest) { 60 | const host = request.headers.get('host'); 61 | const protocols = (request.headers.get('x-forwarded-proto') || 'https').split(','); 62 | const protocol = protocols[0].trim(); 63 | 64 | if (host) { 65 | return `${protocol}://${host}`; 66 | } 67 | 68 | return null; 69 | } 70 | 71 | // POST /api/v3/projects/1/notices 72 | async function POST(request: NextRequest) { 73 | const { projectKey, requestType } = extractProjectKeyFromRequest(request); 74 | 75 | const project = await prisma.project.findFirst({ where: { api_key: projectKey } }); 76 | if (!project || project.paused) { 77 | const json_response = { error: '**Airbroke: Project not found or paused' }; 78 | if (requestType === 'params') { 79 | return NextResponse.json(json_response, { 80 | status: 404, 81 | headers: generateCorsHeaders(), 82 | }); 83 | } else { 84 | const headers = { 85 | ...generateCorsHeaders(), 86 | 'WWW-Authenticate': `Bearer realm="Airbroke"`, 87 | }; 88 | return NextResponse.json(json_response, { status: 404, headers }); 89 | } 90 | } 91 | 92 | const whitelisted = await parseRequestBody(request); 93 | 94 | const errors = whitelisted.errors; 95 | const context = whitelisted.context; 96 | const environment = whitelisted.environment; 97 | const session = whitelisted.session; 98 | const requestParams = whitelisted.params; 99 | 100 | for (const error of errors) { 101 | await processError(project, error, context, environment, session, requestParams); 102 | } 103 | 104 | const customNanoid = customAlphabet(urlAlphabet, 21); 105 | const responseJSON = { 106 | id: customNanoid(), 107 | url: `${getServerHostname(request)}/projects/${project.id}`, 108 | }; 109 | 110 | return NextResponse.json(responseJSON, { 111 | status: 201, 112 | headers: generateCorsHeaders(), 113 | }); 114 | } 115 | 116 | async function OPTIONS() { 117 | return new NextResponse('', { status: 200, headers: generateCorsHeaders() }); 118 | } 119 | 120 | export { OPTIONS, POST }; 121 | -------------------------------------------------------------------------------- /app/bookmarks/page.tsx: -------------------------------------------------------------------------------- 1 | // app/bookmarks/page.tsx 2 | 3 | import BookmarksTable from '@/components/BookmarksTable'; 4 | import { DashboardShell } from '@/components/DashboardShell'; 5 | import { cookies } from 'next/headers'; 6 | 7 | export default async function Bookmarks(props: { searchParams: Promise> }) { 8 | const cookieStore = await cookies(); 9 | const initialSidebarOpen = cookieStore.get('sidebarOpen')?.value === 'true'; 10 | 11 | const searchParams = await props.searchParams; 12 | const searchQuery = searchParams.searchQuery; 13 | 14 | return ( 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /app/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | // app/layout.tsx 2 | 3 | import { Roboto_Condensed, Roboto_Mono } from 'next/font/google'; 4 | import './globals.css'; 5 | 6 | export const metadata = { 7 | title: 'Airbroke', 8 | description: 'Self-hosted, Cost-effective and Open Source Error Tracking.', 9 | }; 10 | 11 | const robotoCondensed = Roboto_Condensed({ 12 | subsets: ['latin'], 13 | display: 'swap', 14 | variable: '--font-roboto-condensed', 15 | }); 16 | 17 | const robotoMono = Roboto_Mono({ 18 | subsets: ['latin'], 19 | display: 'swap', 20 | variable: '--font-roboto-mono', 21 | }); 22 | 23 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 24 | return ( 25 | 26 | {children} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | // app/loading.tsx 2 | 3 | import { SlDisc } from 'react-icons/sl'; 4 | 5 | export default function Loading() { 6 | return ( 7 |
8 |
9 | 10 | Loading... 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | // app/not-found.tsx 2 | 3 | import Link from 'next/link'; 4 | import { TbFileXFilled } from 'react-icons/tb'; 5 | 6 | export default function NotFound() { 7 | return ( 8 |
9 |
10 | 11 | 12 |

404

13 |

Page Not Found

14 |

15 | Oops! We couldn’t find the page you’re looking for. It might have been removed or you may have typed the wrong 16 | URL. 17 |

18 | 19 | 23 | Back to Projects 24 | 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/notices/[notice_id]/page.tsx: -------------------------------------------------------------------------------- 1 | // app/notices/[notice_id]/page.tsx 2 | 3 | import { DashboardShell } from '@/components/DashboardShell'; 4 | import OccurrencesTable from '@/components/OccurrencesTable'; 5 | import { getNoticeById } from '@/lib/queries/notices'; 6 | import { cookies } from 'next/headers'; 7 | import Link from 'next/link'; 8 | import { notFound } from 'next/navigation'; 9 | 10 | type ComponentProps = { 11 | params: Promise<{ notice_id: string }>; 12 | searchParams: Promise<{ [key: string]: string | undefined }>; 13 | }; 14 | 15 | export async function generateMetadata(props: ComponentProps) { 16 | const noticeId = (await props.params).notice_id; 17 | const notice = await getNoticeById(noticeId); 18 | return { title: `(${notice?.project?.name}) ${notice?.kind}` }; 19 | } 20 | 21 | // /notices/:notice_id 22 | export default async function Notice(props: ComponentProps) { 23 | const [cookieStore, resolvedSearchParams, resolvedParams] = await Promise.all([ 24 | cookies(), 25 | props.searchParams, 26 | props.params, 27 | ]); 28 | const initialSidebarOpen = cookieStore.get('sidebarOpen')?.value === 'true'; 29 | 30 | const notice = await getNoticeById(resolvedParams.notice_id); 31 | if (!notice) { 32 | notFound(); 33 | } 34 | 35 | return ( 36 | 37 |
38 |
39 |

40 | 41 | {notice.project.organization} / {notice.project.name} 42 | 43 |

44 |

{notice.kind}

45 |
46 | 47 |
48 | 52 | Edit Project 53 | 54 |
55 |
56 | 57 | 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | // app/page.tsx 2 | 3 | import Background from '@/components/Background'; 4 | import FooterCredits from '@/components/FooterCredits'; 5 | import logo from '@/public/logo.svg'; 6 | import screenshot from '@/public/screenshot.png'; 7 | import Image from 'next/image'; 8 | import Link from 'next/link'; 9 | import { FaGithub } from 'react-icons/fa'; 10 | 11 | export default function HomePage() { 12 | return ( 13 |
14 |
15 | 16 |
17 |
18 | Airbroke logo 19 | 26 |

27 | Hello, welcome to Airbroke 28 |

29 |

30 | Self-hosted, Cost-effective and Open Source Error Tracker. 31 |

32 |
33 | 37 | Go to Projects 38 | 39 | 40 | 41 | Learn more 42 | 43 |
44 |
45 |
46 |
47 | Airbroke screenshot 55 |
56 |
57 |
58 |
59 | 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/projects/[project_id]/Filter.tsx: -------------------------------------------------------------------------------- 1 | // app/projects/[project_id]/Filter.tsx 2 | 3 | 'use client'; 4 | 5 | import { getEnvironmentClasses } from '@/lib/environmentStyles'; 6 | import { generateUpdatedURLWithRemovals } from '@/lib/generateUpdatedUrlWithRemovals'; 7 | import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; 8 | import clsx from 'clsx'; 9 | import Link from 'next/link'; 10 | import { usePathname, useSearchParams } from 'next/navigation'; 11 | import { Fragment } from 'react'; 12 | import { MdOutlineFilterList, MdOutlineFilterListOff } from 'react-icons/md'; 13 | 14 | export default function Filter({ environments }: { environments: string[] }) { 15 | const pathname = usePathname(); 16 | const searchParams = useSearchParams(); 17 | const { filterByEnv } = Object.fromEntries(searchParams); 18 | 19 | return ( 20 |
21 | 22 | 23 | Environment 24 | 26 | 27 | 36 | 37 | {/* List environment items */} 38 | {environments.map((env) => { 39 | const envStyle = getEnvironmentClasses(env); 40 | const isActive = filterByEnv === env; 41 | 42 | return ( 43 | 44 | {({ focus }) => ( 45 | 54 | {env} 55 | 56 | )} 57 | 58 | ); 59 | })} 60 |
61 | {/* “Clear filter” item with an icon */} 62 | 63 | {({ focus }) => ( 64 | 71 | 76 | 77 | 78 |
79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /app/projects/[project_id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | // app/projects/[project_id]/edit/page.tsx 2 | 3 | import { DashboardShell } from '@/components/DashboardShell'; 4 | import IntegrationsGrid from '@/components/IntegrationsGrid'; 5 | import EditForm from '@/components/project/EditForm'; 6 | import Overview from '@/components/project/Overview'; 7 | import { getProjectById } from '@/lib/queries/projects'; 8 | import clsx from 'clsx'; 9 | import { cookies } from 'next/headers'; 10 | import Link from 'next/link'; 11 | import { notFound } from 'next/navigation'; 12 | import { MdDataObject, MdDataSaverOff, MdDriveFileRenameOutline } from 'react-icons/md'; 13 | 14 | import type { ProjectTabs } from '@/types/airbroke'; 15 | import type { Route } from 'next'; 16 | 17 | type ComponentProps = { 18 | params: Promise<{ project_id: string }>; 19 | searchParams: Promise>; 20 | }; 21 | 22 | export default async function Project(props: ComponentProps) { 23 | const cookieStore = await cookies(); 24 | const initialSidebarOpen = cookieStore.get('sidebarOpen')?.value === 'true'; 25 | 26 | const searchParams = await props.searchParams; 27 | const params = await props.params; 28 | 29 | const currentTab = searchParams.tab ?? 'overview'; 30 | 31 | const project = await getProjectById(params.project_id); 32 | if (!project) { 33 | notFound(); 34 | } 35 | 36 | const replacements = { 37 | REPLACE_PROJECT_KEY: project.api_key, 38 | }; 39 | 40 | const tabs: ProjectTabs = { 41 | overview: { 42 | id: 'overview', 43 | name: 'Overview', 44 | current: currentTab === 'overview', 45 | icon: MdDataSaverOff, 46 | href: `/projects/${project.id}/edit?tab=overview` as Route, 47 | }, 48 | integrations: { 49 | id: 'integrations', 50 | name: 'Integrations', 51 | current: currentTab === 'integrations', 52 | icon: MdDataObject, 53 | href: `/projects/${project.id}/edit?tab=integrations` as Route, 54 | }, 55 | edit: { 56 | id: 'edit', 57 | name: 'Edit', 58 | current: currentTab === 'edit', 59 | icon: MdDriveFileRenameOutline, 60 | href: `/projects/${project.id}/edit?tab=edit` as Route, 61 | }, 62 | }; 63 | 64 | return ( 65 | 66 |
67 |
68 |
69 | 95 |
96 |
97 | 98 | {currentTab === 'overview' && } 99 | {currentTab === 'integrations' && ( 100 |
101 | 102 |
103 | )} 104 | {currentTab === 'edit' && } 105 |
106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /app/projects/[project_id]/page.tsx: -------------------------------------------------------------------------------- 1 | // app/projects/[project_id]/page.tsx 2 | 3 | import { DashboardShell } from '@/components/DashboardShell'; 4 | import NoticesTable from '@/components/NoticesTable'; 5 | import { getNoticeEnvs } from '@/lib/queries/notices'; 6 | import { getProjectById } from '@/lib/queries/projects'; 7 | import { cookies } from 'next/headers'; 8 | import Link from 'next/link'; 9 | import { notFound } from 'next/navigation'; 10 | import Filter from './Filter'; 11 | import Sort from './Sort'; 12 | 13 | type ComponentProps = { 14 | params: Promise<{ project_id: string }>; 15 | searchParams: Promise<{ [key: string]: string | undefined }>; 16 | }; 17 | 18 | export async function generateMetadata(props: ComponentProps) { 19 | const projectId = (await props.params).project_id; 20 | const project = await getProjectById(projectId); 21 | return { title: project?.name }; 22 | } 23 | 24 | // /projects/:project_id 25 | export default async function ProjectNotices(props: ComponentProps) { 26 | const [cookieStore, resolvedSearchParams, resolvedParams] = await Promise.all([ 27 | cookies(), 28 | props.searchParams, 29 | props.params, 30 | ]); 31 | const initialSidebarOpen = cookieStore.get('sidebarOpen')?.value === 'true'; 32 | 33 | const project = await getProjectById(resolvedParams.project_id); 34 | if (!project) { 35 | notFound(); 36 | } 37 | 38 | const uniqueEnvArray = await getNoticeEnvs(project.id); 39 | 40 | return ( 41 | 42 |
43 |
44 |

45 | {project.organization} / {project.name} 46 |

47 |

API Key: {project.api_key}

48 |
49 | 50 |
51 | 55 | Edit Project 56 | 57 |
58 |
59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 | 67 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/projects/new/page.tsx: -------------------------------------------------------------------------------- 1 | // app/projects/new/page.tsx 2 | 3 | import CreateProjectProposal from '@/components/CreateProjectProposal'; 4 | import { DashboardShell } from '@/components/DashboardShell'; 5 | import { cookies } from 'next/headers'; 6 | 7 | export async function generateMetadata() { 8 | return { title: 'New Project' }; 9 | } 10 | 11 | // /projects/new 12 | export default async function NewProject() { 13 | const cookieStore = await cookies(); 14 | const initialSidebarOpen = cookieStore.get('sidebarOpen')?.value === 'true'; 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/projects/page.tsx: -------------------------------------------------------------------------------- 1 | // app/projects/page.tsx 2 | 3 | import CounterLabel from '@/components/CounterLabel'; 4 | import CreateProjectProposal from '@/components/CreateProjectProposal'; 5 | import { DashboardShell } from '@/components/DashboardShell'; 6 | import OccurrencesChartBackground from '@/components/OccurrencesChartBackground'; 7 | import PingDot from '@/components/PingDot'; 8 | import { cachedProjectChartOccurrencesData } from '@/lib/actions/projectActions'; 9 | import { getProjects } from '@/lib/queries/projects'; 10 | import { cookies } from 'next/headers'; 11 | import Link from 'next/link'; 12 | import { TbFileAlert } from 'react-icons/tb'; 13 | 14 | type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>; 15 | 16 | export async function generateMetadata() { 17 | return { title: 'Projects' }; 18 | } 19 | 20 | // /projects 21 | export default async function Projects(props: { searchParams: SearchParams }) { 22 | const resolvedSearchParams = await props.searchParams; 23 | const searchQuery = resolvedSearchParams.searchQuery; 24 | const currentSearchTerm = typeof searchQuery === 'string' ? searchQuery : ''; 25 | const cookieStore = await cookies(); 26 | const initialSidebarOpen = cookieStore.get('sidebarOpen')?.value === 'true'; 27 | 28 | const projects = await getProjects(currentSearchTerm); 29 | 30 | if (projects.length === 0) { 31 | return ( 32 | 33 | {currentSearchTerm ? ( 34 |
35 |
39 | ) : ( 40 | 41 | )} 42 |
43 | ); 44 | } 45 | 46 | const projectsWithChartData = await Promise.all( 47 | projects.map(async (project) => { 48 | const chartData = await cachedProjectChartOccurrencesData(project.id); 49 | return { ...project, chartData }; 50 | }) 51 | ); 52 | 53 | return ( 54 | 55 |
    56 | {projectsWithChartData.map((project) => { 57 | // Decide color class 58 | const isEmpty = project.notices_count === BigInt(0); 59 | const colorKey = project.paused ? 'gray' : isEmpty ? 'green' : 'red'; 60 | const badgeLabel = `${project.organization} / ${project.name}`; 61 | 62 | return ( 63 |
  • 64 | {/* Chart as background */} 65 |
    66 | 67 |
    68 | 69 | 70 |
    71 | 72 | {/* Dot with ping animation */} 73 | 74 | {badgeLabel} 75 | 76 | 77 | {/* Right side: CounterLabel */} 78 |
    79 | 80 |
    81 |
    82 | 83 |
  • 84 | ); 85 | })} 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /app/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /app/signin/SignInPageClient.tsx: -------------------------------------------------------------------------------- 1 | // app/signin/SignInPageClient.tsx 2 | 3 | 'use client'; 4 | 5 | import FooterCredits from '@/components/FooterCredits'; 6 | import logo from '@/public/logo.svg'; 7 | import { signIn as nextAuthSignIn } from 'next-auth/react'; 8 | import Image from 'next/image'; 9 | import { useSearchParams } from 'next/navigation'; 10 | import { useState } from 'react'; 11 | import { FaApple, FaBitbucket, FaGithub, FaGitlab, FaGoogle, FaSlack } from 'react-icons/fa'; 12 | import { SiAmazoncognito, SiAuthentik, SiKeycloak, SiOkta } from 'react-icons/si'; 13 | import { SlDisc } from 'react-icons/sl'; 14 | import { VscAzure } from 'react-icons/vsc'; 15 | 16 | const providerIcons: Record = { 17 | github: FaGithub, 18 | atlassian: FaBitbucket, 19 | google: FaGoogle, 20 | gitlab: FaGitlab, 21 | keycloak: SiKeycloak, 22 | 'microsoft-entra-id': VscAzure, 23 | apple: FaApple, 24 | authentik: SiAuthentik, 25 | slack: FaSlack, 26 | okta: SiOkta, 27 | cognito: SiAmazoncognito, 28 | }; 29 | 30 | type ProviderInfo = { 31 | id: string; 32 | name: string; 33 | }; 34 | 35 | interface SignInPageClientProps { 36 | providers: ProviderInfo[]; 37 | } 38 | 39 | export default function SignInPageClient({ providers }: SignInPageClientProps) { 40 | const [signingInProvider, setSigningInProvider] = useState(null); 41 | 42 | const searchParams = useSearchParams(); 43 | const callbackUrl = searchParams.get('callbackUrl') ?? '/projects'; 44 | const error = searchParams.get('error'); 45 | const showError = Boolean(error); 46 | 47 | const handleSignIn = (providerId: string) => { 48 | setSigningInProvider(providerId); 49 | nextAuthSignIn(providerId, { callbackUrl: callbackUrl }); 50 | }; 51 | 52 | return ( 53 |
54 |
55 |
56 | Airbroke Logo 57 |

Sign in to Airbroke

58 |

Choose your preferred sign-in method below.

59 |
60 | 61 | {showError && ( 62 |
63 | Sign-in error: {error}. 64 |
65 | )} 66 | 67 | {/* Provider Buttons */} 68 |
69 | {providers.map((provider) => { 70 | const Icon = providerIcons[provider.id]; 71 | const isThisButtonSigningIn = signingInProvider === provider.id; 72 | 73 | return ( 74 | 90 | ); 91 | })} 92 |
93 |
94 | {/* Footer */} 95 |

96 | 97 |

98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /app/signin/page.tsx: -------------------------------------------------------------------------------- 1 | // app/signin/page.tsx 2 | 3 | import { auth, getSerializedProviders } from '@/lib/auth'; 4 | import { redirect } from 'next/navigation'; 5 | import SignInPageClient from './SignInPageClient'; 6 | 7 | export default async function SignInPage() { 8 | const session = await auth(); 9 | if (session) { 10 | redirect('/projects'); 11 | } 12 | 13 | const providers = await getSerializedProviders(); 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /components/Background.tsx: -------------------------------------------------------------------------------- 1 | // components/Background.tsx 2 | 3 | export default function Background() { 4 | return ( 5 | <> 6 | 23 |