From deb125bedc755c4d918f5e3cc00d22ab74f08df7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 30 Oct 2026 16:16:09 +0100 Subject: [PATCH] admin: show the TREK API as its three services The card names place search, address search and routing with what each answers and what is left of today's allowance. Each service is a tile with what is left as its figure, the day's use as a bar and used and limit as badges; the state, the kind of installation, a key from the environment and the time the count starts again sit as badges in the header. The sources add the terrain tiles the routes take their heights from. --- .../Admin/ServiceIdentityPanel.test.tsx | 12 +- .../components/Admin/ServiceIdentityPanel.tsx | 142 ++++++++++-------- .../src/components/Admin/trekApiModel.test.ts | 38 ++++- client/src/components/Admin/trekApiModel.ts | 37 ++++- .../screens/admin/MApiProviderBlocks.tsx | 22 ++- client/src/pages/admin/TrekApiCard.tsx | 44 +++++- shared/src/i18n/ar/admin.ts | 17 ++- shared/src/i18n/az/admin.ts | 17 ++- shared/src/i18n/br/admin.ts | 17 ++- shared/src/i18n/ca/admin.ts | 17 ++- shared/src/i18n/cs/admin.ts | 17 ++- shared/src/i18n/de/admin.ts | 17 ++- shared/src/i18n/en/admin.ts | 17 ++- shared/src/i18n/es/admin.ts | 17 ++- shared/src/i18n/et/admin.ts | 17 ++- shared/src/i18n/fr/admin.ts | 17 ++- shared/src/i18n/gr/admin.ts | 17 ++- shared/src/i18n/hu/admin.ts | 17 ++- shared/src/i18n/id/admin.ts | 17 ++- shared/src/i18n/it/admin.ts | 17 ++- shared/src/i18n/ja/admin.ts | 17 ++- shared/src/i18n/ko/admin.ts | 17 ++- shared/src/i18n/nl/admin.ts | 17 ++- shared/src/i18n/pl/admin.ts | 17 ++- shared/src/i18n/ru/admin.ts | 17 ++- shared/src/i18n/sk/admin.ts | 17 ++- shared/src/i18n/sv/admin.ts | 17 ++- shared/src/i18n/th/admin.ts | 17 ++- shared/src/i18n/tr/admin.ts | 17 ++- shared/src/i18n/uk/admin.ts | 17 ++- shared/src/i18n/vi/admin.ts | 17 ++- shared/src/i18n/zh-TW/admin.ts | 17 ++- shared/src/i18n/zh/admin.ts | 17 ++- 33 files changed, 644 insertions(+), 110 deletions(-) diff --git a/client/src/components/Admin/ServiceIdentityPanel.test.tsx b/client/src/components/Admin/ServiceIdentityPanel.test.tsx index 5d2dcea67..1a26425d3 100644 --- a/client/src/components/Admin/ServiceIdentityPanel.test.tsx +++ b/client/src/components/Admin/ServiceIdentityPanel.test.tsx @@ -39,9 +39,13 @@ describe('ServiceIdentityPanel', () => { const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' }); expect(bar).toHaveAttribute('aria-valuenow', '2500'); expect(bar).toHaveAttribute('aria-valuemax', '10000'); - expect(screen.getByText('2,500 / 10,000')).toBeInTheDocument(); + // What is left is the figure of the tile; used and the limit sit beside it as badges. expect(screen.getByText('7,500')).toBeInTheDocument(); - expect(screen.getByText('admin.trekApi.quota.resets')).toBeInTheDocument(); + expect(screen.getByText('2,500')).toBeInTheDocument(); + expect(screen.getByText('10,000')).toBeInTheDocument(); + expect(screen.getByText('admin.trekApi.quota.limit')).toBeInTheDocument(); + // The day starts again at the same time for every service, so it is said once, in the header. + expect(screen.getAllByText('admin.trekApi.quota.resets')).toHaveLength(1); expect(screen.queryByText(/admin\.trekApi\.quota\.hint\./)).toBeNull(); }); @@ -63,7 +67,9 @@ describe('ServiceIdentityPanel', () => { it('FE-ADMIN-SVCID-004: a key from the environment is named, and only changed there', async () => { serve({ ...CONNECTED, keySource: 'env', tier: 'own' }); render(); - expect(await screen.findByText('admin.trekApi.quota.envKey')).toBeInTheDocument(); + // A badge naming the variable; the sentence explaining it is its tooltip. + expect(await screen.findByText('TREK_SERVICES_KEY')).toBeInTheDocument(); + expect(screen.getByText('admin.trekApi.quota.key')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull(); }); diff --git a/client/src/components/Admin/ServiceIdentityPanel.tsx b/client/src/components/Admin/ServiceIdentityPanel.tsx index 3c6d96a8f..00e9fbf30 100644 --- a/client/src/components/Admin/ServiceIdentityPanel.tsx +++ b/client/src/components/Admin/ServiceIdentityPanel.tsx @@ -1,5 +1,5 @@ import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared'; -import { RefreshCw } from 'lucide-react'; +import { MapPin, RefreshCw, Route, Search, type LucideIcon } from 'lucide-react'; import { useRef, useState } from 'react'; import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; @@ -8,6 +8,7 @@ import { getApiErrorMessage } from '../../utils/apiError'; import { formatClockTime } from '../../utils/formatters'; import ConfirmDialog from '../shared/ConfirmDialog'; import { useToast } from '../shared/Toast'; +import { FigureBadge } from '../Tours/paddle/FigureBadge'; import { useServiceIdentity } from './useServiceIdentity'; const STATE_DOT: Record = { @@ -18,78 +19,88 @@ const STATE_DOT: Record = { revoked: 'bg-danger', }; +const SERVICE_ICON: Record = { places: MapPin, photon: Search, valhalla: Route }; + /** Below a tenth of the day left the bar turns amber, at nothing left red. */ function barTone(quota: ServiceQuotaView): string { if (quota.remaining === 0) return 'bg-danger'; return quota.remaining < quota.limit / 10 ? 'bg-warning' : 'bg-accent'; } -function Stat({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); +function figureTone(quota: ServiceQuotaView): string { + if (quota.remaining === 0) return 'text-danger'; + return quota.remaining < quota.limit / 10 ? 'text-warning' : 'text-content'; } -function QuotaMeter({ quota, label }: { quota: ServiceQuotaView; label: string }) { - const { t, locale } = useTranslation(); +/** The clock time the counts start again, in the user's format; one for all services, which share the day. */ +function useResetTime(status: ServiceIdentityStatus | null): string | null { const is12h = useSettingsStore((s) => s.settings.time_format === '12h'); - const number = new Intl.NumberFormat(locale); - const share = quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100; - const reset = new Date(quota.resetAt); - const resetAt = formatClockTime( + const at = status?.services.find((s) => s.quota)?.quota?.resetAt; + if (!at) return null; + const reset = new Date(at); + return formatClockTime( `${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`, is12h ); - - return ( -
-
-
-
-
- - - -
-
- ); } -function rowNote(t: TranslationFn, allowance: ServiceAllowance): string | null { +function tileNote(t: TranslationFn, allowance: ServiceAllowance): string | null { if (!allowance.inUse) return t('admin.trekApi.quota.notInUse'); return allowance.quota ? null : t('admin.trekApi.quota.unavailable'); } /** - * One service under the key: its name, today's allowance where its host told - * it, and whether this instance asks it at all. A service not in use is - * dimmed rather than hidden, so the list reads the same on every install. + * One service under the key, as a tile: what is left today as the figure that + * matters, the day's use as a bar, used and limit as badges. A service not in + * use is dimmed rather than hidden, so the row reads the same on every install. */ -function ServiceRow({ allowance }: { allowance: ServiceAllowance }) { - const { t } = useTranslation(); +function ServiceTile({ allowance }: { allowance: ServiceAllowance }) { + const { t, locale } = useTranslation(); + const number = new Intl.NumberFormat(locale); const name = t(`admin.trekApi.quota.service.${allowance.service}`); - const note = rowNote(t, allowance); + const note = tileNote(t, allowance); + const quota = allowance.quota; + const Icon = SERVICE_ICON[allowance.service] ?? MapPin; + const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100; return ( -
  • -

    {name}

    - {allowance.quota && } +
  • +
    + + +

    {name}

    +
    + + {quota && ( + <> +
    +

    + {number.format(quota.remaining)} +

    +

    {t('admin.trekApi.quota.remaining')}

    +
    +
    +
    +
    +
    + + +
    + + )} {note &&

    {note}

    }
  • ); @@ -113,6 +124,7 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo const panel = useRef(null); const { status, failed, regenerating, regenerate } = useServiceIdentity(panel); const [confirming, setConfirming] = useState(false); + const resetAt = useResetTime(status); const confirmRegenerate = async () => { setConfirming(false); @@ -131,32 +143,38 @@ export default function ServiceIdentityPanel({ managed = false }: { managed?: bo
    -

    +

    - {status?.tier && ( - - {t(`admin.trekApi.quota.tier.${status.tier}`)} - - )} +
    + {status?.tier && } + {status?.keySource === 'env' && ( + + )} + {resetAt && } +
    {status && status.services.length > 0 && ( -
      +
        {status.services.map((allowance) => ( - + ))}
      )} {hint &&

      {hint}

      } - {status?.keySource === 'env' && ( -

      {t('admin.trekApi.quota.envKey')}

      - )} {canRegenerate && (