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 && (