diff --git a/.version b/.version index 94f15e9c..0e83a9a9 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.13.1 +2.13.2 diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index fc249ab4..00000000 --- a/Jenkinsfile +++ /dev/null @@ -1,285 +0,0 @@ -import groovy.transform.Field - -@Field -def shOutput = "" -def buildxPushTags = "" - -pipeline { - agent { - label 'docker-multiarch' - } - options { - buildDiscarder(logRotator(numToKeepStr: '5')) - disableConcurrentBuilds() - ansiColor('xterm') - } - environment { - IMAGE = 'nginx-proxy-manager' - BUILD_VERSION = getVersion() - MAJOR_VERSION = '2' - BRANCH_LOWER = "${BRANCH_NAME.toLowerCase().replaceAll('\\\\', '-').replaceAll('/', '-').replaceAll('\\.', '-')}" - BUILDX_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}" - COMPOSE_INTERACTIVE_NO_CLI = 1 - } - stages { - stage('Environment') { - parallel { - stage('Master') { - when { - branch 'master' - } - steps { - script { - buildxPushTags = "-t docker.io/jc21/${IMAGE}:${BUILD_VERSION} -t docker.io/jc21/${IMAGE}:${MAJOR_VERSION} -t docker.io/jc21/${IMAGE}:latest" - } - } - } - stage('Other') { - when { - not { - branch 'master' - } - } - steps { - script { - // Defaults to the Branch name, which is applies to all branches AND pr's - buildxPushTags = "-t docker.io/nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}" - } - } - } - stage('Versions') { - steps { - sh 'cat frontend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge frontend/package.json' - sh 'echo -e "\\E[1;36mFrontend Version is:\\E[1;33m $(cat frontend/package.json | jq -r .version)\\E[0m"' - sh 'cat backend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge backend/package.json' - sh 'echo -e "\\E[1;36mBackend Version is:\\E[1;33m $(cat backend/package.json | jq -r .version)\\E[0m"' - sh 'sed -i -E "s/(version-)[0-9]+\\.[0-9]+\\.[0-9]+(-green)/\\1${BUILD_VERSION}\\2/" README.md' - } - } - stage('Docker Login') { - steps { - withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) { - sh 'docker login -u "${duser}" -p "${dpass}"' - } - } - } - } - } - stage('Builds') { - parallel { - stage('Project') { - steps { - script { - // Frontend and Backend - def shStatusCode = sh(label: 'Checking and Building', returnStatus: true, script: ''' - set -e - ./scripts/ci/frontend-build > ${WORKSPACE}/tmp-sh-build 2>&1 - ./scripts/ci/test-and-build > ${WORKSPACE}/tmp-sh-build 2>&1 - ''') - shOutput = readFile "${env.WORKSPACE}/tmp-sh-build" - if (shStatusCode != 0) { - error "${shOutput}" - } - } - } - post { - always { - sh 'rm -f ${WORKSPACE}/tmp-sh-build' - } - failure { - npmGithubPrComment("CI Error:\n\n```\n${shOutput}\n```", true) - } - } - } - stage('Docs') { - steps { - dir(path: 'docs') { - sh 'yarn install' - sh 'yarn build' - } - } - } - } - } - stage('Test Sqlite') { - environment { - COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_sqlite" - COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.sqlite.yml' - } - when { - not { - equals expected: 'UNSTABLE', actual: currentBuild.result - } - } - steps { - sh 'rm -rf ./test/results/junit/*' - sh './scripts/ci/fulltest-cypress' - } - post { - always { - // Dumps to analyze later - sh 'mkdir -p debug/sqlite' - sh 'docker logs $(docker compose ps --all -q fullstack) > debug/sqlite/docker_fullstack.log 2>&1' - sh 'docker logs $(docker compose ps --all -q stepca) > debug/sqlite/docker_stepca.log 2>&1' - sh 'docker logs $(docker compose ps --all -q pdns) > debug/sqlite/docker_pdns.log 2>&1' - sh 'docker logs $(docker compose ps --all -q pdns-db) > debug/sqlite/docker_pdns-db.log 2>&1' - sh 'docker logs $(docker compose ps --all -q dnsrouter) > debug/sqlite/docker_dnsrouter.log 2>&1' - junit 'test/results/junit/*' - sh 'docker compose down --remove-orphans --volumes -t 30 || true' - } - unstable { - dir(path: 'test/results') { - archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') - } - } - } - } - stage('Test Mysql') { - environment { - COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_mysql" - COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.mysql.yml' - } - when { - not { - equals expected: 'UNSTABLE', actual: currentBuild.result - } - } - steps { - sh 'rm -rf ./test/results/junit/*' - sh './scripts/ci/fulltest-cypress' - } - post { - always { - // Dumps to analyze later - sh 'mkdir -p debug/mysql' - sh 'docker logs $(docker compose ps --all -q fullstack) > debug/mysql/docker_fullstack.log 2>&1' - sh 'docker logs $(docker compose ps --all -q stepca) > debug/mysql/docker_stepca.log 2>&1' - sh 'docker logs $(docker compose ps --all -q pdns) > debug/mysql/docker_pdns.log 2>&1' - sh 'docker logs $(docker compose ps --all -q pdns-db) > debug/mysql/docker_pdns-db.log 2>&1' - sh 'docker logs $(docker compose ps --all -q dnsrouter) > debug/mysql/docker_dnsrouter.log 2>&1' - junit 'test/results/junit/*' - sh 'docker compose down --remove-orphans --volumes -t 30 || true' - } - unstable { - dir(path: 'test/results') { - archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') - } - } - } - } - stage('Test Postgres') { - environment { - COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_postgres" - COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.postgres.yml' - } - when { - not { - equals expected: 'UNSTABLE', actual: currentBuild.result - } - } - steps { - sh 'rm -rf ./test/results/junit/*' - sh './scripts/ci/fulltest-cypress' - } - post { - always { - // Dumps to analyze later - sh 'mkdir -p debug/postgres' - sh 'docker logs $(docker compose ps --all -q fullstack) > debug/postgres/docker_fullstack.log 2>&1' - sh 'docker logs $(docker compose ps --all -q stepca) > debug/postgres/docker_stepca.log 2>&1' - sh 'docker logs $(docker compose ps --all -q pdns) > debug/postgres/docker_pdns.log 2>&1' - sh 'docker logs $(docker compose ps --all -q pdns-db) > debug/postgres/docker_pdns-db.log 2>&1' - sh 'docker logs $(docker compose ps --all -q dnsrouter) > debug/postgres/docker_dnsrouter.log 2>&1' - sh 'docker logs $(docker compose ps --all -q db-postgres) > debug/postgres/docker_db-postgres.log 2>&1' - sh 'docker logs $(docker compose ps --all -q authentik) > debug/postgres/docker_authentik.log 2>&1' - sh 'docker logs $(docker compose ps --all -q authentik-redis) > debug/postgres/docker_authentik-redis.log 2>&1' - sh 'docker logs $(docke rcompose ps --all -q authentik-ldap) > debug/postgres/docker_authentik-ldap.log 2>&1' - - junit 'test/results/junit/*' - sh 'docker compose down --remove-orphans --volumes -t 30 || true' - } - unstable { - dir(path: 'test/results') { - archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml') - } - } - } - } - stage('MultiArch Build') { - when { - not { - equals expected: 'UNSTABLE', actual: currentBuild.result - } - } - steps { - sh "./scripts/buildx --push ${buildxPushTags}" - } - } - stage('Docs / Comment') { - parallel { - stage('Docs Job') { - when { - allOf { - branch pattern: "^(develop|master)\$", comparator: "REGEXP" - not { - equals expected: 'UNSTABLE', actual: currentBuild.result - } - } - } - steps { - build wait: false, job: 'nginx-proxy-manager-docs', parameters: [string(name: 'docs_branch', value: "$BRANCH_NAME")] - } - } - stage('PR Comment') { - when { - allOf { - changeRequest() - not { - equals expected: 'UNSTABLE', actual: currentBuild.result - } - } - } - steps { - script { - npmGithubPrComment("""Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/nginxproxymanager/${IMAGE}-dev): -``` -nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER} -``` - -> [!NOTE] -> Ensure you backup your NPM instance before testing this image! Especially if there are database changes. -> This is a different docker image namespace than the official image. - -> [!WARNING] -> Changes and additions to DNS Providers require verification by at least 2 members of the community! -""", true) - } - } - } - } - } - } - post { - always { - sh 'echo Reverting ownership' - sh 'docker run --rm -v "$(pwd):/data" jc21/ci-tools chown -R "$(id -u):$(id -g)" /data' - printResult(true) - } - failure { - archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true) - } - unstable { - archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true) - } - } -} - -def getVersion() { - ver = sh(script: 'cat .version', returnStdout: true) - return ver.trim() -} - -def getCommit() { - ver = sh(script: 'git log -n 1 --format=%h', returnStdout: true) - return ver.trim() -} diff --git a/README.md b/README.md index 669be713..683c9681 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@



- + diff --git a/backend/db.js b/backend/db.js index bd74e518..bf540f8a 100644 --- a/backend/db.js +++ b/backend/db.js @@ -1,6 +1,8 @@ import knex from "knex"; import {configGet, configHas} from "./lib/config.js"; +let instance = null; + const generateDbConfig = () => { if (!configHas("database")) { throw new Error( @@ -30,4 +32,11 @@ const generateDbConfig = () => { }; }; -export default knex(generateDbConfig()); +const getInstance = () => { + if (!instance) { + instance = knex(generateDbConfig()); + } + return instance; +} + +export default getInstance; diff --git a/backend/migrate.js b/backend/migrate.js index dd3f1b61..4c99cab6 100644 --- a/backend/migrate.js +++ b/backend/migrate.js @@ -2,9 +2,9 @@ import db from "./db.js"; import { migrate as logger } from "./logger.js"; const migrateUp = async () => { - const version = await db.migrate.currentVersion(); + const version = await db().migrate.currentVersion(); logger.info("Current database version:", version); - return await db.migrate.latest({ + return await db().migrate.latest({ tableName: "migrations", directory: "migrations", }); diff --git a/backend/models/access_list.js b/backend/models/access_list.js index 98016a17..427d447d 100644 --- a/backend/models/access_list.js +++ b/backend/models/access_list.js @@ -10,7 +10,7 @@ import now from "./now_helper.js"; import ProxyHostModel from "./proxy_host.js"; import User from "./user.js"; -Model.knex(db); +Model.knex(db()); const boolFields = ["is_deleted", "satisfy_any", "pass_auth"]; diff --git a/backend/models/access_list_auth.js b/backend/models/access_list_auth.js index a4fd85a5..75bf4352 100644 --- a/backend/models/access_list_auth.js +++ b/backend/models/access_list_auth.js @@ -6,7 +6,7 @@ import db from "../db.js"; import accessListModel from "./access_list.js"; import now from "./now_helper.js"; -Model.knex(db); +Model.knex(db()); class AccessListAuth extends Model { $beforeInsert() { diff --git a/backend/models/access_list_client.js b/backend/models/access_list_client.js index 4b63aec9..91165fe1 100644 --- a/backend/models/access_list_client.js +++ b/backend/models/access_list_client.js @@ -6,7 +6,7 @@ import db from "../db.js"; import accessListModel from "./access_list.js"; import now from "./now_helper.js"; -Model.knex(db); +Model.knex(db()); class AccessListClient extends Model { $beforeInsert() { diff --git a/backend/models/audit-log.js b/backend/models/audit-log.js index a9b2d563..6e2d398f 100644 --- a/backend/models/audit-log.js +++ b/backend/models/audit-log.js @@ -6,7 +6,7 @@ import db from "../db.js"; import now from "./now_helper.js"; import User from "./user.js"; -Model.knex(db); +Model.knex(db()); class AuditLog extends Model { $beforeInsert() { diff --git a/backend/models/auth.js b/backend/models/auth.js index 4ba50b41..e8af582d 100644 --- a/backend/models/auth.js +++ b/backend/models/auth.js @@ -8,7 +8,7 @@ import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.j import now from "./now_helper.js"; import User from "./user.js"; -Model.knex(db); +Model.knex(db()); const boolFields = ["is_deleted"]; diff --git a/backend/models/certificate.js b/backend/models/certificate.js index 9ad03c89..ad6e0a65 100644 --- a/backend/models/certificate.js +++ b/backend/models/certificate.js @@ -11,7 +11,7 @@ import redirectionHostModel from "./redirection_host.js"; import streamModel from "./stream.js"; import userModel from "./user.js"; -Model.knex(db); +Model.knex(db()); const boolFields = ["is_deleted"]; diff --git a/backend/models/dead_host.js b/backend/models/dead_host.js index 56807012..0acf7ca7 100644 --- a/backend/models/dead_host.js +++ b/backend/models/dead_host.js @@ -8,7 +8,7 @@ import Certificate from "./certificate.js"; import now from "./now_helper.js"; import User from "./user.js"; -Model.knex(db); +Model.knex(db()); const boolFields = ["is_deleted", "ssl_forced", "http2_support", "enabled", "hsts_enabled", "hsts_subdomains"]; diff --git a/backend/models/now_helper.js b/backend/models/now_helper.js index 4dc71cea..293dcc72 100644 --- a/backend/models/now_helper.js +++ b/backend/models/now_helper.js @@ -2,7 +2,7 @@ import { Model } from "objection"; import db from "../db.js"; import { isSqlite } from "../lib/config.js"; -Model.knex(db); +Model.knex(db()); export default () => { if (isSqlite()) { diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index 119fe2b7..b6ce6361 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -9,7 +9,7 @@ import Certificate from "./certificate.js"; import now from "./now_helper.js"; import User from "./user.js"; -Model.knex(db); +Model.knex(db()); const boolFields = [ "is_deleted", diff --git a/backend/models/redirection_host.js b/backend/models/redirection_host.js index bb397baa..46c73017 100644 --- a/backend/models/redirection_host.js +++ b/backend/models/redirection_host.js @@ -8,7 +8,7 @@ import Certificate from "./certificate.js"; import now from "./now_helper.js"; import User from "./user.js"; -Model.knex(db); +Model.knex(db()); const boolFields = [ "is_deleted", diff --git a/backend/models/setting.js b/backend/models/setting.js index 0e0d6f4f..56f7dc5a 100644 --- a/backend/models/setting.js +++ b/backend/models/setting.js @@ -4,7 +4,7 @@ import { Model } from "objection"; import db from "../db.js"; -Model.knex(db); +Model.knex(db()); class Setting extends Model { $beforeInsert () { diff --git a/backend/models/stream.js b/backend/models/stream.js index 92d335ff..5f61945a 100644 --- a/backend/models/stream.js +++ b/backend/models/stream.js @@ -5,7 +5,7 @@ import Certificate from "./certificate.js"; import now from "./now_helper.js"; import User from "./user.js"; -Model.knex(db); +Model.knex(db()); const boolFields = ["is_deleted", "enabled", "tcp_forwarding", "udp_forwarding"]; diff --git a/backend/models/user.js b/backend/models/user.js index 64aed05d..68a31446 100644 --- a/backend/models/user.js +++ b/backend/models/user.js @@ -7,7 +7,7 @@ import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.j import now from "./now_helper.js"; import UserPermission from "./user_permission.js"; -Model.knex(db); +Model.knex(db()); const boolFields = ["is_deleted", "is_disabled"]; diff --git a/backend/models/user_permission.js b/backend/models/user_permission.js index 49ea2d90..d8784717 100644 --- a/backend/models/user_permission.js +++ b/backend/models/user_permission.js @@ -5,7 +5,7 @@ import { Model } from "objection"; import db from "../db.js"; import now from "./now_helper.js"; -Model.knex(db); +Model.knex(db()); class UserPermission extends Model { $beforeInsert () { diff --git a/docker/Dockerfile b/docker/Dockerfile index 913f79d5..88ce95ed 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,7 +4,6 @@ # This file assumes that the frontend has been built using ./scripts/frontend-build FROM nginxproxymanager/testca AS testca -FROM letsencrypt/pebble AS pebbleca FROM nginxproxymanager/nginx-full:certbot-node ARG TARGETPLATFORM @@ -46,7 +45,6 @@ RUN yarn install \ # add late to limit cache-busting by modifications COPY docker/rootfs / -COPY --from=pebbleca /test/certs/pebble.minica.pem /etc/ssl/certs/pebble.minica.pem COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt # Remove frontend service not required for prod, dev nginx config as well diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile index 45d97a32..f0f5ec60 100644 --- a/docker/dev/Dockerfile +++ b/docker/dev/Dockerfile @@ -1,5 +1,4 @@ FROM nginxproxymanager/testca AS testca -FROM letsencrypt/pebble AS pebbleca FROM nginxproxymanager/nginx-full:certbot-node LABEL maintainer="Jamie Curnow " @@ -33,7 +32,6 @@ RUN rm -f /etc/nginx/conf.d/production.conf \ && chmod 644 -R /root/.cache # Certs for testing purposes -COPY --from=pebbleca /test/certs/pebble.minica.pem /etc/ssl/certs/pebble.minica.pem COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt EXPOSE 80 81 443 diff --git a/docker/dev/pebble-config.json b/docker/dev/pebble-config.json deleted file mode 100644 index ea937905..00000000 --- a/docker/dev/pebble-config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "pebble": { - "listenAddress": "0.0.0.0:443", - "managementListenAddress": "0.0.0.0:15000", - "certificate": "test/certs/localhost/cert.pem", - "privateKey": "test/certs/localhost/key.pem", - "httpPort": 80, - "tlsPort": 443, - "ocspResponderURL": "", - "externalAccountBindingRequired": false - } -} diff --git a/frontend/src/components/Form/AccessField.tsx b/frontend/src/components/Form/AccessField.tsx index c72ef588..16059ac3 100644 --- a/frontend/src/components/Form/AccessField.tsx +++ b/frontend/src/components/Form/AccessField.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import Select, { type ActionMeta, components, type OptionProps } from "react-select"; import type { AccessList } from "src/api/backend"; import { useAccessLists } from "src/hooks"; -import { DateTimeFormat, intl, T } from "src/locale"; +import { formatDateTime, intl, T } from "src/locale"; interface AccessOption { readonly value: number; @@ -48,7 +48,7 @@ export function AccessField({ name = "accessListId", label = "access-list", id = { users: item?.items?.length, rules: item?.clients?.length, - date: item?.createdOn ? DateTimeFormat(item?.createdOn) : "N/A", + date: item?.createdOn ? formatDateTime(item?.createdOn) : "N/A", }, ), icon: , diff --git a/frontend/src/components/Form/SSLCertificateField.tsx b/frontend/src/components/Form/SSLCertificateField.tsx index c4767509..0e7ce337 100644 --- a/frontend/src/components/Form/SSLCertificateField.tsx +++ b/frontend/src/components/Form/SSLCertificateField.tsx @@ -3,7 +3,7 @@ import { Field, useFormikContext } from "formik"; import Select, { type ActionMeta, components, type OptionProps } from "react-select"; import type { Certificate } from "src/api/backend"; import { useCertificates } from "src/hooks"; -import { DateTimeFormat, intl, T } from "src/locale"; +import { formatDateTime, intl, T } from "src/locale"; interface CertOption { readonly value: number | "new"; @@ -75,7 +75,7 @@ export function SSLCertificateField({ data?.map((cert: Certificate) => ({ value: cert.id, label: cert.niceName, - subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? DateTimeFormat(cert.expiresOn) : "N/A" })}`, + subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} — ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`, icon: , })) || []; diff --git a/frontend/src/components/Table/Formatter/DateFormatter.tsx b/frontend/src/components/Table/Formatter/DateFormatter.tsx index e8c2c6c7..acb96461 100644 --- a/frontend/src/components/Table/Formatter/DateFormatter.tsx +++ b/frontend/src/components/Table/Formatter/DateFormatter.tsx @@ -1,6 +1,6 @@ import cn from "classnames"; -import { differenceInDays, isPast, parseISO } from "date-fns"; -import { DateTimeFormat } from "src/locale"; +import { differenceInDays, isPast } from "date-fns"; +import { formatDateTime, parseDate } from "src/locale"; interface Props { value: string; @@ -8,11 +8,12 @@ interface Props { highlistNearlyExpired?: boolean; } export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: Props) { - const dateIsPast = isPast(parseISO(value)); - const days = differenceInDays(parseISO(value), new Date()); + const d = parseDate(value); + const dateIsPast = d ? isPast(d) : false; + const days = d ? differenceInDays(d, new Date()) : 0; const cl = cn({ "text-danger": highlightPast && dateIsPast, "text-warning": highlistNearlyExpired && !dateIsPast && days <= 30 && days >= 0, }); - return {DateTimeFormat(value)}; + return {formatDateTime(value)}; } diff --git a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx b/frontend/src/components/Table/Formatter/DomainsFormatter.tsx index 8560eae2..4cc50475 100644 --- a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx +++ b/frontend/src/components/Table/Formatter/DomainsFormatter.tsx @@ -1,6 +1,6 @@ import cn from "classnames"; import type { ReactNode } from "react"; -import { DateTimeFormat, T } from "src/locale"; +import { formatDateTime, T } from "src/locale"; interface Props { domains: string[]; @@ -53,7 +53,7 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color

{...elms}
{createdOn ? (
- +
) : null} diff --git a/frontend/src/components/Table/Formatter/EventFormatter.tsx b/frontend/src/components/Table/Formatter/EventFormatter.tsx index f6f7787d..ff37ecbe 100644 --- a/frontend/src/components/Table/Formatter/EventFormatter.tsx +++ b/frontend/src/components/Table/Formatter/EventFormatter.tsx @@ -1,7 +1,7 @@ import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react"; import cn from "classnames"; import type { AuditLog } from "src/api/backend"; -import { DateTimeFormat, T } from "src/locale"; +import { formatDateTime, T } from "src/locale"; const getEventValue = (event: AuditLog) => { switch (event.objectType) { @@ -73,7 +73,7 @@ export function EventFormatter({ row }: Props) {   — {getEventValue(row)} -
{DateTimeFormat(row.createdOn)}
+
{formatDateTime(row.createdOn)}
); } diff --git a/frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx b/frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx index e4e7fb27..38352b31 100644 --- a/frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx +++ b/frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx @@ -1,4 +1,4 @@ -import { DateTimeFormat, T } from "src/locale"; +import { formatDateTime, T } from "src/locale"; interface Props { value: string; @@ -13,7 +13,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) { {createdOn ? (
- +
) : null} diff --git a/frontend/src/locale/DateTimeFormat.ts b/frontend/src/locale/DateTimeFormat.ts deleted file mode 100644 index fb8e66c8..00000000 --- a/frontend/src/locale/DateTimeFormat.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { intlFormat, parseISO } from "date-fns"; - -const DateTimeFormat = (isoDate: string) => - intlFormat(parseISO(isoDate), { - weekday: "long", - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: true, - }); - -export { DateTimeFormat }; diff --git a/frontend/src/locale/IntlProvider.tsx b/frontend/src/locale/IntlProvider.tsx index 46467d01..c2779908 100644 --- a/frontend/src/locale/IntlProvider.tsx +++ b/frontend/src/locale/IntlProvider.tsx @@ -30,13 +30,20 @@ const getLocale = (short = false) => { if (short) { return loc.slice(0, 2); } + // finally, fallback + if (!loc) { + loc = "en"; + } return loc; }; const cache = createIntlCache(); const initialMessages = loadMessages(getLocale()); -let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache); +let intl = createIntl( + { locale: getLocale(), messages: initialMessages }, + cache, +); const changeLocale = (locale: string): void => { const messages = loadMessages(locale); @@ -76,4 +83,12 @@ const T = ({ ); }; -export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T }; +export { + localeOptions, + getFlagCodeForLocale, + getLocale, + createIntl, + changeLocale, + intl, + T, +}; diff --git a/frontend/src/locale/Utils.test.tsx b/frontend/src/locale/Utils.test.tsx new file mode 100644 index 00000000..fb262501 --- /dev/null +++ b/frontend/src/locale/Utils.test.tsx @@ -0,0 +1,74 @@ +import { formatDateTime } from "src/locale"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +describe("DateFormatter", () => { + // Keep a reference to the real Intl to restore later + const RealIntl = global.Intl; + const desiredTimeZone = "Europe/London"; + const desiredLocale = "en-GB"; + + beforeAll(() => { + // Ensure Node-based libs using TZ behave deterministically + try { + process.env.TZ = desiredTimeZone; + } catch { + // ignore if not available + } + + // Mock Intl.DateTimeFormat so formatting is stable regardless of host + const MockedDateTimeFormat = class extends RealIntl.DateTimeFormat { + constructor(_locales?: string | string[], options?: Intl.DateTimeFormatOptions) { + super(desiredLocale, { + ...options, + timeZone: desiredTimeZone, + }); + } + } as unknown as typeof Intl.DateTimeFormat; + + global.Intl = { + ...RealIntl, + DateTimeFormat: MockedDateTimeFormat, + }; + }); + + afterAll(() => { + // Restore original Intl after tests + global.Intl = RealIntl; + }); + + it("format date from iso date", () => { + const value = "2024-01-01T00:00:00.000Z"; + const text = formatDateTime(value); + expect(text).toBe("Monday, 01/01/2024, 12:00:00 am"); + }); + + it("format date from unix timestamp number", () => { + const value = 1762476112; + const text = formatDateTime(value); + expect(text).toBe("Friday, 07/11/2025, 12:41:52 am"); + }); + + it("format date from unix timestamp string", () => { + const value = "1762476112"; + const text = formatDateTime(value); + expect(text).toBe("Friday, 07/11/2025, 12:41:52 am"); + }); + + it("catch bad format from string", () => { + const value = "this is not a good date"; + const text = formatDateTime(value); + expect(text).toBe("this is not a good date"); + }); + + it("catch bad format from number", () => { + const value = -100; + const text = formatDateTime(value); + expect(text).toBe("-100"); + }); + + it("catch bad format from number as string", () => { + const value = "-100"; + const text = formatDateTime(value); + expect(text).toBe("-100"); + }); +}); diff --git a/frontend/src/locale/Utils.ts b/frontend/src/locale/Utils.ts new file mode 100644 index 00000000..018efd0b --- /dev/null +++ b/frontend/src/locale/Utils.ts @@ -0,0 +1,42 @@ +import { fromUnixTime, intlFormat, parseISO } from "date-fns"; + +const isUnixTimestamp = (value: unknown): boolean => { + if (typeof value !== "number" && typeof value !== "string") return false; + const num = Number(value); + if (!Number.isFinite(num)) return false; + // Check plausible Unix timestamp range: from 1970 to ~year 3000 + // Support both seconds and milliseconds + if (num > 0 && num < 10000000000) return true; // seconds (<= 10 digits) + if (num >= 10000000000 && num < 32503680000000) return true; // milliseconds (<= 13 digits) + return false; +}; + +const parseDate = (value: string | number): Date | null => { + if (typeof value !== "number" && typeof value !== "string") return null; + try { + return isUnixTimestamp(value) ? fromUnixTime(+value) : parseISO(`${value}`); + } catch { + return null; + } +}; + +const formatDateTime = (value: string | number): string => { + const d = parseDate(value); + if (!d) return `${value}`; + try { + return intlFormat(d, { + weekday: "long", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: true, + }); + } catch { + return `${value}`; + } +}; + +export { formatDateTime, parseDate, isUnixTimestamp }; diff --git a/frontend/src/locale/index.ts b/frontend/src/locale/index.ts index 6d9ac03c..bdd1343e 100644 --- a/frontend/src/locale/index.ts +++ b/frontend/src/locale/index.ts @@ -1,2 +1,2 @@ -export * from "./DateTimeFormat"; export * from "./IntlProvider"; +export * from "./Utils"; diff --git a/scripts/ci/frontend-build b/scripts/ci/frontend-build index 64e26e50..1cfc5116 100755 --- a/scripts/ci/frontend-build +++ b/scripts/ci/frontend-build @@ -16,7 +16,7 @@ if hash docker 2>/dev/null; then -e NODE_OPTIONS=--openssl-legacy-provider \ -v "$(pwd)/frontend:/app/frontend" \ -w /app/frontend "${DOCKER_IMAGE}" \ - sh -c "yarn install && yarn lint && yarn build && chown -R $(id -u):$(id -g) /app/frontend" + sh -c "yarn install && yarn lint && yarn vitest run && yarn build && chown -R $(id -u):$(id -g) /app/frontend" echo -e "${BLUE}❯ ${GREEN}Building Frontend Complete${RESET}" else