diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
new file mode 100644
index 0000000..7591c77
--- /dev/null
+++ b/.github/release-drafter.yml
@@ -0,0 +1,51 @@
+name-template: 'v$RESOLVED_VERSION'
+tag-template: 'v$RESOLVED_VERSION'
+change-template: '* $TITLE (#$NUMBER) by @$AUTHOR'
+template: |
+ $CHANGES
+
+ **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
+
+sort-direction: ascending
+
+categories:
+ - title: 'â ī¸ BREAKING CHANGES'
+ label: 'breaking'
+ - title: 'đĢ Features'
+ label: 'feature'
+ - title: 'đ ī¸ Bug fixes'
+ label: 'fix'
+ - title: 'đšī¸ Others'
+ label: 'chore'
+
+version-resolver:
+ # Major is not meant to be used at the moment.
+ # Should be used with label breaking in the future.
+ major:
+ labels:
+ - 'major'
+ minor:
+ labels:
+ - 'breaking'
+ - 'feature'
+ - 'chore'
+ patch:
+ labels:
+ - 'fix'
+
+exclude-labels:
+ - 'skip-changelog'
+
+autolabeler:
+ - label: 'breaking'
+ title:
+ - '/!:/i'
+ - label: 'chore'
+ title:
+ - '/^chore/i'
+ - label: 'fix'
+ title:
+ - '/^fix/i'
+ - label: 'feature'
+ title:
+ - '/^feat/i'
diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
new file mode 100644
index 0000000..baf4e9c
--- /dev/null
+++ b/.github/workflows/docker.yaml
@@ -0,0 +1,98 @@
+name: Docker
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'src/**'
+ - '*.ts'
+ - '*.json'
+ - '*.yaml'
+ - Dockerfile
+ - .github/workflows/docker.yaml
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'src/**'
+ - '*.ts'
+ - '*.json'
+ - '*.yaml'
+ - Dockerfile
+ - .github/workflows/docker.yaml
+ release:
+ types:
+ - published
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+ PLATFORMS: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+
+ - name: "Generate Build ID (main)"
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ run: |
+ branch=${GITHUB_REF##*/}
+ sha=${GITHUB_SHA::8}
+ ts=$(date +%s)
+ echo "BUILD_ID=${branch}-${ts}-${sha}" >> $GITHUB_ENV
+
+ - name: "Generate Build ID (PR)"
+ if: github.event_name == 'pull_request'
+ run: |
+ echo "BUILD_ID=pr-${{ github.event.number }}-$GITHUB_RUN_ID" >> $GITHUB_ENV
+
+ - name: "Generate Build ID (Release)"
+ if: github.event_name == 'release'
+ run: |
+ echo "BUILD_ID=${GITHUB_REF##*/}" >> $GITHUB_ENV
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@v2
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=pr
+ type=ref,event=branch
+ type=raw,value=${{ env.BUILD_ID }}
+ type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ platforms: ${{ env.PLATFORMS }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/pr-title.yaml b/.github/workflows/pr-title.yaml
new file mode 100644
index 0000000..f6064af
--- /dev/null
+++ b/.github/workflows/pr-title.yaml
@@ -0,0 +1,29 @@
+name: 'Validate PR title'
+
+on:
+ pull_request:
+ branches: [ main ]
+ types: [ opened, reopened, synchronize ]
+
+permissions:
+ pull-requests: read
+ statuses: write
+
+jobs:
+ main:
+ name: Validate PR title
+ runs-on: ubuntu-latest
+ steps:
+ # Please look up the latest version from
+ # https://github.com/amannn/action-semantic-pull-request/releases
+ - uses: amannn/action-semantic-pull-request@v5
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ types: |
+ fix
+ feat
+ chore
+ requireScope: false
+ wip: true
+ validateSingleCommit: false
diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml
new file mode 100644
index 0000000..49a0a21
--- /dev/null
+++ b/.github/workflows/release-drafter.yaml
@@ -0,0 +1,22 @@
+name: Release Drafter
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+ types: [ opened, reopened, synchronize ]
+
+permissions:
+ contents: read
+
+jobs:
+ update_release_draft:
+ permissions:
+ contents: write
+ pull-requests: write
+ runs-on: ubuntu-latest
+ steps:
+ - uses: release-drafter/release-drafter@v5
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d1961bd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,70 @@
+# syntax=docker.io/docker/dockerfile:1
+
+FROM node:22-alpine AS base
+
+# Install dependencies only when needed
+FROM base AS deps
+# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+# Install dependencies based on the preferred package manager
+COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
+RUN \
+ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
+ elif [ -f package-lock.json ]; then npm ci; \
+ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
+ else echo "Lockfile not found." && exit 1; \
+ fi
+
+
+# Rebuild the source code only when needed
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# Next.js collects completely anonymous telemetry data about general usage.
+# Learn more here: https://nextjs.org/telemetry
+# Uncomment the following line in case you want to disable telemetry during the build.
+# ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN \
+ if [ -f yarn.lock ]; then yarn run build; \
+ elif [ -f package-lock.json ]; then npm run build; \
+ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
+ else echo "Lockfile not found." && exit 1; \
+ fi
+
+# Production image, copy all the files and run next
+FROM base AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+# Uncomment the following line in case you want to disable telemetry during runtime.
+# ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+
+# Set the correct permission for prerender cache
+RUN mkdir .next
+RUN chown nextjs:nodejs .next
+
+# Automatically leverage output traces to reduce image size
+# https://nextjs.org/docs/advanced-features/output-file-tracing
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT=3000
+
+# server.js is created by next build from the standalone output
+# https://nextjs.org/docs/pages/api-reference/next-config-js/output
+ENV HOSTNAME="0.0.0.0"
+CMD ["node", "server.js"]
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..68a6c64 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "standalone",
};
export default nextConfig;
diff --git a/public/humans.txt b/public/humans.txt
new file mode 100644
index 0000000..e8de72d
--- /dev/null
+++ b/public/humans.txt
@@ -0,0 +1,3 @@
+/* TEAM */
+Name: Matthieu Moquet
+Contact: matthieu [at] moquet [dot] net
diff --git a/src/app/alerts/[handle]/page.tsx b/src/app/alerts/[handle]/page.tsx
index c3af1cd..32d13de 100644
--- a/src/app/alerts/[handle]/page.tsx
+++ b/src/app/alerts/[handle]/page.tsx
@@ -1,6 +1,4 @@
import { AlertsTemplate } from "@/components/alerts/template"
-import { config } from "@/config"
-import { notFound } from "next/navigation"
type Props = {
params: Promise<{ handle: string }>
@@ -9,10 +7,6 @@ type Props = {
export default async function AlertsViewPage(props: Props) {
const { handle } = await props.params
- if (!config.views[handle]) {
- return notFound()
- }
-
return (
)
diff --git a/src/app/api/clusters/[slug]/alerts/route.ts b/src/app/api/clusters/[slug]/alerts/route.ts
index 5814330..991663c 100644
--- a/src/app/api/clusters/[slug]/alerts/route.ts
+++ b/src/app/api/clusters/[slug]/alerts/route.ts
@@ -1,10 +1,11 @@
-import { config } from "@/config";
+import { getConfig } from "@/config";
export async function GET(
_: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const cluster = (await params).slug
+ const config = await getConfig()
const endpoint = config.clusters.find((c) => c.name === cluster)?.endpoint
if (!endpoint) {
diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts
index 25e9306..2a0b85c 100644
--- a/src/app/api/config/route.ts
+++ b/src/app/api/config/route.ts
@@ -1,6 +1,17 @@
-import { clientConfig } from "@/config/client";
+import { getConfig } from "@/config";
export async function GET(_: Request) {
+ const config = await getConfig()
+
+ // Remove the endpoints from the client config
+ const clientConfig = {
+ ...config,
+ clusters: config.clusters.map((c) => ({
+ ...c,
+ endpoint: ""
+ })),
+ }
+
return new Response(JSON.stringify(clientConfig), {
headers: { "Content-Type": "application/json" },
});
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e36a020..f1ee913 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,12 +1,13 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
-import { clientConfig } from "@/config/client";
import { ConfigProvider } from "@/contexts/config";
import { ThemeProvider } from "next-themes";
import { AppLayout } from "@/components/layout/app-layout";
import { AlertsProvider } from "@/contexts/alerts";
+export const dynamic = "force-dynamic";
+
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
@@ -23,7 +24,7 @@ export const metadata: Metadata = {
description: "Dashboard for AlertManager Prometheus",
};
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
@@ -39,7 +40,7 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
-
+
{children}
diff --git a/src/components/alerts/template.tsx b/src/components/alerts/template.tsx
index a7f01ec..ccee57e 100644
--- a/src/components/alerts/template.tsx
+++ b/src/components/alerts/template.tsx
@@ -10,7 +10,7 @@ import { AlertModal } from './alert-modal'
import { LabelFilter, Group } from './types'
import { useConfig } from '@/contexts/config'
import { alertFilter, alertSort } from './utils'
-
+import { notFound } from 'next/navigation'
type Props = {
view: string
@@ -22,6 +22,10 @@ export function AlertsTemplate(props: Props) {
const { alerts, loading, errors, refreshAlerts } = useAlerts()
const [selectedAlert, setSelectedAlert] = useState(null)
+ if (!config.views[view]) {
+ return notFound()
+ }
+
const { filters, groupBy } = config.views[view]
// Flatten alerts
diff --git a/src/config/client.ts b/src/config/client.ts
index 78b359e..bdea11e 100644
--- a/src/config/client.ts
+++ b/src/config/client.ts
@@ -1,4 +1,4 @@
-import { config } from "."
+import { config } from "./server"
// Remove the endpoints from the client config
export const clientConfig = {
diff --git a/src/config/index.ts b/src/config/index.ts
index 50c4645..4d1b581 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -1,4 +1,4 @@
-import { parseConfigFile, resolveConfigFile, resolveEnvVars} from './utils'
+import { parseConfigFile, resolveConfigFile, resolveEnvVars } from './utils'
import { Config, ConfigSchema } from './types'
const defaultConfigFiles = [
@@ -7,16 +7,34 @@ const defaultConfigFiles = [
'config.yml',
]
-const configPath = process.env.APP_CONFIG || await resolveConfigFile(defaultConfigFiles, process.cwd())
+/* eslint-disable @typescript-eslint/no-explicit-any */
+const memoize = Promise>(fn: T) => {
+ const cache = new Map>()
+ return async function(...args: Parameters): Promise> {
+ const key = JSON.stringify(args)
+ if (cache.has(key)) {
+ return cache.get(key) as ReturnType
+ }
+ const result = await fn(...args)
+ cache.set(key, result)
+ return result
+ }
+}
-console.log(`Using config file: ${configPath}`)
+export const getConfig = memoize(async function() {
+ const configPath = process.env.APP_CONFIG || await resolveConfigFile(defaultConfigFiles, process.cwd())
-const parsedConfig = await parseConfigFile(configPath)
-const resolvedConfig = resolveEnvVars(parsedConfig)
+ console.log(`Using config file: ${configPath}`)
-const validatedConfig = ConfigSchema.safeParse(resolvedConfig)
-if (!validatedConfig.success) {
- throw new Error('Invalid config file: ' + validatedConfig.error.errors)
-}
+ const parsedConfig = await parseConfigFile(configPath)
+ const resolvedConfig = resolveEnvVars(parsedConfig)
+
+ const validatedConfig = ConfigSchema.safeParse(resolvedConfig)
+ if (!validatedConfig.success) {
+ throw new Error('Invalid config file: ' + validatedConfig.error.errors)
+ }
+
+ const config: Config = validatedConfig.data
-export const config: Config = validatedConfig.data
+ return config
+})
diff --git a/src/config/server.ts b/src/config/server.ts
new file mode 100644
index 0000000..5e9bdbb
--- /dev/null
+++ b/src/config/server.ts
@@ -0,0 +1,3 @@
+import { getConfig } from "."
+
+export const config = await getConfig()
diff --git a/src/contexts/config.tsx b/src/contexts/config.tsx
index 05fb3ba..3cc9112 100644
--- a/src/contexts/config.tsx
+++ b/src/contexts/config.tsx
@@ -1,6 +1,6 @@
'use client'
-import React, { createContext, useContext, ReactNode } from 'react'
+import React, { createContext, useContext, ReactNode, useState, useEffect } from 'react'
import { Config } from '@/config/types'
interface ConfigContextProps {
@@ -9,7 +9,36 @@ interface ConfigContextProps {
const ConfigContext = createContext(undefined)
-export const ConfigProvider = ({ children, config }: { children: ReactNode, config: Config }) => {
+export const ConfigProvider = ({ children }: { children: ReactNode }) => {
+ const [config, setConfig] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ const fetchConfig = async () => {
+ try {
+ const response = await fetch('/api/config')
+ if (!response.ok) {
+ throw new Error('Network response was not ok')
+ }
+ const data = await response.json()
+ setConfig(data)
+ } catch (error) {
+ console.error('Failed to fetch config:', error)
+ setError("Failed to fetch config")
+ }
+ }
+
+ fetchConfig()
+ }, [])
+
+ if (error !== null) {
+ return {error}
+ }
+
+ if (config === null) {
+ return Loading...
+ }
+
return (
{children}