diff --git a/.github/actions/build-docker-image/action.yaml b/.github/actions/build-docker-image/action.yaml index 8b55d29b..3424255b 100644 --- a/.github/actions/build-docker-image/action.yaml +++ b/.github/actions/build-docker-image/action.yaml @@ -1,5 +1,8 @@ -name: Build docker Image -description: Build and tag specific Docker images +name: Build docker image +description: Build, tag and optionally push a Docker image to GHCR + +# The caller is responsible for checking out the repository first and for +# deciding which tags apply - this action does not inspect the triggering event. inputs: platforms: @@ -12,36 +15,24 @@ inputs: required: false image_tags: default: "" - description: "The tags to apply to the image" + description: "Semver version to tag the image with (e.g. 1.2.3). Skipped when empty." + required: false + extra_tag: + default: "" + description: "An extra raw tag to apply to the image (e.g. edge, canary, pr-42)" required: false push: default: "false" - description: "Push the image to the registries" + description: "Push the image to the registry" required: false token: description: "Github token" required: true - tag_suffix: - default: "" - description: "Suffix to append to the image tags" - required: false - extra_tag: - default: "" - description: "An extra raw tag to apply to the image (e.g. edge, canary)" - required: false runs: using: composite steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -61,38 +52,17 @@ runs: images: ghcr.io/${{ github.repository }} tags: | type=sha - type=raw,value=canary,enable=${{ github.event_name == 'workflow_dispatch' }} - type=raw,event=workflow_dispatch,value=${{ github.event.inputs.dispatch-tag }} - type=semver,pattern={{version}},value=${{ inputs.image_tags }},branch=develop + type=semver,pattern={{version}},value=${{ inputs.image_tags }} type=raw,value=${{ inputs.extra_tag }},enable=${{ inputs.extra_tag != '' }} - - name: Clean variables - id: clean - shell: bash - run: | - # Replace illegal characters in tag suffix - echo suffix=$(echo "${{ inputs.tag_suffix }}" | sed -e 's/[^a-zA-Z0-9._-]/_/g') >> $GITHUB_OUTPUT - - - name: Add suffix to image tags - id: tag_suffix - shell: bash - run: | - delimiter="$(openssl rand -hex 8)" - echo "tags<<${delimiter}" >> $GITHUB_OUTPUT - # if tag_suffix is set, append it to each tag - if [[ -n "${{ inputs.tag_suffix }}" ]]; then - echo "${{ steps.meta.outputs.tags }}" | sed -e "s/$/-${{ steps.clean.outputs.suffix }}/" >> $GITHUB_OUTPUT - else - echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_OUTPUT - fi - echo "${delimiter}" >> $GITHUB_OUTPUT - - - name: Build image for push + - name: Build image uses: docker/build-push-action@v7.2.0 with: + context: . file: ${{ inputs.docker_file }} - tags: ${{ steps.tag_suffix.outputs.tags }} + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: ${{ inputs.platforms }} push: ${{ inputs.push == 'true' }} - + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/actions/setup-and-cache/action.yaml b/.github/actions/setup-and-cache/action.yaml index bdbcf07a..4a44c361 100644 --- a/.github/actions/setup-and-cache/action.yaml +++ b/.github/actions/setup-and-cache/action.yaml @@ -1,44 +1,21 @@ name: Setup and cache -description: Setup for node, pnpm and cache for browser testing binaries +description: Setup node and pnpm with a cached pnpm store inputs: node-version: required: false description: Node version for setup-node - default: 22.x + default: 24.x runs: using: composite steps: - name: Install pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + uses: pnpm/action-setup@v6 - name: Set node version to ${{ inputs.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - - - name: Resolve package versions - id: resolve-package-versions - shell: bash - run: > - echo "$( - node -e " - const fs = require('fs'); - const lockfile = fs.readFileSync('./pnpm-lock.yaml', 'utf8'); - const pattern = (name) => new RegExp(name + ':\\\s+specifier: [\\\s\\\w\\\.^]+version: (\\\d+\\\.\\\d+\\\.\\\d+)'); - const nuxtVersion = lockfile.match(pattern('nuxt'))[1]; - console.log('NUXT_VERSION=' + nuxtVersion); - " - )" >> $GITHUB_OUTPUT - - - name: Print versions - shell: bash - run: echo "${{ toJson(steps.resolve-package-versions.outputs) }}" - -# - name: Check resolved package versions -# shell: bash -# if: contains(fromJSON('[null, "", "null"]'), steps.resolve-package-version) -# run: echo "Failed to resolve package versions. See log above." && exit 1 - + cache: pnpm diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2a891e76..a542c242 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,41 +12,12 @@ concurrency: cancel-in-progress: true jobs: - changed: - runs-on: ubuntu-latest - name: 'Check differences' - outputs: - should_skip: ${{ steps.changed-files.outputs.only_changed == 'true' }} - - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@a284dc1814e3fd07f2e34267fc8f81227ed29fb8 # v45.0.9 - with: - files: | - docs/** - .github/** - !.github/workflows/ci.yml - **.md - run-tests: - needs: changed name: 'Build & Test' - # if: needs.changed.outputs.should_skip != 'true' - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest timeout-minutes: 30 - strategy: - matrix: - os: [ ubuntu-latest ] - node_version: [ 24 ] - fail-fast: false - steps: - name: Checkout uses: actions/checkout@v6 @@ -61,12 +32,12 @@ jobs: - uses: ./.github/actions/setup-and-cache with: - node-version: ${{ matrix.node_version }} + node-version: 24 - - uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1.7.3 + - uses: browser-actions/setup-chrome@v2 - - name: Install pnpm - run: pnpm i + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build @@ -74,18 +45,26 @@ jobs: - name: Test run: pnpm run test:ci - test-image-compilation: - name: "Test production image compilation" + preview-image: + name: 'Build preview image' + if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout # required for finding action uses: actions/checkout@v6 with: persist-credentials: false - - uses: ./.github/actions/build-docker-image + # Pull requests from forks get a read-only token, so they can only verify + # that the image still compiles - they cannot publish a preview image. + - name: Build preview image + uses: ./.github/actions/build-docker-image with: platforms: linux/amd64 docker_file: prod.Dockerfile - push: 'false' + push: ${{ github.event.pull_request.head.repo.full_name == github.repository }} token: ${{ secrets.GITHUB_TOKEN }} + extra_tag: pr-${{ github.event.number }} diff --git a/.github/workflows/docker-preview.yaml b/.github/workflows/docker-preview.yaml deleted file mode 100644 index 60db4ca7..00000000 --- a/.github/workflows/docker-preview.yaml +++ /dev/null @@ -1,59 +0,0 @@ -name: Docker Preview Images - -on: - push: - branches-ignore: - - develop - pull_request: - branches: - - develop - types: [opened, synchronize, reopened] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-edge-image: - name: Build and push edge Docker image - if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Build edge image - uses: ./.github/actions/build-docker-image - with: - platforms: linux/amd64 - docker_file: prod.Dockerfile - push: 'true' - token: ${{ secrets.GITHUB_TOKEN }} - extra_tag: edge - - build-canary-image: - name: Build and push canary Docker image - if: ${{ github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Build canary image - uses: ./.github/actions/build-docker-image - with: - platforms: linux/amd64 - docker_file: prod.Dockerfile - push: 'true' - token: ${{ secrets.GITHUB_TOKEN }} - extra_tag: canary diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5c677e4b..84168c64 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,6 +13,7 @@ concurrency: jobs: release-please: name: Release + if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest outputs: released: ${{ steps.release.outputs.release_created }} @@ -33,10 +34,13 @@ jobs: echo "Was a release created: ${{ steps.release.outputs.release_created }}" echo "New version: ${{ steps.release.outputs.version }}" - build-prod-image: - name: Build and push production Docker image - if: ${{ needs.release-please.outputs.released == 'true' }} + build-image: + # A single build per commit: develop HEAD is always tagged `edge` and picks up + # the semver tag as well when release-please cut a release for that same commit. + # Manual runs skip release-please entirely and publish a `canary` tag instead. + name: Build and push Docker image needs: release-please # Wait until pkg bumped + if: ${{ !cancelled() && needs.release-please.result != 'failure' }} runs-on: ubuntu-latest permissions: contents: read @@ -49,9 +53,10 @@ jobs: - name: check-version run: | - echo "Version from release please: ${{ needs.release-please.outputs.version }}" + echo "Release created: ${{ needs.release-please.outputs.released || 'false' }}" + echo "Version from release please: ${{ needs.release-please.outputs.version || '(none)' }}" - - name: Build production image + - name: Build image uses: ./.github/actions/build-docker-image with: platforms: linux/amd64 @@ -59,24 +64,4 @@ jobs: image_tags: ${{ needs.release-please.outputs.version }} push: 'true' token: ${{ secrets.GITHUB_TOKEN }} - - build-canary-image: - name: Build and push canary image - if: ${{ github.event_name == 'workflow_dispatch' }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout # required for finding action - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Build canary image - uses: ./.github/actions/build-docker-image - with: - platforms: linux/amd64 - docker_file: prod.Dockerfile - push: 'true' - token: ${{ secrets.GITHUB_TOKEN }} + extra_tag: ${{ github.event_name == 'workflow_dispatch' && 'canary' || 'edge' }} diff --git a/Dockerfile b/Dockerfile index a98848b6..f718e5bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,18 +9,17 @@ RUN corepack enable WORKDIR /app -# pnpm-workspace.yaml carries patchedDependencies and allowBuilds (pnpm v11+); -# without it, patches are silently skipped and build scripts are not run. +# for patchedDependencies COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./ COPY patches /app/patches -# Remove once corepack bug fixed https://github.com/nodejs/corepack/issues/612#issuecomment-2629613697 -ENV COREPACK_INTEGRITY_KEYS=0 - RUN pnpm install --frozen-lockfile COPY . . +# always from scratch +RUN rm -rf .nuxt + ENV NUXT_HOST=0.0.0.0 ENV NUXT_PORT=3000 diff --git a/app/components/TableRowMetadata.vue b/app/components/TableRowMetadata.vue deleted file mode 100644 index 7cf1983c..00000000 --- a/app/components/TableRowMetadata.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/app/components/analysis/AnalysesTable.vue b/app/components/analysis/AnalysesTable.vue index 216c1c7b..af6741a2 100644 --- a/app/components/analysis/AnalysesTable.vue +++ b/app/components/analysis/AnalysesTable.vue @@ -551,7 +551,7 @@ const onCloseNavToast = () => {
- + @@ -136,7 +141,9 @@ const links = computed(() => .menu-bar-header { border-radius: 0; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.08), + 0 1px 2px rgba(0, 0, 0, 0.06); } .menu-bar-header .menu-bar-item { diff --git a/app/components/table/ExpandRowButtons.vue b/app/components/table/ExpandRowButtons.vue deleted file mode 100644 index a46d3c8c..00000000 --- a/app/components/table/ExpandRowButtons.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - diff --git a/app/components/table/SearchBar.vue b/app/components/table/SearchBar.vue index a925496d..42474a5d 100644 --- a/app/components/table/SearchBar.vue +++ b/app/components/table/SearchBar.vue @@ -2,9 +2,9 @@ import IconField from "primevue/iconfield"; import InputIcon from "primevue/inputicon"; -const props = defineProps({ - searchTerm: [String, undefined], -}); +const props = defineProps<{ + searchTerm?: string; +}>(); const emit = defineEmits(["clearFilters", "updateSearch"]); diff --git a/app/components/uptime/BucketDrilldownDialog.vue b/app/components/uptime/BucketDrilldownDialog.vue new file mode 100644 index 00000000..5dcb9bd3 --- /dev/null +++ b/app/components/uptime/BucketDrilldownDialog.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/app/components/uptime/ServiceUptimeCard.vue b/app/components/uptime/ServiceUptimeCard.vue new file mode 100644 index 00000000..86d1ed43 --- /dev/null +++ b/app/components/uptime/ServiceUptimeCard.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/app/components/uptime/UptimeToolbar.vue b/app/components/uptime/UptimeToolbar.vue new file mode 100644 index 00000000..d3a8a562 --- /dev/null +++ b/app/components/uptime/UptimeToolbar.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/app/components/uptime/UptimeTrack.vue b/app/components/uptime/UptimeTrack.vue new file mode 100644 index 00000000..97700efc --- /dev/null +++ b/app/components/uptime/UptimeTrack.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/app/composables/useAPIFetch.ts b/app/composables/useAPIFetch.ts index d53f6e9a..e0c0aede 100644 --- a/app/composables/useAPIFetch.ts +++ b/app/composables/useAPIFetch.ts @@ -1,14 +1,9 @@ import type { AnalysisNode, - BodyKongProjectLinkKongProjectProjectIdDatastoreDatastoreIdPost, - DetailedAnalysis, - EventLogResponse, - LinkDataStoreProject, ListServices, Project, ProjectNode, - UnlinkResponse, - UserSettings, + ServiceHealthHistory, } from "~/services/Api"; import { useFetch, type UseFetchOptions, useNuxtApp } from "nuxt/app"; @@ -23,33 +18,26 @@ export function useAPIFetch( }); } -// Event endpoints -export function getEvents( +// Health endpoints +export function getServiceHealthHistory( query: { - limit?: number; - offset?: number; start_date?: string; end_date?: string; - service_tag?: string; + service?: string[]; + include_checks?: boolean; + limit?: number; + resolution?: number; } = {}, opts?, ) { - return useAPIFetch("/events", { - ...opts, - method: "GET", - query: { - limit: 50, - ...query, + return useNuxtApp().$hubApi( + "/health/services/history", + { + ...opts, + method: "GET", + query, }, - }); -} - -// Node endpoints -export function getNodeConfiguration(opts?) { - return useAPIFetch("/node/settings", { - ...opts, - method: "GET", - }); + ); } // Hub endpoints @@ -74,16 +62,6 @@ export function getProjects(opts?) { }); } -export function getAnalyses(opts?) { - return useAPIFetch("/analyses", { - ...opts, - method: "GET", - query: { - sort: "-updated_at", - }, - }); -} - export function getAnalysisNodes(opts?) { return useAPIFetch( "/analysis-nodes?include=analysis,node", @@ -113,7 +91,7 @@ export function deleteDataStore( cascade: boolean = false, opts?, ) { - return useAPIFetch(`/kong/datastore/${dataStoreIdOrName}`, { + return useNuxtApp().$hubApi(`/kong/datastore/${dataStoreIdOrName}`, { ...opts, method: "DELETE", query: { @@ -121,43 +99,3 @@ export function deleteDataStore( }, }); } - -export function linkProjectToDataStore( - projectId: string, - datastoreId: string, - linkProps: BodyKongProjectLinkKongProjectProjectIdDatastoreDatastoreIdPost = {}, - opts?, -) { - return useAPIFetch( - `/kong/project/${projectId}/datastore/${datastoreId}`, - { - ...opts, - method: "POST", - body: linkProps, - }, - ); -} - -export function deleteProjectFromKong(projectId: string, opts?) { - return useAPIFetch(`/kong/project/${projectId}`, { - ...opts, - method: "DELETE", - }); -} - -// Results endpoints -export function downloadLocalObject(objectId: string, opts?) { - return useAPIFetch(`/local/${objectId}`, { - ...opts, - method: "GET", - headers: { "Content-Disposition": "application/octet-stream" }, - }); -} - -export function downloadIntermediateObject(objectId: string, opts?) { - return useAPIFetch(`/intermediate/${objectId}`, { - ...opts, - method: "GET", - headers: { "Content-Disposition": "application/octet-stream" }, - }); -} diff --git a/app/composables/useDataStoreList.ts b/app/composables/useDataStoreList.ts index e9573179..75787532 100644 --- a/app/composables/useDataStoreList.ts +++ b/app/composables/useDataStoreList.ts @@ -19,7 +19,7 @@ export function buildProjectNameMap( const DATA_ROW_UNIX_COLS = ["created_at", "updated_at"]; -export async function useDataStoreList() { +export function useDataStoreList() { const dataStores = ref([]); const projectNameMap = ref>(new Map()); const loading = ref(true); @@ -29,9 +29,9 @@ export async function useDataStoreList() { status: dsStatus, error: dsError, refresh, - } = await getDataStores(true, { lazy: true }); + } = getDataStores(true, { lazy: true }); - const { data: projectResp } = await getProjects({ lazy: true }); + const { data: projectResp } = getProjects({ lazy: true }); watchEffect(() => { if (dsStatus.value === "pending") return; diff --git a/app/composables/useServiceHealth.ts b/app/composables/useServiceHealth.ts new file mode 100644 index 00000000..d6b49008 --- /dev/null +++ b/app/composables/useServiceHealth.ts @@ -0,0 +1,94 @@ +import type { UptimeBucket } from "~/utils/uptime-state"; + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +export const MAX_SPAN_MS = 7 * DAY; + +const MAX_CELLS = 200; // too hard to see if higher than this + +const SLICE_WIDTHS = [30, 60, 180, 720, 3600]; + +export interface UptimeRangePreset { + label: string; + spanMs: number; +} + +export const SPAN_PRESETS: UptimeRangePreset[] = [ + { label: "1h", spanMs: 1 * HOUR }, + { label: "6h", spanMs: 6 * HOUR }, + { label: "24h", spanMs: 24 * HOUR }, + { label: "7d", spanMs: 7 * DAY }, +]; + +export interface UptimeSlot { + start: Date; + end: Date; +} + +export function resolutionFor(spanMs: number): number { + const seconds = spanMs / 1000; + const width = SLICE_WIDTHS.find( + (candidate) => seconds / candidate <= MAX_CELLS, + ); + + return width ?? SLICE_WIDTHS[SLICE_WIDTHS.length - 1]!; +} + +export function floorToGrid(date: Date, resolutionSeconds: number): Date { + const width = resolutionSeconds * 1000; + + return new Date(Math.floor(date.getTime() / width) * width); +} + +export function buildSlots( + start: Date, + end: Date, + resolutionSeconds: number, +): UptimeSlot[] { + const width = resolutionSeconds * 1000; + if (width <= 0) return []; // gotta be positive otherwise weird things happen + + const last = end.getTime(); + const slots: UptimeSlot[] = []; + + for ( + let t = floorToGrid(start, resolutionSeconds).getTime(); + t < last; + t += width + ) { + slots.push({ + start: new Date(t), + end: new Date(Math.min(t + width, last)), + }); + } + + return slots; +} + +export function alignBuckets( + slots: UptimeSlot[], + buckets: UptimeBucket[], +): (UptimeBucket | null)[] { + const ordered = buckets + .map((bucket) => ({ bucket, time: new Date(bucket.start).getTime() })) + .filter((entry) => Number.isFinite(entry.time)) + .sort((a, b) => a.time - b.time); + + let cursor = 0; + + return slots.map((slot) => { + const slotStart = slot.start.getTime(); + const slotEnd = slot.end.getTime(); + + while (cursor < ordered.length && ordered[cursor]!.time < slotStart) + cursor++; + + const candidate = ordered[cursor]; + if (!candidate || candidate.time >= slotEnd) return null; + + cursor++; + return candidate.bucket; + }); +} diff --git a/app/pages/uptime.vue b/app/pages/uptime.vue new file mode 100644 index 00000000..92cf21ff --- /dev/null +++ b/app/pages/uptime.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/app/plugins/api.ts b/app/plugins/api.ts index e43ab62d..7b42a478 100644 --- a/app/plugins/api.ts +++ b/app/plugins/api.ts @@ -41,7 +41,7 @@ export default defineNuxtPlugin(() => { const hubApi = $fetch.create({ baseURL: baseUrl, - timeout: 30000, + timeout: 60000, // matches HA async onRequest({ options }) { const sessionData = await getSession(); diff --git a/app/services/Api.ts b/app/services/Api.ts index ee6d599d..2fc67950 100644 --- a/app/services/Api.ts +++ b/app/services/Api.ts @@ -12,7 +12,7 @@ /** * ServiceTag - * Service tags. + * Service tags */ export enum ServiceTag { Auth = "Auth", @@ -31,6 +31,24 @@ export enum ServiceTag { Unknown = "Unknown", } +/** + * ServiceMonitoringStatus + * Whether a downstream service is being monitored on this node. + */ +export enum ServiceMonitoringStatus { + ACTIVE = "ACTIVE", + DISABLED = "DISABLED", +} + +/** + * ServiceCheckStatus + * Outcome of a single recorded probe. Only these two values are ever stored. + */ +export enum ServiceCheckStatus { + OK = "OK", + ERROR = "ERROR", +} + /** * ProtocolCode * Protocol codes. @@ -75,6 +93,20 @@ export enum HttpMethodCode { CUSTOM = "CUSTOM", } +/** + * HealthStatus + * Health of a service as reported by a live probe. + * + * Defined as an enum rather than a Literal so that it appears as a named schema in openapi.json + * and the frontend can import it instead of hardcoding the strings. + */ +export enum HealthStatus { + OK = "OK", + WARNING = "WARNING", + ERROR = "ERROR", + CRITICAL = "CRITICAL", +} + /** * DataStoreType * Data store types. @@ -1011,7 +1043,7 @@ export interface EventLog { component: string; /** Event Name */ event_name: string; - /** Service tags. */ + /** Service tags */ service: ServiceTag; /** Level */ level: string; @@ -1048,12 +1080,19 @@ export interface HTTPValidationError { * Response model to validate and return when performing a health check. */ export interface HealthCheck { - /** Status */ - status: "OK" | "WARNING" | "ERROR" | "CRITICAL"; + /** + * Health of a service as reported by a live probe. + * + * Defined as an enum rather than a Literal so that it appears as a named schema in openapi.json + * and the frontend can import it instead of hardcoding the strings. + */ + status: HealthStatus; /** Status Code */ status_code?: number | null; /** Message */ message?: string | null; + /** Latency Ms */ + latency_ms?: number | null; } /** InitializeAnalysis */ @@ -1100,13 +1139,13 @@ export interface KeyAuthConsumer { /** * KongCleanupSettings - * Settings for the background sweep that deletes Kong analysis consumers once their analysis - * reaches a terminal status. Always runs; only the interval is configurable. + * Settings for the background sweep that deletes Kong analysis consumers once their analysis reaches a terminal + * status. */ export interface KongCleanupSettings { /** * Interval - * @default 30 + * @default 120 */ interval?: number | null; } @@ -1838,6 +1877,197 @@ export interface ServiceClientCertificate { id?: string | null; } +/** + * ServiceHealthBucket + * Aggregate of every recorded probe falling inside one time slice. + * + * Computed in SQL over all rows in the slice, so the counts stay honest regardless of any + * limit applied to the raw datapoints. + */ +export interface ServiceHealthBucket { + /** + * Start + * Inclusive start of the slice + * @format date-time + */ + start: string; + /** + * End + * Exclusive end of the slice + * @format date-time + */ + end: string; + /** Total */ + total: number; + /** Successful */ + successful: number; + /** Failed */ + failed: number; + /** Max Latency Ms */ + max_latency_ms?: number | null; + /** Avg Latency Ms */ + avg_latency_ms?: number | null; + /** ERROR when any check in the slice failed, otherwise OK */ + worst_status: ServiceCheckStatus; + /** + * Message + * Error text of the earliest failed check in the slice, if any + */ + message?: string | null; +} + +/** + * ServiceHealthHistory + * Response model for the stored health history of the downstream services. + */ +export interface ServiceHealthHistory { + /** + * Monitoring Enabled + * Whether health checks are being recorded to Postgres + */ + monitoring_enabled: boolean; + /** + * Monitoring Detail + * Why monitoring is disabled, if applicable + */ + monitoring_detail?: string | null; + /** + * Interval Seconds + * How often the services are probed + */ + interval_seconds?: number | null; + /** + * Retention Days + * How long recorded checks are kept + */ + retention_days?: number | null; + /** + * Start + * @format date-time + */ + start: string; + /** + * End + * @format date-time + */ + end: string; + /** Services */ + services: Record; +} + +/** + * ServiceHealthPoint + * A single recorded probe of a downstream service. + */ +export interface ServiceHealthPoint { + /** + * Checked At + * @format date-time + */ + checked_at: string; + /** Outcome of a single recorded probe. Only these two values are ever stored. */ + status: ServiceCheckStatus; + /** Status Code */ + status_code?: number | null; + /** Latency Ms */ + latency_ms?: number | null; + /** Message */ + message?: string | null; + /** + * Sweep Id + * Identifies the probe cycle this check belongs to, shared by every service checked at the same time + */ + sweep_id?: string | null; +} + +/** + * ServiceHealthSettings + * Settings for the background routine that probes the downstream services and stores the results in Postgres. + * Only runs when a Postgres connection could be established when the app started. + */ +export interface ServiceHealthSettings { + /** + * Interval + * @default 60 + */ + interval?: number | null; + /** + * Retention Days + * @default 30 + */ + retention_days?: number | null; +} + +/** + * ServiceHealthSummary + * Aggregated health of a single downstream service over the requested timeframe. + */ +export interface ServiceHealthSummary { + /** + * Configured + * Whether a URL is configured for this service on this node + */ + configured: boolean; + /** DISABLED means the service is not being monitored */ + status: ServiceMonitoringStatus; + /** + * Url + * Health endpoint that is probed + */ + url?: string | null; + /** + * Detail + * Why the service is not being monitored, if applicable + */ + detail?: string | null; + /** + * Total Checks + * @default 0 + */ + total_checks?: number; + /** + * Successful Checks + * @default 0 + */ + successful_checks?: number; + /** + * Failed Checks + * @default 0 + */ + failed_checks?: number; + /** Uptime Percentage */ + uptime_percentage?: number | null; + /** Min Latency Ms */ + min_latency_ms?: number | null; + /** Avg Latency Ms */ + avg_latency_ms?: number | null; + /** Max Latency Ms */ + max_latency_ms?: number | null; + last_status?: ServiceCheckStatus | null; + /** Last Status Code */ + last_status_code?: number | null; + /** Last Checked At */ + last_checked_at?: string | null; + /** Last Error */ + last_error?: string | null; + /** + * Checks Returned + * Number of raw datapoints included below + * @default 0 + */ + checks_returned?: number; + /** + * Checks + * Raw datapoints in the timeframe, newest first, capped by the limit parameter + */ + checks?: ServiceHealthPoint[]; + /** + * Buckets + * Per-slice aggregates, only populated when a resolution was requested + */ + buckets?: ServiceHealthBucket[]; +} + /** * ServiceRequest * Improved version of the CreateServiceRequest with better defaults. @@ -1962,8 +2192,10 @@ export interface UserSettings { require_data_store?: boolean | null; /** @default {"enabled":false,"interval":60} */ autostart?: AutostartSettings | null; - /** @default {"interval":30} */ + /** @default {"interval":120} */ kong_cleanup?: KongCleanupSettings | null; + /** @default {"interval":60,"retention_days":30} */ + service_health?: ServiceHealthSettings | null; } /** ValidationError */ @@ -3538,7 +3770,7 @@ export class Api< }; healthz = { /** - * @description ## Perform a Health Check Endpoint to perform a healthcheck on. This endpoint can primarily be used Docker to ensure a robust container orchestration and management is in place. Other services which rely on proper functioning of the API service will not deploy if this endpoint returns any other HTTP status code except 200 (OK). Returns: HealthCheck: Returns a JSON response with the health status + * @description Returns: HealthCheck: Returns a JSON response with the health status * * @tags Health * @name HealthStatusGetHealthzGet @@ -3569,6 +3801,61 @@ export class Api< format: "json", ...params, }), + + /** + * @description Return the recorded health of the downstream microservices within a timeframe. Checks are recorded by a background routine which requires Postgres. When no database connection could be made at startup, monitoring_enabled is false and monitoring_detail explains why. Services with no URL configured on this node are reported as "disabled". + * + * @tags Health + * @name HealthStatusServicesHistoryGetHealthServicesHistoryGet + * @summary Fetch the recorded health of the downstream microservices + * @request GET:/health/services/history + */ + healthStatusServicesHistoryGetHealthServicesHistoryGet: ( + query?: { + /** + * Start Date + * Fetch checks from this timestamp using ISO8601 format. Defaults to 24 hours ago + */ + start_date?: string | null; + /** + * End Date + * Fetch checks up to this timestamp using ISO8601 format. Defaults to now + */ + end_date?: string | null; + /** + * Service + * Limit the response to these services. Can be repeated. Defaults to all services + */ + service?: string[] | null; + /** + * Include Checks + * Whether to include the raw datapoints alongside the summary + * @default true + */ + include_checks?: boolean; + /** + * Resolution + * Aggregate checks into slices this many seconds wide instead of returning raw datapoints. Slices with no checks are omitted + */ + resolution?: number | null; + /** + * Limit + * Maximum number of raw datapoints to return per service, newest first + * @exclusiveMin 0 + * @max 10000 + * @default 500 + */ + limit?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/health/services/history`, + method: "GET", + query: query, + format: "json", + ...params, + }), }; token = { /** diff --git a/app/services/hub_adapter_swagger.json b/app/services/hub_adapter_swagger.json index 1255d2eb..59eaca54 100644 --- a/app/services/hub_adapter_swagger.json +++ b/app/services/hub_adapter_swagger.json @@ -3152,7 +3152,7 @@ "get": { "tags": ["Health"], "summary": "Perform a Health Check", - "description": "## Perform a Health Check\nEndpoint to perform a healthcheck on. This endpoint can primarily be used Docker\nto ensure a robust container orchestration and management is in place. Other\nservices which rely on proper functioning of the API service will not deploy if this\nendpoint returns any other HTTP status code except 200 (OK).\nReturns:\n HealthCheck: Returns a JSON response with the health status", + "description": "Returns:\n HealthCheck: Returns a JSON response with the health status", "operationId": "health_status_get_healthz_get", "responses": { "200": { @@ -3188,6 +3188,143 @@ } } }, + "/health/services/history": { + "get": { + "tags": ["Health"], + "summary": "Fetch the recorded health of the downstream microservices", + "description": "Return the recorded health of the downstream microservices within a timeframe.\n\nChecks are recorded by a background routine which requires Postgres. When no database connection could be made\nat startup, monitoring_enabled is false and monitoring_detail explains why.\n\nServices with no URL configured on this node are reported as \"disabled\".", + "operationId": "health_status_services_history_get_health_services_history_get", + "parameters": [ + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Fetch checks from this timestamp using ISO8601 format. Defaults to 24 hours ago", + "title": "Start Date" + }, + "description": "Fetch checks from this timestamp using ISO8601 format. Defaults to 24 hours ago" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Fetch checks up to this timestamp using ISO8601 format. Defaults to now", + "title": "End Date" + }, + "description": "Fetch checks up to this timestamp using ISO8601 format. Defaults to now" + }, + { + "name": "service", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Limit the response to these services. Can be repeated. Defaults to all services", + "title": "Service" + }, + "description": "Limit the response to these services. Can be repeated. Defaults to all services" + }, + { + "name": "include_checks", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to include the raw datapoints alongside the summary", + "default": true, + "title": "Include Checks" + }, + "description": "Whether to include the raw datapoints alongside the summary" + }, + { + "name": "resolution", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 86400, + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ], + "description": "Aggregate checks into slices this many seconds wide instead of returning raw datapoints. Slices with no checks are omitted", + "title": "Resolution" + }, + "description": "Aggregate checks into slices this many seconds wide instead of returning raw datapoints. Slices with no checks are omitted" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 10000, + "exclusiveMinimum": 0, + "description": "Maximum number of raw datapoints to return per service, newest first", + "default": 500, + "title": "Limit" + }, + "description": "Maximum number of raw datapoints to return per service, newest first" + } + ], + "responses": { + "200": { + "description": "Per service uptime, latency statistics and raw datapoints for a timeframe", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceHealthHistory" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/token": { "post": { "tags": ["Auth"], @@ -6277,9 +6414,7 @@ "HealthCheck": { "properties": { "status": { - "type": "string", - "enum": ["OK", "WARNING", "ERROR", "CRITICAL"], - "title": "Status" + "$ref": "#/components/schemas/HealthStatus" }, "status_code": { "anyOf": [ @@ -6302,6 +6437,17 @@ } ], "title": "Message" + }, + "latency_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latency Ms" } }, "type": "object", @@ -6309,6 +6455,12 @@ "title": "HealthCheck", "description": "Response model to validate and return when performing a health check." }, + "HealthStatus": { + "type": "string", + "enum": ["OK", "WARNING", "ERROR", "CRITICAL"], + "title": "HealthStatus", + "description": "Health of a service as reported by a live probe.\n\nDefined as an enum rather than a Literal so that it appears as a named schema in openapi.json\nand the frontend can import it instead of hardcoding the strings." + }, "HttpMethodCode": { "type": "string", "enum": [ @@ -6455,13 +6607,13 @@ } ], "title": "Interval", - "default": 30 + "default": 120 } }, "additionalProperties": false, "type": "object", "title": "KongCleanupSettings", - "description": "Settings for the background sweep that deletes Kong analysis consumers once their analysis\nreaches a terminal status. Always runs; only the interval is configurable." + "description": "Settings for the background sweep that deletes Kong analysis consumers once their analysis reaches a terminal\nstatus." }, "LinkDataStoreProject": { "properties": { @@ -8208,6 +8360,12 @@ "title": "Service", "description": "service entities are abstractions of upstream services. The main attribute of a service is its URL which can be set as a single string or by specifying the `protocol`, `host`, `port` and `path` individually." }, + "ServiceCheckStatus": { + "type": "string", + "enum": ["OK", "ERROR"], + "title": "ServiceCheckStatus", + "description": "Outcome of a single recorded probe. Only these two values are ever stored." + }, "ServiceClientCertificate": { "properties": { "id": { @@ -8226,6 +8384,417 @@ "title": "ServiceClientCertificate", "description": "Certificate to be used as client certificate while TLS handshaking to the upstream server." }, + "ServiceHealthBucket": { + "properties": { + "start": { + "type": "string", + "format": "date-time", + "title": "Start", + "description": "Inclusive start of the slice" + }, + "end": { + "type": "string", + "format": "date-time", + "title": "End", + "description": "Exclusive end of the slice" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "successful": { + "type": "integer", + "title": "Successful" + }, + "failed": { + "type": "integer", + "title": "Failed" + }, + "max_latency_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Max Latency Ms" + }, + "avg_latency_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Avg Latency Ms" + }, + "worst_status": { + "$ref": "#/components/schemas/ServiceCheckStatus", + "description": "ERROR when any check in the slice failed, otherwise OK" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message", + "description": "Error text of the earliest failed check in the slice, if any" + } + }, + "type": "object", + "required": [ + "start", + "end", + "total", + "successful", + "failed", + "worst_status" + ], + "title": "ServiceHealthBucket", + "description": "Aggregate of every recorded probe falling inside one time slice.\n\nComputed in SQL over all rows in the slice, so the counts stay honest regardless of any\nlimit applied to the raw datapoints." + }, + "ServiceHealthHistory": { + "properties": { + "monitoring_enabled": { + "type": "boolean", + "title": "Monitoring Enabled", + "description": "Whether health checks are being recorded to Postgres" + }, + "monitoring_detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Monitoring Detail", + "description": "Why monitoring is disabled, if applicable" + }, + "interval_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Interval Seconds", + "description": "How often the services are probed" + }, + "retention_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Retention Days", + "description": "How long recorded checks are kept" + }, + "start": { + "type": "string", + "format": "date-time", + "title": "Start" + }, + "end": { + "type": "string", + "format": "date-time", + "title": "End" + }, + "services": { + "additionalProperties": { + "$ref": "#/components/schemas/ServiceHealthSummary" + }, + "type": "object", + "title": "Services" + } + }, + "type": "object", + "required": ["monitoring_enabled", "start", "end", "services"], + "title": "ServiceHealthHistory", + "description": "Response model for the stored health history of the downstream services." + }, + "ServiceHealthPoint": { + "properties": { + "checked_at": { + "type": "string", + "format": "date-time", + "title": "Checked At" + }, + "status": { + "$ref": "#/components/schemas/ServiceCheckStatus" + }, + "status_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Status Code" + }, + "latency_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latency Ms" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + }, + "sweep_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Sweep Id", + "description": "Identifies the probe cycle this check belongs to, shared by every service checked at the same time" + } + }, + "type": "object", + "required": ["checked_at", "status"], + "title": "ServiceHealthPoint", + "description": "A single recorded probe of a downstream service." + }, + "ServiceHealthSettings": { + "properties": { + "interval": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Interval", + "default": 60 + }, + "retention_days": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Retention Days", + "default": 30 + } + }, + "additionalProperties": false, + "type": "object", + "title": "ServiceHealthSettings", + "description": "Settings for the background routine that probes the downstream services and stores the results in Postgres.\nOnly runs when a Postgres connection could be established when the app started." + }, + "ServiceHealthSummary": { + "properties": { + "configured": { + "type": "boolean", + "title": "Configured", + "description": "Whether a URL is configured for this service on this node" + }, + "status": { + "$ref": "#/components/schemas/ServiceMonitoringStatus", + "description": "DISABLED means the service is not being monitored" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url", + "description": "Health endpoint that is probed" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail", + "description": "Why the service is not being monitored, if applicable" + }, + "total_checks": { + "type": "integer", + "title": "Total Checks", + "default": 0 + }, + "successful_checks": { + "type": "integer", + "title": "Successful Checks", + "default": 0 + }, + "failed_checks": { + "type": "integer", + "title": "Failed Checks", + "default": 0 + }, + "uptime_percentage": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Uptime Percentage" + }, + "min_latency_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Min Latency Ms" + }, + "avg_latency_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Avg Latency Ms" + }, + "max_latency_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Max Latency Ms" + }, + "last_status": { + "anyOf": [ + { + "$ref": "#/components/schemas/ServiceCheckStatus" + }, + { + "type": "null" + } + ] + }, + "last_status_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Status Code" + }, + "last_checked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Checked At" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Error" + }, + "checks_returned": { + "type": "integer", + "title": "Checks Returned", + "description": "Number of raw datapoints included below", + "default": 0 + }, + "checks": { + "items": { + "$ref": "#/components/schemas/ServiceHealthPoint" + }, + "type": "array", + "title": "Checks", + "description": "Raw datapoints in the timeframe, newest first, capped by the limit parameter" + }, + "buckets": { + "items": { + "$ref": "#/components/schemas/ServiceHealthBucket" + }, + "type": "array", + "title": "Buckets", + "description": "Per-slice aggregates, only populated when a resolution was requested" + } + }, + "type": "object", + "required": ["configured", "status"], + "title": "ServiceHealthSummary", + "description": "Aggregated health of a single downstream service over the requested timeframe." + }, + "ServiceMonitoringStatus": { + "type": "string", + "enum": ["ACTIVE", "DISABLED"], + "title": "ServiceMonitoringStatus", + "description": "Whether a downstream service is being monitored on this node." + }, "ServiceRequest": { "properties": { "name": { @@ -8432,7 +9001,7 @@ "Unknown" ], "title": "ServiceTag", - "description": "Service tags." + "description": "Service tags" }, "StatusOnlyResponse": { "additionalProperties": { @@ -8566,7 +9135,21 @@ } ], "default": { - "interval": 30 + "interval": 120 + } + }, + "service_health": { + "anyOf": [ + { + "$ref": "#/components/schemas/ServiceHealthSettings" + }, + { + "type": "null" + } + ], + "default": { + "interval": 60, + "retention_days": 30 } } }, diff --git a/app/utils/prettify-key.ts b/app/utils/prettify-key.ts deleted file mode 100644 index fa0cd606..00000000 --- a/app/utils/prettify-key.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function prettifyKey(metadataKey: string): string { - const keyParts = metadataKey.split("_").map((word) => { - return word[0].toUpperCase() + word.slice(1); - }); - return keyParts.join(" "); -} diff --git a/app/utils/uptime-state.ts b/app/utils/uptime-state.ts new file mode 100644 index 00000000..16f4a3df --- /dev/null +++ b/app/utils/uptime-state.ts @@ -0,0 +1,67 @@ +import type { ServiceHealthBucket } from "~/services/Api"; + +export const SLOW_LATENCY_MS = 200; + +export type UptimeState = "ok" | "slow" | "error" | "empty"; +export type UptimeBucket = ServiceHealthBucket; + +export function bucketState(bucket: UptimeBucket | null): UptimeState { + if (!bucket || bucket.total === 0) return "empty"; + if (bucket.failed > 0) return "error"; + if ((bucket.max_latency_ms ?? 0) > SLOW_LATENCY_MS) return "slow"; + return "ok"; +} + +export function uptimePalette(isDark: boolean): Record { + return isDark + ? { ok: "#4ade80", slow: "#fbbf24", error: "#f87171", empty: "#44403c" } + : { ok: "#22c55e", slow: "#f59e0b", error: "#ef4444", empty: "#e2e8f0" }; +} + +export function trackGapColor(isDark: boolean): string { + return isDark ? "#1c1917" : "#ffffff"; +} + +export function formatClockTime(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatSlot(start: Date, end: Date): string { + return `${start.toLocaleDateString()} ${formatClockTime(start)}โ€“${formatClockTime(end)}`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function bucketTooltip( + bucket: UptimeBucket | null, + slotStart: Date, + slotEnd: Date, +): string { + const header = `${formatSlot(slotStart, slotEnd)}`; + + if (!bucket || bucket.total === 0) { + return `${header}
No data recorded`; + } + + const lines = [header, `${bucket.successful}/${bucket.total} checks ok`]; + + if (bucket.max_latency_ms != null) { + lines.push(`worst ${Math.round(bucket.max_latency_ms)} ms`); + } + + if (bucket.failed > 0 && bucket.message) { + lines.push(`${escapeHtml(bucket.message)}`); + } + + return lines.join("
"); +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 166ddf59..ec7e6950 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -10,7 +10,12 @@ const projectRoot = fileURLToPath(new URL(".", import.meta.url)); export default defineNuxtConfig({ ssr: false, devtools: { enabled: false }, - modules: ["@primevue/nuxt-module", "@sidebase/nuxt-auth", "@pinia/nuxt"], + modules: [ + "@primevue/nuxt-module", + "@sidebase/nuxt-auth", + "@pinia/nuxt", + "nuxt-echarts", + ], plugins: ["./app/plugins/api.ts"], @@ -30,6 +35,10 @@ export default defineNuxtConfig({ }, }, + typescript: { + typeCheck: true, + }, + auth: { isEnabled: true, originEnvKey: "NUXT_PUBLIC_ORIGIN", @@ -65,6 +74,12 @@ export default defineNuxtConfig({ }, }, + echarts: { + renderer: ["canvas"], + charts: ["HeatmapChart"], + components: ["TooltipComponent", "VisualMapComponent", "GridComponent"], + }, + css: [ "~/assets/css/main.css", "primeicons/primeicons.css", @@ -85,6 +100,10 @@ export default defineNuxtConfig({ vite: { plugins: [tailwindcss()], + optimizeDeps: { + // avoid a nasty dev only lazy import bug + exclude: ["@primevue/core/api"], + }, }, compatibilityDate: "2026-02-05", diff --git a/package.json b/package.json index 532597d3..f83d7aa4 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "version": "0.7.1", "license": "Apache-2.0", "description": "User interface for the FLAME Node software.", - "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b", + "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c", "type": "module", "scripts": { "start": "nuxt start", "build": "nuxt build", + "typecheck": "nuxt typecheck", "coverage": "vitest run --coverage", "dev": "nuxt dev", "generate": "nuxt generate", @@ -31,10 +32,12 @@ "@types/uuid": "^9.0.8", "@vueuse/core": "^11.3.0", "chart.js": "^4.5.1", + "echarts": "^6.1.0", "globals": "^15.15.0", "next-auth": "~4.21.1", "node-fetch-native": "^1.6.7", "nuxt": "^4.4.7", + "nuxt-echarts": "^1.0.1", "pinia": "^3.0.4", "prettier": "^3.6.2", "primeicons": "^7.0.0", @@ -45,8 +48,8 @@ "swagger-typescript-api": "^13.2.16", "tailwindcss": "^4.2.0", "tailwindcss-primeui": "^0.6.1", - "typescript": "^5.9.3", "vue": "^3.5.28", + "vue-echarts": "^8.0.1", "vue-router": "^5.0.3", "webpack": "^5.102.1" }, @@ -64,8 +67,10 @@ "eslint-plugin-vue": "^9.33.0", "happy-dom": "^20.8.9", "msw": "^2.11.6", + "typescript": "^5.9.3", "typescript-eslint": "^8.46.2", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.6" + "vitest": "^3.2.6", + "vue-tsc": "^3.3.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8f50d2c..38857a4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - next-auth@4.21.1: - hash: be804a50721ec4d24d9187f1dbb3057252a47eef6275eedcc8684444349d1c1d - path: patches/next-auth-no-proxy.patch + next-auth@4.21.1: be804a50721ec4d24d9187f1dbb3057252a47eef6275eedcc8684444349d1c1d importers: @@ -46,6 +44,9 @@ importers: chart.js: specifier: ^4.5.1 version: 4.5.1 + echarts: + specifier: ^6.1.0 + version: 6.1.0 globals: specifier: ^15.15.0 version: 15.15.0 @@ -57,7 +58,10 @@ importers: version: 1.6.7 nuxt: specifier: ^4.4.7 - version: 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) + version: 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0) + nuxt-echarts: + specifier: ^1.0.1 + version: 1.0.1(echarts@6.1.0)(magicast@0.5.3)(vue-echarts@8.0.1(echarts@6.1.0)(vue@3.5.28(typescript@5.9.3))) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)) @@ -88,12 +92,12 @@ importers: tailwindcss-primeui: specifier: ^0.6.1 version: 0.6.1(tailwindcss@4.2.0) - typescript: - specifier: ^5.9.3 - version: 5.9.3 vue: specifier: ^3.5.28 version: 3.5.28(typescript@5.9.3) + vue-echarts: + specifier: ^8.0.1 + version: 8.0.1(echarts@6.1.0)(vue@3.5.28(typescript@5.9.3)) vue-router: specifier: ^5.0.3 version: 5.0.3(@vue/compiler-sfc@3.5.38)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.5.28(typescript@5.9.3)) @@ -140,6 +144,9 @@ importers: msw: specifier: ^2.11.6 version: 2.12.10(@types/node@25.3.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 typescript-eslint: specifier: ^8.46.2 version: 8.56.0(eslint@9.26.0(jiti@2.7.0))(typescript@5.9.3) @@ -149,6 +156,9 @@ importers: vitest: specifier: ^3.2.6 version: 3.2.6(@types/node@25.3.0)(happy-dom@20.8.9)(jiti@2.7.0)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) + vue-tsc: + specifier: ^3.3.9 + version: 3.3.9(typescript@5.9.3) packages: @@ -1917,11 +1927,6 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm-eabi@4.61.1': - resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.62.0': resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} cpu: [arm] @@ -1932,11 +1937,6 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-android-arm64@4.61.1': - resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} - cpu: [arm64] - os: [android] - '@rollup/rollup-android-arm64@4.62.0': resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} cpu: [arm64] @@ -1947,11 +1947,6 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-arm64@4.61.1': - resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.62.0': resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} cpu: [arm64] @@ -1962,11 +1957,6 @@ packages: cpu: [x64] os: [darwin] - '@rollup/rollup-darwin-x64@4.61.1': - resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} - cpu: [x64] - os: [darwin] - '@rollup/rollup-darwin-x64@4.62.0': resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} cpu: [x64] @@ -1977,11 +1967,6 @@ packages: cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.61.1': - resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} - cpu: [arm64] - os: [freebsd] - '@rollup/rollup-freebsd-arm64@4.62.0': resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} cpu: [arm64] @@ -1992,11 +1977,6 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.61.1': - resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} - cpu: [x64] - os: [freebsd] - '@rollup/rollup-freebsd-x64@4.62.0': resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} cpu: [x64] @@ -2008,12 +1988,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} cpu: [arm] @@ -2026,12 +2000,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-arm-musleabihf@4.61.1': - resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} - cpu: [arm] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm-musleabihf@4.62.0': resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} cpu: [arm] @@ -2044,12 +2012,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-gnu@4.61.1': - resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-gnu@4.62.0': resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} cpu: [arm64] @@ -2062,12 +2024,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-musl@4.61.1': - resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-musl@4.62.0': resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} cpu: [arm64] @@ -2080,12 +2036,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-gnu@4.61.1': - resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} - cpu: [loong64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-loong64-gnu@4.62.0': resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} cpu: [loong64] @@ -2098,12 +2048,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-musl@4.61.1': - resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} - cpu: [loong64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-loong64-musl@4.62.0': resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} cpu: [loong64] @@ -2116,12 +2060,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.61.1': - resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-ppc64-gnu@4.62.0': resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} cpu: [ppc64] @@ -2134,12 +2072,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-musl@4.61.1': - resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} - cpu: [ppc64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-musl@4.62.0': resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} cpu: [ppc64] @@ -2152,12 +2084,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.61.1': - resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.62.0': resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} cpu: [riscv64] @@ -2170,12 +2096,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-musl@4.61.1': - resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-riscv64-musl@4.62.0': resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} cpu: [riscv64] @@ -2188,12 +2108,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-s390x-gnu@4.61.1': - resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-s390x-gnu@4.62.0': resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} cpu: [s390x] @@ -2206,12 +2120,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.61.1': - resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.62.0': resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} cpu: [x64] @@ -2224,12 +2132,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-x64-musl@4.61.1': - resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} - cpu: [x64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-x64-musl@4.62.0': resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} cpu: [x64] @@ -2241,11 +2143,6 @@ packages: cpu: [x64] os: [openbsd] - '@rollup/rollup-openbsd-x64@4.61.1': - resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} - cpu: [x64] - os: [openbsd] - '@rollup/rollup-openbsd-x64@4.62.0': resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} cpu: [x64] @@ -2256,11 +2153,6 @@ packages: cpu: [arm64] os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.61.1': - resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} - cpu: [arm64] - os: [openharmony] - '@rollup/rollup-openharmony-arm64@4.62.0': resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} cpu: [arm64] @@ -2271,11 +2163,6 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.61.1': - resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} - cpu: [arm64] - os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.62.0': resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} cpu: [arm64] @@ -2286,11 +2173,6 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.61.1': - resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} - cpu: [ia32] - os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.62.0': resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} cpu: [ia32] @@ -2301,11 +2183,6 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.61.1': - resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-gnu@4.62.0': resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} cpu: [x64] @@ -2316,11 +2193,6 @@ packages: cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.61.1': - resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-msvc@4.62.0': resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} cpu: [x64] @@ -2646,6 +2518,15 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue-macros/common@3.1.2': resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} engines: {node: '>=20.19.0'} @@ -2727,6 +2608,9 @@ packages: '@vue/devtools-shared@8.1.3': resolution: {integrity: sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==} + '@vue/language-core@3.3.9': + resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==} + '@vue/reactivity@3.5.28': resolution: {integrity: sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==} @@ -2905,6 +2789,9 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3488,6 +3375,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + editorconfig@1.0.4: resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} engines: {node: '>=14'} @@ -4732,6 +4622,12 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuxt-echarts@1.0.1: + resolution: {integrity: sha512-YI7XKHSV7K918KKpm35eKMJkuiKcnaHYgRxMhi4ofMolbQkb821r0cOxDdI2Jlc7jh00OAx8drt80rX+PG8d5A==} + peerDependencies: + echarts: ^6.0.0 + vue-echarts: ^8.0.0 + nuxt@4.4.7: resolution: {integrity: sha512-4ASIbcOVF2O1HJqoKRVntxOphl9zmikgmj25D9c93l795yp8x0f4TItzrnT0xyrFxMkpL6z3rHhrfQQfoxNXxw==} engines: {node: ^22.12.0 || ^24.11.0 || >=26.0.0} @@ -4893,6 +4789,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5380,11 +5279,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rollup@4.61.1: - resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -5883,6 +5777,9 @@ packages: typescript: optional: true + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -6333,6 +6230,9 @@ packages: engines: {node: '>=6.0'} hasBin: true + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-bundle-renderer@2.2.0: resolution: {integrity: sha512-sz/0WEdYH1KfaOm0XaBmRZOWgYTEvUDt6yPYaUzl4E52qzgWLlknaPPTTZmp6benaPTlQAI/hN1x3tAzZygycg==} @@ -6353,6 +6253,12 @@ packages: vue-devtools-stub@0.1.0: resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} + vue-echarts@8.0.1: + resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==} + peerDependencies: + echarts: ^6.0.0 + vue: ^3.3.0 + vue-eslint-parser@9.4.3: resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} engines: {node: ^14.17.0 || >=16.0.0} @@ -6392,6 +6298,12 @@ packages: vite: optional: true + vue-tsc@3.3.9: + resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + vue@3.5.28: resolution: {integrity: sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==} peerDependencies: @@ -6593,6 +6505,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -7538,7 +7453,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.7(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.133.0)(srvx@0.11.16)(typescript@5.9.3)': + '@nuxt/nitro-server@4.4.7(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.133.0)(srvx@0.11.16)(typescript@5.9.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.7(magicast@0.5.3) @@ -7556,7 +7471,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(oxc-parser@0.133.0)(srvx@0.11.16) - nuxt: 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.7 ohash: 2.0.11 pathe: 2.0.3 @@ -7665,10 +7580,10 @@ snapshots: - typescript - vite - '@nuxt/vite-builder@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@types/node@25.3.0)(eslint@9.26.0(jiti@2.7.0))(lightningcss@1.31.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(terser@5.48.0)(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3))(yaml@2.9.0)': + '@nuxt/vite-builder@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@types/node@25.3.0)(eslint@9.26.0(jiti@2.7.0))(lightningcss@1.31.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.9(typescript@5.9.3))(vue@3.5.38(typescript@5.9.3))(yaml@2.9.0)': dependencies: '@nuxt/kit': 4.4.7(magicast@0.5.3) - '@rollup/plugin-replace': 6.0.3(rollup@4.62.0) + '@rollup/plugin-replace': 6.0.3(rollup@4.57.1) '@vitejs/plugin-vue': 6.0.7(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) autoprefixer: 10.5.0(postcss@8.5.15) @@ -7683,7 +7598,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) + nuxt: 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.7 pathe: 2.0.3 pkg-types: 2.3.1 @@ -7694,7 +7609,7 @@ snapshots: unenv: 2.0.0-rc.24 vite: 7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) vite-node: 5.3.0(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) - vite-plugin-checker: 0.14.4(eslint@9.26.0(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)) + vite-plugin-checker: 0.14.4(eslint@9.26.0(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3)) vue: 3.5.38(typescript@5.9.3) vue-bundle-renderer: 2.2.0 optionalDependencies: @@ -8236,225 +8151,150 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.57.1': optional: true - '@rollup/rollup-android-arm-eabi@4.61.1': - optional: true - '@rollup/rollup-android-arm-eabi@4.62.0': optional: true '@rollup/rollup-android-arm64@4.57.1': optional: true - '@rollup/rollup-android-arm64@4.61.1': - optional: true - '@rollup/rollup-android-arm64@4.62.0': optional: true '@rollup/rollup-darwin-arm64@4.57.1': optional: true - '@rollup/rollup-darwin-arm64@4.61.1': - optional: true - '@rollup/rollup-darwin-arm64@4.62.0': optional: true '@rollup/rollup-darwin-x64@4.57.1': optional: true - '@rollup/rollup-darwin-x64@4.61.1': - optional: true - '@rollup/rollup-darwin-x64@4.62.0': optional: true '@rollup/rollup-freebsd-arm64@4.57.1': optional: true - '@rollup/rollup-freebsd-arm64@4.61.1': - optional: true - '@rollup/rollup-freebsd-arm64@4.62.0': optional: true '@rollup/rollup-freebsd-x64@4.57.1': optional: true - '@rollup/rollup-freebsd-x64@4.61.1': - optional: true - '@rollup/rollup-freebsd-x64@4.62.0': optional: true '@rollup/rollup-linux-arm-gnueabihf@4.57.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': optional: true '@rollup/rollup-linux-arm-musleabihf@4.57.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.61.1': - optional: true - '@rollup/rollup-linux-arm-musleabihf@4.62.0': optional: true '@rollup/rollup-linux-arm64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.61.1': - optional: true - '@rollup/rollup-linux-arm64-gnu@4.62.0': optional: true '@rollup/rollup-linux-arm64-musl@4.57.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.61.1': - optional: true - '@rollup/rollup-linux-arm64-musl@4.62.0': optional: true '@rollup/rollup-linux-loong64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.61.1': - optional: true - '@rollup/rollup-linux-loong64-gnu@4.62.0': optional: true '@rollup/rollup-linux-loong64-musl@4.57.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.61.1': - optional: true - '@rollup/rollup-linux-loong64-musl@4.62.0': optional: true '@rollup/rollup-linux-ppc64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.61.1': - optional: true - '@rollup/rollup-linux-ppc64-gnu@4.62.0': optional: true '@rollup/rollup-linux-ppc64-musl@4.57.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.61.1': - optional: true - '@rollup/rollup-linux-ppc64-musl@4.62.0': optional: true '@rollup/rollup-linux-riscv64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.61.1': - optional: true - '@rollup/rollup-linux-riscv64-gnu@4.62.0': optional: true '@rollup/rollup-linux-riscv64-musl@4.57.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.61.1': - optional: true - '@rollup/rollup-linux-riscv64-musl@4.62.0': optional: true '@rollup/rollup-linux-s390x-gnu@4.57.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.61.1': - optional: true - '@rollup/rollup-linux-s390x-gnu@4.62.0': optional: true '@rollup/rollup-linux-x64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.61.1': - optional: true - '@rollup/rollup-linux-x64-gnu@4.62.0': optional: true '@rollup/rollup-linux-x64-musl@4.57.1': optional: true - '@rollup/rollup-linux-x64-musl@4.61.1': - optional: true - '@rollup/rollup-linux-x64-musl@4.62.0': optional: true '@rollup/rollup-openbsd-x64@4.57.1': optional: true - '@rollup/rollup-openbsd-x64@4.61.1': - optional: true - '@rollup/rollup-openbsd-x64@4.62.0': optional: true '@rollup/rollup-openharmony-arm64@4.57.1': optional: true - '@rollup/rollup-openharmony-arm64@4.61.1': - optional: true - '@rollup/rollup-openharmony-arm64@4.62.0': optional: true '@rollup/rollup-win32-arm64-msvc@4.57.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.61.1': - optional: true - '@rollup/rollup-win32-arm64-msvc@4.62.0': optional: true '@rollup/rollup-win32-ia32-msvc@4.57.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.61.1': - optional: true - '@rollup/rollup-win32-ia32-msvc@4.62.0': optional: true '@rollup/rollup-win32-x64-gnu@4.57.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.61.1': - optional: true - '@rollup/rollup-win32-x64-gnu@4.62.0': optional: true '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.61.1': - optional: true - '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true @@ -8871,6 +8711,18 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + '@vue-macros/common@3.1.2(vue@3.5.28(typescript@5.9.3))': dependencies: '@vue/compiler-sfc': 3.5.28 @@ -9035,6 +8887,16 @@ snapshots: '@vue/devtools-shared@8.1.3': {} + '@vue/language-core@3.3.9': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.38 + '@vue/shared': 3.5.38 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.4 + '@vue/reactivity@3.5.28': dependencies: '@vue/shared': 3.5.28 @@ -9259,6 +9121,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + alien-signals@3.2.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -9833,6 +9697,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + editorconfig@1.0.4: dependencies: '@one-ini/wasm': 0.1.1 @@ -11359,16 +11228,24 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0): + nuxt-echarts@1.0.1(echarts@6.1.0)(magicast@0.5.3)(vue-echarts@8.0.1(echarts@6.1.0)(vue@3.5.28(typescript@5.9.3))): + dependencies: + '@nuxt/kit': 4.4.7(magicast@0.5.3) + echarts: 6.1.0 + vue-echarts: 8.0.1(echarts@6.1.0)(vue@3.5.28(typescript@5.9.3)) + transitivePeerDependencies: + - magicast + + nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@5.9.3) '@nuxt/cli': 3.35.2(@nuxt/schema@4.4.7)(cac@6.7.14)(magicast@0.5.3) '@nuxt/devtools': 3.2.4(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) '@nuxt/kit': 4.4.7(magicast@0.5.3) - '@nuxt/nitro-server': 4.4.7(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0))(oxc-parser@0.133.0)(srvx@0.11.16)(typescript@5.9.3) + '@nuxt/nitro-server': 4.4.7(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(db0@0.3.4)(ioredis@5.11.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0))(oxc-parser@0.133.0)(srvx@0.11.16)(typescript@5.9.3) '@nuxt/schema': 4.4.7 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.7(magicast@0.5.3)) - '@nuxt/vite-builder': 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@types/node@25.3.0)(eslint@9.26.0(jiti@2.7.0))(lightningcss@1.31.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(sass@1.97.3)(terser@5.48.0)(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3))(yaml@2.9.0) + '@nuxt/vite-builder': 4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@types/node@25.3.0)(eslint@9.26.0(jiti@2.7.0))(lightningcss@1.31.1)(magicast@0.5.3)(nuxt@4.4.7(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.3.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@9.26.0(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.31.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3))(yaml@2.9.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.57.1)(sass@1.97.3)(terser@5.48.0)(typescript@5.9.3)(vue-tsc@3.3.9(typescript@5.9.3))(vue@3.5.38(typescript@5.9.3))(yaml@2.9.0) '@unhead/vue': 2.1.15(vue@3.5.38(typescript@5.9.3)) '@vue/shared': 3.5.38 chokidar: 5.0.0 @@ -11724,6 +11601,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -12206,37 +12085,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 - rollup@4.61.1: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.61.1 - '@rollup/rollup-android-arm64': 4.61.1 - '@rollup/rollup-darwin-arm64': 4.61.1 - '@rollup/rollup-darwin-x64': 4.61.1 - '@rollup/rollup-freebsd-arm64': 4.61.1 - '@rollup/rollup-freebsd-x64': 4.61.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 - '@rollup/rollup-linux-arm-musleabihf': 4.61.1 - '@rollup/rollup-linux-arm64-gnu': 4.61.1 - '@rollup/rollup-linux-arm64-musl': 4.61.1 - '@rollup/rollup-linux-loong64-gnu': 4.61.1 - '@rollup/rollup-linux-loong64-musl': 4.61.1 - '@rollup/rollup-linux-ppc64-gnu': 4.61.1 - '@rollup/rollup-linux-ppc64-musl': 4.61.1 - '@rollup/rollup-linux-riscv64-gnu': 4.61.1 - '@rollup/rollup-linux-riscv64-musl': 4.61.1 - '@rollup/rollup-linux-s390x-gnu': 4.61.1 - '@rollup/rollup-linux-x64-gnu': 4.61.1 - '@rollup/rollup-linux-x64-musl': 4.61.1 - '@rollup/rollup-openbsd-x64': 4.61.1 - '@rollup/rollup-openharmony-arm64': 4.61.1 - '@rollup/rollup-win32-arm64-msvc': 4.61.1 - '@rollup/rollup-win32-ia32-msvc': 4.61.1 - '@rollup/rollup-win32-x64-gnu': 4.61.1 - '@rollup/rollup-win32-x64-msvc': 4.61.1 - fsevents: 2.3.3 - rollup@4.62.0: dependencies: '@types/estree': 1.0.9 @@ -12762,6 +12610,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + tslib@2.3.0: {} + tslib@2.8.1: {} type-check@0.3.2: @@ -13050,7 +12900,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.14.4(eslint@9.26.0(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)): + vite-plugin-checker@0.14.4(eslint@9.26.0(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.9(typescript@5.9.3)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 @@ -13064,6 +12914,7 @@ snapshots: eslint: 9.26.0(jiti@2.7.0) optionator: 0.9.4 typescript: 5.9.3 + vue-tsc: 3.3.9(typescript@5.9.3) vite-plugin-inspect@11.4.1(@nuxt/kit@4.4.7(magicast@0.5.3))(vite@7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0)): dependencies: @@ -13107,7 +12958,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 - rollup: 4.61.1 + rollup: 4.62.0 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.3.0 @@ -13184,6 +13035,8 @@ snapshots: acorn: 8.17.0 acorn-walk: 8.3.5 + vscode-uri@3.1.0: {} + vue-bundle-renderer@2.2.0: dependencies: ufo: 1.6.4 @@ -13196,6 +13049,11 @@ snapshots: vue-devtools-stub@0.1.0: {} + vue-echarts@8.0.1(echarts@6.1.0)(vue@3.5.28(typescript@5.9.3)): + dependencies: + echarts: 6.1.0 + vue: 3.5.28(typescript@5.9.3) + vue-eslint-parser@9.4.3(eslint@9.26.0(jiti@2.7.0)): dependencies: debug: 4.4.3 @@ -13258,6 +13116,12 @@ snapshots: pinia: 3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)) vite: 7.3.5(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.48.0)(yaml@2.9.0) + vue-tsc@3.3.9(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.9 + typescript: 5.9.3 + vue@3.5.28(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.28 @@ -13464,3 +13328,7 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f39ea067..1099560e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,5 +4,6 @@ patchedDependencies: allowBuilds: '@parcel/watcher': true esbuild: true + maplibre-gl: true msw: true vue-demi: true diff --git a/prod.Dockerfile b/prod.Dockerfile index 81c77a50..5b725e81 100644 --- a/prod.Dockerfile +++ b/prod.Dockerfile @@ -7,19 +7,16 @@ RUN corepack enable WORKDIR /app -# pnpm-workspace.yaml carries patchedDependencies and allowBuilds (pnpm v11+); -# without it, patches are silently skipped and build scripts are not run. +# for patchedDependencies COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./ COPY patches /app/patches -# Remove once corepack bug fixed https://github.com/nodejs/corepack/issues/612#issuecomment-2629613697 -ENV COREPACK_INTEGRITY_KEYS=0 - RUN pnpm install --frozen-lockfile COPY . . -RUN pnpm build +# always build from scratch +RUN rm -rf .nuxt && pnpm build FROM node:24-alpine AS production RUN apk add --no-cache curl diff --git a/server/routes/flame/api/auth/[...].ts b/server/routes/flame/api/auth/[...].ts index 76d698d4..fdae7abc 100644 --- a/server/routes/flame/api/auth/[...].ts +++ b/server/routes/flame/api/auth/[...].ts @@ -5,6 +5,7 @@ import OktaProvider from "next-auth/providers/okta"; import OneLoginProvider from "next-auth/providers/onelogin"; import ZitadelProvider from "next-auth/providers/zitadel"; import type { Account, Session, User } from "next-auth"; +import type { Provider } from "next-auth/providers/index"; import type { JWT } from "next-auth/jwt"; import { NuxtAuthHandler } from "#auth"; @@ -41,7 +42,7 @@ function buildProvider() { const clientIssuer = process.env.NUXT_PUBLIC_IDP_ISSUER ?? "http://localhost:8080/realms/flame"; - const providers = []; + const providers: Provider[] = []; switch (idpProvider) { case "keycloak": { @@ -95,7 +96,7 @@ function buildProvider() { }; }, }; - providers.push(hubProvider); + providers.push(hubProvider as Provider); break; } @@ -182,7 +183,7 @@ async function refreshAccessToken(token: JWT) { } export default NuxtAuthHandler({ - secret: useRuntimeConfig().authSecret, + secret: useRuntimeConfig().authSecret as string | undefined, events: { async signIn({ account }: { account: Account | null }) { // After successful sign in @@ -220,8 +221,8 @@ export default NuxtAuthHandler({ async session({ session, token }: { session: Session; token: JWT }) { return { ...session, - accessToken: token.access_token, - expiresAt: token.expires_at, + accessToken: token.access_token as string | undefined, + expiresAt: token.expires_at as number | undefined, }; }, /* on JWT token creation or mutation */ diff --git a/test/components/data-stores/DataStoreList.spec.ts b/test/components/data-stores/DataStoreList.spec.ts index 578d2fea..aec9cb76 100644 --- a/test/components/data-stores/DataStoreList.spec.ts +++ b/test/components/data-stores/DataStoreList.spec.ts @@ -8,7 +8,6 @@ import { fakeDataStoreResp, fakeProjectResp } from "./constants"; vi.mock("~/composables/useAPIFetch", () => ({ getProjects: vi.fn(), - getAnalyses: vi.fn(), getDataStores: vi.fn(), })); @@ -31,7 +30,7 @@ describe("DataStoreList.vue", () => { datastoreData: ListServices | undefined, projectData: Project[] | undefined, ) { - vi.mocked(getDataStores).mockResolvedValue({ + vi.mocked(getDataStores).mockReturnValue({ data: ref(datastoreData), pending: ref(false), error: ref(undefined), @@ -41,7 +40,7 @@ describe("DataStoreList.vue", () => { clear: vi.fn(), }); - vi.mocked(getProjects).mockResolvedValue({ + vi.mocked(getProjects).mockReturnValue({ data: ref(projectData), pending: ref(false), error: ref(undefined), diff --git a/test/components/data-stores/DetailedDataStoreTable.spec.ts b/test/components/data-stores/DetailedDataStoreTable.spec.ts new file mode 100644 index 00000000..81b02857 --- /dev/null +++ b/test/components/data-stores/DetailedDataStoreTable.spec.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount } from "@vue/test-utils"; +import { useConfirm } from "primevue/useconfirm"; +import { useToast } from "primevue/usetoast"; +import DetailedDataStoreTable from "~/components/data-stores/DetailedDataStoreTable.vue"; +import type { ModifiedDetailedService } from "~/services/modifiedApiInterfaces"; +import { fakeDataStoreResp, validProjectId } from "./constants"; + +const mockDelete = vi.fn(); + +vi.mock("~/composables/useAPIFetch", () => ({ + deleteDataStore: (...args: unknown[]) => mockDelete(...args), +})); + +const stores = fakeDataStoreResp.data as ModifiedDetailedService[]; +const store = stores[0]!; + +describe("DetailedDataStoreTable.vue", () => { + let toastAdd: ReturnType; + let confirmOptions: { accept?: () => void }; + + beforeEach(() => { + mockDelete.mockReset(); + mockDelete.mockResolvedValue(undefined); + + toastAdd = vi.fn(); + vi.mocked(useToast).mockReturnValue({ add: toastAdd }); + + confirmOptions = {}; + vi.mocked(useConfirm).mockReturnValue({ + require: (options: { accept?: () => void }) => { + confirmOptions = options; + }, + }); + }); + + function mountTable() { + return mount(DetailedDataStoreTable, { + props: { + stores, + projectNameMap: new Map([[validProjectId, "A project"]]), + loading: false, + }, + }); + } + + async function confirmDelete() { + const wrapper = mountTable(); + + await wrapper.get("button[aria-label='Delete']").trigger("click"); + confirmOptions.accept?.(); + await flushPromises(); + + return wrapper; + } + + it("deletes the chosen store, cascading to its project links", async () => { + await confirmDelete(); + + expect(mockDelete).toHaveBeenCalledWith(store.id, true); + }); + + it("does not delete anything until the confirmation is accepted", async () => { + const wrapper = mountTable(); + + await wrapper.get("button[aria-label='Delete']").trigger("click"); + await flushPromises(); + + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it("tells the list to drop the row once the store is gone", async () => { + const wrapper = await confirmDelete(); + + expect(wrapper.emitted("deleteDataStore")).toEqual([[store.id]]); + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: "success" }), + ); + }); + + it("keeps the row when the delete fails, instead of reporting success", async () => { + mockDelete.mockRejectedValue(new Error("kong is down")); + + const wrapper = await confirmDelete(); + + // Emitting here would take the store off the list while it still exists in Kong. + expect(wrapper.emitted("deleteDataStore")).toBeUndefined(); + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ severity: "error" }), + ); + }); + + it("stops showing the row as busy after a failed delete", async () => { + mockDelete.mockRejectedValue(new Error("kong is down")); + + const wrapper = await confirmDelete(); + + // A spinner left running would make the row impossible to retry. + expect( + wrapper.get("button[aria-label='Delete']").attributes("disabled"), + ).toBeUndefined(); + }); +}); diff --git a/test/components/data-stores/create/DataStoreProjectInitializer.spec.ts b/test/components/data-stores/create/DataStoreProjectInitializer.spec.ts index e71c9961..edaafe96 100644 --- a/test/components/data-stores/create/DataStoreProjectInitializer.spec.ts +++ b/test/components/data-stores/create/DataStoreProjectInitializer.spec.ts @@ -290,10 +290,8 @@ describe("DataStoreProjectInitializer.vue", () => { const vm = localWrapper.findComponent(DataStoreProjectInitializer).vm; expect(vm.selectedProject?.id).toBe(target.id); - // The generated data store name is derived from the selected project name - // plus a random adjective-noun suffix expect(vm.dataStoreName).toMatch( - new RegExp(`^${target.name}-[a-z]+-[a-z]+-[0-9a-f]+$`), + new RegExp(`^${target.name}-[a-z]+-[a-z]+-[0-9a-f]{4}$`), ); localWrapper.unmount(); @@ -396,7 +394,9 @@ describe("DataStoreProjectInitializer.vue", () => { innerVm().selectedProject = project; await innerVm().$nextTick(); - const namePattern = new RegExp(`^${project.name}-[a-z]+-[a-z]+-[0-9a-f]+$`); + const namePattern = new RegExp( + `^${project.name}-[a-z]+-[a-z]+-[0-9a-f]{4}$`, + ); const firstName = innerVm().dataStoreName; expect(firstName).toMatch(namePattern); expect( diff --git a/test/components/header/MenuHeader.spec.ts b/test/components/header/MenuHeader.spec.ts index 2b104005..6c2a9024 100644 --- a/test/components/header/MenuHeader.spec.ts +++ b/test/components/header/MenuHeader.spec.ts @@ -1,10 +1,17 @@ import { flushPromises, mount } from "@vue/test-utils"; import { useRuntimeConfig } from "nuxt/app"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import Menubar from "primevue/menubar"; import MenuHeader from "~/components/header/MenuHeader.vue"; import { type DefineComponent, defineComponent, ref } from "vue"; import { useAuthState } from "@/test/mockapi/nuxt-auth-mock"; +/** One row of `allLinks`; only the fields the nav assertions care about. */ +interface MenuLink { + label: string; + route?: string; +} + describe("MenuHeader.vue", () => { vi.mocked(useRuntimeConfig); @@ -24,8 +31,9 @@ describe("MenuHeader.vue", () => { "Home", "Projects", "Analyses", - "Events", "Data Stores", + "Events", + "Uptime", ]; vi.mocked(useAuthState).mockReturnValue({ @@ -63,4 +71,31 @@ describe("MenuHeader.vue", () => { it("Authenticated menu header", async () => { await menuHeaderChecks(true); }); + + it("points the Uptime tab at the uptime page", async () => { + vi.mocked(useAuthState).mockReturnValue({ + status: ref("authenticated"), + data: ref(null), + }); + + const wrapper = mount(MenuHeaderTestComponent, { + global: { + stubs: { + AvatarButton: true, + DarkModeToggle: true, + PreferencesDialog: true, + }, + }, + }); + await flushPromises(); + + // Read the route off the model rather than the rendered `href`: the custom `#item` + // slot renders a `router-link`, which the test router resolves to "/" for any route + // this suite has not registered - so a typo'd path would still render plausibly. + const model = wrapper.findComponent(Menubar).props("model") as MenuLink[]; + + expect(model.find((item) => item.label === "Uptime")?.route).toBe( + "/uptime", + ); + }); }); diff --git a/test/components/uptime/BucketDrilldownDialog.spec.ts b/test/components/uptime/BucketDrilldownDialog.spec.ts new file mode 100644 index 00000000..5f94543b --- /dev/null +++ b/test/components/uptime/BucketDrilldownDialog.spec.ts @@ -0,0 +1,455 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount } from "@vue/test-utils"; +import BucketDrilldownDialog from "~/components/uptime/BucketDrilldownDialog.vue"; +import { buildSlots, type UptimeSlot } from "~/composables/useServiceHealth"; +import { formatClockTime, SLOW_LATENCY_MS } from "~/utils/uptime-state"; +import { ServiceCheckStatus, type ServiceHealthPoint } from "~/services/Api"; + +// Mocking the module replaces it wholesale, so every other export becomes undefined +// inside this file. That is fine while the component imports only this one function - +// and it is why the component imports it explicitly: a call reaching the composable +// through Nuxt's auto-import would not be intercepted by a mock keyed to this path. +const mockFetch = vi.fn(); + +vi.mock("~/composables/useAPIFetch", () => ({ + getServiceHealthHistory: (...args: unknown[]) => mockFetch(...args), +})); + +/** The subset of the query the dialog is responsible for building. */ +interface HistoryQuery { + start_date?: string; + end_date?: string; + service?: string[]; + include_checks?: boolean; + resolution?: number; +} + +// Three five-minute slices, the same shape the track hands over. +const slots = buildSlots( + new Date("2026-07-30T12:00:00Z"), + new Date("2026-07-30T12:15:00Z"), + 300, +); + +function check( + overrides: Partial = {}, +): ServiceHealthPoint { + return { + checked_at: "2026-07-30T12:01:00Z", + status: ServiceCheckStatus.OK, + status_code: 200, + latency_ms: 42, + message: null, + sweep_id: null, + ...overrides, + }; +} + +/** The adapter's shape: checks live under the service key that was asked for. */ +function respondWith(checks: ServiceHealthPoint[], service = "kong") { + mockFetch.mockResolvedValue({ services: { [service]: { checks } } }); +} + +function mountDialog(props: Record = {}) { + return mount(BucketDrilldownDialog, { + props: { + visible: true, + service: "kong", + serviceTitle: "Kong Gateway API", + slotRange: slots[0]!, + slots, + ...props, + }, + // Not a component stub: PrimeVue's Dialog teleports its body to document.body, so + // its content would never appear in the wrapper. Stubbing Vue's own Teleport keeps + // the REAL Dialog, DataTable and Buttons mounted and renders them in place. + global: { stubs: { teleport: true } }, + }); +} + +type DialogWrapper = ReturnType; + +async function mountOpen(props: Record = {}) { + const wrapper = mountDialog(props); + await flushPromises(); + + return wrapper; +} + +function lastQuery(): HistoryQuery { + const calls = mockFetch.mock.calls; + + return calls[calls.length - 1]![0] as HistoryQuery; +} + +/** + * Every rendered probe row, as its cell texts. DataTable renders its `#empty` template + * as a row of its own, which is not a probe and must not be counted as one. + */ +function rows(wrapper: DialogWrapper): string[][] { + return wrapper + .findAll("tbody tr:not(.p-datatable-empty-message)") + .map((row) => row.findAll("td").map((cell) => cell.text())); +} + +function stepped(wrapper: DialogWrapper): UptimeSlot[] { + const emitted = wrapper.emitted("update:slotRange") as + | [UptimeSlot][] + | undefined; + + return (emitted ?? []).map(([slot]) => slot); +} + +/** Emulates the parent half of `v-model:slotRange`, which VTU does not wire up. */ +async function applyStep(wrapper: DialogWrapper) { + const all = stepped(wrapper); + + await wrapper.setProps({ slotRange: all[all.length - 1] }); + await flushPromises(); +} + +describe("BucketDrilldownDialog.vue", () => { + beforeEach(() => { + mockFetch.mockReset(); + respondWith([]); + }); + + describe("fetching", () => { + it("requests raw checks for only the clicked service and slice", async () => { + await mountOpen(); + + const query = lastQuery(); + expect(query.service).toEqual(["kong"]); + expect(query.include_checks).toBe(true); + // A resolution would return aggregates; this dialog exists to show the probes + // the aggregate was computed from. + expect(query.resolution).toBeUndefined(); + expect(query.start_date).toBe(slots[0]!.start.toISOString()); + expect(query.end_date).toBe(slots[0]!.end.toISOString()); + }); + + it("does not fetch while hidden", async () => { + await mountOpen({ visible: false }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("fetches when it is opened, not when it is built", async () => { + const wrapper = await mountOpen({ visible: false }); + + await wrapper.setProps({ visible: true }); + await flushPromises(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("empties the table when a slice fails, rather than keeping the last one's probes", async () => { + respondWith([check({ message: "first window" })]); + const wrapper = await mountOpen({ slotRange: slots[0]! }); + expect(wrapper.text()).toContain("first window"); + + mockFetch.mockRejectedValue(new Error("gateway is down")); + await wrapper.setProps({ slotRange: slots[1]! }); + await flushPromises(); + + // Leaving the previous rows up would attribute one slice's probes to another. + expect(wrapper.text()).not.toContain("first window"); + expect(wrapper.text()).toContain("No checks recorded in this window"); + }); + + it("does not fetch without a slice to fetch", async () => { + await mountOpen({ slotRange: null }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("does not fetch without a service to fetch it for", async () => { + await mountOpen({ service: null }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe("the probe list", () => { + it("shows the message the hub adapter returned for a failing check", async () => { + respondWith([ + check({ + status: ServiceCheckStatus.ERROR, + status_code: 503, + latency_ms: 1204, + message: "Connection refused", + }), + ]); + + const wrapper = await mountOpen(); + + // The whole point of the drill-down: a cell says something failed, this says why. + expect(wrapper.text()).toContain("Connection refused"); + }); + + it("marks a check slower than the threshold, the only place a single slow probe is named", async () => { + respondWith([check({ latency_ms: SLOW_LATENCY_MS + 1 })]); + + const wrapper = await mountOpen(); + + // The track colours a whole slice by its worst check, so an individual slow probe + // is invisible until here. Asserted against the shared constant rather than a + // literal so the table and the track can never disagree about "slow". + expect(wrapper.find(".bucket-drilldown-slow").exists()).toBe(true); + }); + + it("leaves a check at the threshold unmarked, so 'slow' means strictly over it", async () => { + respondWith([check({ latency_ms: SLOW_LATENCY_MS })]); + + const wrapper = await mountOpen(); + + expect(wrapper.find(".bucket-drilldown-slow").exists()).toBe(false); + }); + + it("does not mark a check whose latency the adapter did not record", async () => { + respondWith([check({ latency_ms: null })]); + + const wrapper = await mountOpen(); + + // A missing latency is unknown, not fast and not slow. + expect(wrapper.find(".bucket-drilldown-slow").exists()).toBe(false); + + // And it reads as a hyphen rather than a blank cell, matching how the card + // renders an unknown uptime - a blank would look like a rendering fault. + expect(rows(wrapper)[0]).toContain("-"); + }); + + it("shows the status code and latency of each probe", async () => { + respondWith([ + check({ + status: ServiceCheckStatus.ERROR, + status_code: 503, + latency_ms: 1204, + message: "Connection refused", + }), + ]); + + const wrapper = await mountOpen(); + const [row] = rows(wrapper); + + expect(row).toContain("503"); + expect(row).toContain("1204"); + }); + + it("marks a failed probe differently from a successful one", async () => { + respondWith([ + check({ checked_at: "2026-07-30T12:01:00Z" }), + check({ + checked_at: "2026-07-30T12:01:30Z", + status: ServiceCheckStatus.ERROR, + message: "Connection refused", + }), + ]); + + const wrapper = await mountOpen(); + const tags = wrapper.findAll(".p-tag"); + + expect(tags).toHaveLength(2); + expect(tags[0]!.classes()).toContain("p-tag-success"); + expect(tags[1]!.classes()).toContain("p-tag-danger"); + }); + + it("distinguishes probes made in the same minute", async () => { + respondWith([ + check({ checked_at: "2026-07-30T12:01:00Z" }), + check({ checked_at: "2026-07-30T12:01:30Z" }), + ]); + + const wrapper = await mountOpen(); + const [first, second] = rows(wrapper); + + // The load-bearing one: the node probes every 30s at the finest resolution, so a + // format without seconds renders two distinct probes as identical rows. + expect(first![0]).not.toBe(second![0]); + // Pins the chosen format. Compared against the same formatter rather than a + // literal, so the suite does not depend on the runner's locale or timezone. + expect(first![0]).toBe( + new Date("2026-07-30T12:01:00Z").toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "medium", + }), + ); + }); + + it("says a slice recorded nothing rather than showing an empty table", async () => { + respondWith([]); + + const wrapper = await mountOpen(); + + // Stepping deliberately lands on empty slices, so this is a state the reader + // reaches often - an empty table alone reads as a failure to load. + expect(rows(wrapper)).toHaveLength(0); + expect(wrapper.text()).toContain("No checks recorded in this window"); + }); + + it("names the service it is showing", async () => { + const wrapper = await mountOpen(); + + expect(wrapper.text()).toContain("Kong Gateway API"); + }); + + it("names the slice it is showing, and its place in the range", async () => { + const wrapper = await mountOpen({ slotRange: slots[1]! }); + const window = wrapper.get("[data-testid='uptime-drilldown-window']"); + + expect(window.text()).toContain( + `${formatClockTime(slots[1]!.start)} - ${formatClockTime(slots[1]!.end)}`, + ); + expect(window.text()).toContain("2 of 3"); + expect(window.attributes("aria-live")).toBe("polite"); + }); + + it("asks to be closed when the dialog is dismissed", async () => { + const wrapper = await mountOpen(); + + await wrapper.get(".p-dialog-close-button").trigger("click"); + + expect(wrapper.emitted("update:visible")).toEqual([[false]]); + }); + }); + + describe("stepping between slices", () => { + it("steps forward onto the adjacent slice", async () => { + const wrapper = await mountOpen({ slotRange: slots[0]! }); + + await wrapper + .get("[data-testid='uptime-drilldown-next']") + .trigger("click"); + + expect(stepped(wrapper)).toEqual([slots[1]]); + }); + + it("steps back onto the adjacent slice", async () => { + const wrapper = await mountOpen({ slotRange: slots[1]! }); + + await wrapper + .get("[data-testid='uptime-drilldown-prev']") + .trigger("click"); + + expect(stepped(wrapper)).toEqual([slots[0]]); + }); + + it("cannot step back from the first slice", async () => { + const wrapper = await mountOpen({ slotRange: slots[0]! }); + + expect( + wrapper + .get("[data-testid='uptime-drilldown-prev']") + .attributes("disabled"), + ).toBeDefined(); + expect( + wrapper + .get("[data-testid='uptime-drilldown-next']") + .attributes("disabled"), + ).toBeUndefined(); + }); + + it("cannot step forward from the last slice", async () => { + const wrapper = await mountOpen({ slotRange: slots[slots.length - 1]! }); + + expect( + wrapper + .get("[data-testid='uptime-drilldown-next']") + .attributes("disabled"), + ).toBeDefined(); + expect( + wrapper + .get("[data-testid='uptime-drilldown-prev']") + .attributes("disabled"), + ).toBeUndefined(); + }); + + it("offers no stepping for a slice that is not in the range", async () => { + const wrapper = await mountOpen({ + slotRange: { + start: new Date("2026-07-30T20:00:00Z"), + end: new Date("2026-07-30T20:05:00Z"), + }, + }); + + for (const control of ["prev", "next"]) { + expect( + wrapper + .get(`[data-testid='uptime-drilldown-${control}']`) + .attributes("disabled"), + ).toBeDefined(); + } + }); + + it("finds its place by timestamp, not by object identity", async () => { + // The slot objects are rebuilt on every range change, so the slice the page is + // holding is never the same object as the one in the list. + const rebuilt: UptimeSlot = { + start: new Date(slots[1]!.start.getTime()), + end: new Date(slots[1]!.end.getTime()), + }; + const wrapper = await mountOpen({ slotRange: rebuilt }); + + await wrapper + .get("[data-testid='uptime-drilldown-next']") + .trigger("click"); + + expect(stepped(wrapper)).toEqual([slots[2]]); + }); + + it("refetches exactly once for the window it stepped onto", async () => { + const wrapper = await mountOpen({ slotRange: slots[0]! }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await wrapper + .get("[data-testid='uptime-drilldown-next']") + .trigger("click"); + await applyStep(wrapper); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(lastQuery().start_date).toBe(slots[1]!.start.toISOString()); + expect(lastQuery().end_date).toBe(slots[1]!.end.toISOString()); + }); + + it("steps onto a slice that recorded nothing instead of skipping it", async () => { + respondWith([]); + const wrapper = await mountOpen({ slotRange: slots[0]! }); + + await wrapper + .get("[data-testid='uptime-drilldown-next']") + .trigger("click"); + await applyStep(wrapper); + + // Skipping empty slices would make the dialog disagree with the track a sighted + // reader is looking at; an empty slice has an answer of its own. + expect(stepped(wrapper)).toEqual([slots[1]]); + expect(wrapper.text()).toContain("No checks recorded in this window"); + }); + + it("ignores a response for a slice the reader has already stepped off", async () => { + const pending: ((value: unknown) => void)[] = []; + mockFetch.mockImplementation( + () => new Promise((resolve) => pending.push(resolve)), + ); + + const wrapper = mountDialog({ slotRange: slots[0]! }); + await flushPromises(); + + await wrapper.setProps({ slotRange: slots[1]! }); + await flushPromises(); + + const body = (message: string) => ({ + services: { kong: { checks: [check({ message })] } }, + }); + + // The second window answers first, the abandoned one afterwards. + pending[1]!(body("second window")); + await flushPromises(); + pending[0]!(body("first window")); + await flushPromises(); + + expect(wrapper.text()).toContain("second window"); + expect(wrapper.text()).not.toContain("first window"); + }); + }); +}); diff --git a/test/components/uptime/ServiceUptimeCard.spec.ts b/test/components/uptime/ServiceUptimeCard.spec.ts new file mode 100644 index 00000000..0c570557 --- /dev/null +++ b/test/components/uptime/ServiceUptimeCard.spec.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import ServiceUptimeCard from "~/components/uptime/ServiceUptimeCard.vue"; +import { buildSlots } from "~/composables/useServiceHealth"; +import { fakeServiceHealthHistory } from "./constants"; +import type { ServiceHealthSummary } from "~/services/Api"; + +const kong = fakeServiceHealthHistory.services.kong!; + +// Two 30-minute slots across the fixture's range. Both of kong's buckets fall inside +// the first, so this exercises the "some slots have no bucket" alignment. +const slots = buildSlots( + new Date("2026-07-30T11:00:00Z"), + new Date("2026-07-30T12:00:00Z"), + 1800, +); + +// `buckets` is optional and the adapter only populates it when a resolution was +// requested, so absent - not present-and-empty - is the shape of a real un-bucketed +// response, and the only one that exercises the card's `?? []`. +const bucketsAbsent: ServiceHealthSummary = { ...kong }; +delete bucketsAbsent.buckets; + +const UptimeTrackStub = { + name: "UptimeTrack", + props: ["slots", "buckets", "disabled", "label"], + template: "
", +}; + +function mountCard( + name: string, + summary: ServiceHealthSummary = fakeServiceHealthHistory.services[name]!, + slotList = slots, +) { + return mount(ServiceUptimeCard, { + props: { name, summary, slots: slotList }, + global: { stubs: { UptimeTrack: UptimeTrackStub } }, + }); +} + +describe("ServiceUptimeCard.vue", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("names the service with a heading so the cards can be navigated", () => { + expect(mountCard("kong").get("h3").text()).toBe("kong"); + }); + + it("shows the uptime percentage for a monitored service", () => { + expect(mountCard("kong").get(".service-uptime-card-uptime").text()).toBe( + "98.33%", + ); + }); + + it("rounds an uptime percentage the adapter did not round for us", () => { + const wrapper = mountCard("kong", { + ...kong, + uptime_percentage: 99.987654321, + }); + + expect(wrapper.get(".service-uptime-card-uptime").text()).toBe("99.99%"); + }); + + it("shows a disabled label for an unconfigured service", () => { + expect(mountCard("fhir").text().toLowerCase()).toContain("disabled"); + }); + + it("explains why a disabled service is not monitored", () => { + expect(mountCard("fhir").get(".service-uptime-card-detail").text()).toBe( + "No URL configured for this service on this node", + ); + }); + + it("leaves out the caption entirely when a disabled service gives no reason", () => { + const wrapper = mountCard("fhir", { + ...fakeServiceHealthHistory.services.fhir!, + detail: null, + }); + + expect(wrapper.find(".service-uptime-card-detail").exists()).toBe(false); + }); + + it("passes disabled through to the track", () => { + const track = mountCard("fhir").findComponent(UptimeTrackStub); + expect(track.props("disabled")).toBe(true); + }); + + it("names the service on the track, whose aria-label is otherwise identical for every card", () => { + const track = mountCard("kong").findComponent(UptimeTrackStub); + expect(track.props("label")).toBe("kong"); + }); + + it("does not mark a configured service as disabled", () => { + const track = mountCard("kong").findComponent(UptimeTrackStub); + expect(track.props("disabled")).toBe(false); + }); + + it("aligns the summary's buckets onto the slot list before handing them over", () => { + const track = mountCard("kong").findComponent(UptimeTrackStub); + + // One entry per slot, not one per returned bucket: the second slot recorded nothing. + expect(track.props("buckets")).toEqual([kong.buckets![0], null]); + }); + + it("survives a summary whose buckets are present but empty", () => { + const track = mountCard("fhir").findComponent(UptimeTrackStub); + + expect(track.props("buckets")).toEqual([null, null]); + }); + + it("survives a summary that carries no buckets key at all", () => { + const track = mountCard("kong", bucketsAbsent).findComponent( + UptimeTrackStub, + ); + + expect(track.props("buckets")).toEqual([null, null]); + }); + + it("renders an em dash rather than 0% when a monitored service recorded nothing", () => { + const summary: ServiceHealthSummary = { + ...kong, + total_checks: 0, + successful_checks: 0, + failed_checks: 0, + uptime_percentage: null, + last_checked_at: null, + buckets: [], + }; + const wrapper = mountCard("kong", summary); + + expect(wrapper.get(".service-uptime-card-uptime").text()).toBe("-"); + expect(wrapper.text()).not.toContain("0%"); + }); + + it("shows only the clock time for a check made today", () => { + // Pinned: the test and the render each read the wall clock, and crossing local + // midnight between the two reads would flip the branch under test. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-30T12:00:00Z")); + + const checkedAt = new Date().toISOString(); + const wrapper = mountCard("kong", { ...kong, last_checked_at: checkedAt }); + + expect(wrapper.get(".service-uptime-card-last-checked").text()).toBe( + `Last checked ${new Date(checkedAt).toLocaleTimeString()}`, + ); + }); + + it("dates a check that was not made today, so it cannot read as minutes old", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-30T12:00:00Z")); + + const checkedAt = new Date( + Date.now() - 3 * 24 * 60 * 60 * 1000, + ).toISOString(); + const wrapper = mountCard("kong", { ...kong, last_checked_at: checkedAt }); + + expect(wrapper.get(".service-uptime-card-last-checked").text()).toBe( + `Last checked ${new Date(checkedAt).toLocaleString()}`, + ); + }); + + it("writes the range out once beneath the track", () => { + const wrapper = mountCard("kong"); + const bounds = wrapper + .get(".service-uptime-card-axis") + .findAll("span") + .map((span) => span.text()); + + // Compared against `toLocale*` rather than a literal, so the suite does not depend + // on the runner's locale or timezone. + expect(bounds).toEqual([ + slots[0]!.start.toLocaleString(), + slots[slots.length - 1]!.end.toLocaleString(), + ]); + }); + + it("captions a disabled service with the reason instead of a range", () => { + expect(mountCard("fhir").find(".service-uptime-card-axis").exists()).toBe( + false, + ); + }); + + it("does not caption a monitored service with a disabled service's detail", () => { + expect(mountCard("kong").find(".service-uptime-card-detail").exists()).toBe( + false, + ); + }); + + it("re-emits cell clicks with the service name attached", async () => { + const wrapper = mountCard("kong"); + const slot = slots[0]!; + + wrapper + .findComponent(UptimeTrackStub) + .vm.$emit("cellClick", { slot, bucket: null }); + await wrapper.vm.$nextTick(); + + const emitted = wrapper.emitted("cellClick") as [{ service: string }][]; + expect(emitted[0]![0]).toEqual({ service: "kong", slot, bucket: null }); + }); + + it("names itself even when the track's payload claims a different service", async () => { + const wrapper = mountCard("kong"); + const slot = slots[0]!; + + // The card is the authority on which service it is: whatever the child sends, the + // name the card was mounted with has to win. + wrapper + .findComponent(UptimeTrackStub) + .vm.$emit("cellClick", { slot, bucket: null, service: "fhir" }); + await wrapper.vm.$nextTick(); + + const emitted = wrapper.emitted("cellClick") as [{ service: string }][]; + expect(emitted[0]![0]!.service).toBe("kong"); + }); +}); diff --git a/test/components/uptime/UptimeToolbar.spec.ts b/test/components/uptime/UptimeToolbar.spec.ts new file mode 100644 index 00000000..9f6c35f9 --- /dev/null +++ b/test/components/uptime/UptimeToolbar.spec.ts @@ -0,0 +1,478 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount } from "@vue/test-utils"; +import UptimeToolbar from "~/components/uptime/UptimeToolbar.vue"; +import { MAX_SPAN_MS, SPAN_PRESETS } from "~/composables/useServiceHealth"; + +const FAKE_NOW = new Date("2026-07-30T12:00:00.000Z"); +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +interface RangeEvent { + start: Date; + end: Date; + live: boolean; +} + +function mountToolbar(props: Record = {}) { + return mount(UptimeToolbar, { + props: { loading: false, intervalSeconds: 60, ...props }, + }); +} + +type Toolbar = ReturnType; + +/** Every range the toolbar has asked for so far, oldest first. */ +function ranges(wrapper: Toolbar): RangeEvent[] { + const emitted = wrapper.emitted("rangeChange") as [RangeEvent][] | undefined; + + return (emitted ?? []).map(([payload]) => payload); +} + +function latestRange(wrapper: Toolbar): RangeEvent { + const all = ranges(wrapper); + + return all[all.length - 1]!; +} + +/** + * The preset options are rendered by SelectButton, which gives each one a plain + * `