--ctp-overlay1: #8c8fa1;
--ctp-blue: #1e66f5;
--ctp-lavender: #7287fd;
+ --ctp-green: #2e7d32;
+ --ctp-orange: #ef6c00;
+ --ctp-red: #c62828;
/* Semantic */
--color-bg: var(--ctp-base);
--color-bg-sunken: var(--ctp-crust);
/* State colors (Material 700 for light mode readability) */
- --color-state-ok: #2e7d32;
- --color-state-warn: #ef6c00;
- --color-state-err: #c62828;
+ --color-state-ok: var(--ctp-green);
+ --color-state-warn: var(--ctp-orange);
+ --color-state-err: var(--ctp-red);
--color-state-idle: var(--ctp-overlay1);
color-scheme: light;
--sap-button-border: #bcc3ca;
--sap-button-text: #0064d9;
--sap-button-emphasized-bg: #0070f2;
+ --sap-positive: #256f3a;
+ --sap-critical: #e76500;
+ --sap-negative: #aa0808;
--sap-white: #fff;
/* Semantic */
--color-bg-sunken: var(--sap-hover);
/* State colors (SAP Horizon palette) */
- --color-state-ok: #256f3a;
- --color-state-warn: #e76500;
- --color-state-err: #aa0808;
+ --color-state-ok: var(--sap-positive);
+ --color-state-warn: var(--sap-critical);
+ --color-state-err: var(--sap-negative);
--color-state-idle: var(--sap-label);
color-scheme: light;
--td-fg-muted: #78909c;
--td-teal: #26a69a;
--td-teal-dim: #00897b;
+ --td-green: #66bb6a;
+ --td-amber: #ffb300;
+ --td-red: #ef5350;
--td-white: #ffffff;
/* Semantic */
--color-bg-sunken: var(--td-bg-dark);
/* State colors */
- --color-state-ok: #66bb6a;
- --color-state-warn: #ffb300;
- --color-state-err: #ef5350;
+ --color-state-ok: var(--td-green);
+ --color-state-warn: var(--td-amber);
+ --color-state-err: var(--td-red);
--color-state-idle: var(--td-fg-muted);
color-scheme: dark;
--tl-fg-muted: #697386;
--tl-teal: #009688;
--tl-teal-dim: #00796b;
+ --tl-green: #2e7d32;
+ --tl-orange: #ef6c00;
+ --tl-red: #c62828;
--tl-white: #ffffff;
/* Semantic */
--color-bg-sunken: var(--tl-bg-active);
/* State colors */
- --color-state-ok: #2e7d32;
- --color-state-warn: #ef6c00;
- --color-state-err: #c62828;
+ --color-state-ok: var(--tl-green);
+ --color-state-warn: var(--tl-orange);
+ --color-state-err: var(--tl-red);
--color-state-idle: var(--tl-fg-muted);
color-scheme: light;
--tn-fg-muted: #8089b3;
--tn-blue: #7aa2f7;
--tn-accent: #3d59a1;
+ --tn-green: #66bb6a;
+ --tn-amber: #ffb300;
+ --tn-red: #ef5350;
--tn-white: #ffffff;
/* Semantic */
--color-bg-sunken: var(--tn-bg-raised);
/* State colors */
- --color-state-ok: #66bb6a;
- --color-state-warn: #ffb300;
- --color-state-err: #ef5350;
+ --color-state-ok: var(--tn-green);
+ --color-state-warn: var(--tn-amber);
+ --color-state-err: var(--tn-red);
--color-state-idle: var(--tn-fg-muted);
color-scheme: dark;
+import { type SKIN_IDS } from 'ui-common'
+
// Local UI project constants
export const ASYNC_COMPONENT_DELAY_MS = 200
+export const DEFAULT_SKIN: (typeof SKIN_IDS)[number] = 'classic'
export const ASYNC_COMPONENT_TIMEOUT_MS = 10_000
export const EMPTY_VALUE_PLACEHOLDER = 'Ø'
export const MAX_SKIN_ERROR_RELOADS = 2
return UIClient.instance
}
- public static isOCPP20x (version: OCPPVersion | undefined): boolean {
+ private static isOCPP20x (version: OCPPVersion | undefined): boolean {
return version === OCPPVersion.VERSION_20 || version === OCPPVersion.VERSION_201
}
export {
ASYNC_COMPONENT_DELAY_MS,
ASYNC_COMPONENT_TIMEOUT_MS,
+ DEFAULT_SKIN,
EMPTY_VALUE_PLACEHOLDER,
LEGACY_UI_SERVER_CONFIG_KEY,
MAX_SKIN_ERROR_RELOADS,
useUIClient,
} from './providers.js'
export {
- deleteFromLocalStorage,
deleteLocalStorageByKeyPattern,
getFromLocalStorage,
getLocalStorage,
import {
chargingStationsKey,
configurationKey,
+ DEFAULT_SKIN,
getFromLocalStorage,
LEGACY_UI_SERVER_CONFIG_KEY,
setToLocalStorage,
import { router } from '@/router/index.js'
import { SKIN_STORAGE_KEY, useSkin } from '@/shared/composables/useSkin.js'
import { DEFAULT_THEME, THEME_STORAGE_KEY, useTheme } from '@/shared/composables/useTheme.js'
-import { DEFAULT_SKIN } from '@/skins/registry.js'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
import { useToast } from 'vue-toast-notification'
-import { ROUTE_NAMES } from '@/core/index.js'
+import { DEFAULT_SKIN, ROUTE_NAMES } from '@/core/index.js'
import { useSkin } from '@/shared/composables/useSkin.js'
-import { DEFAULT_SKIN } from '@/skins/registry.js'
declare module 'vue-router' {
interface RouteMeta {
</template>
<script setup lang="ts">
-import { MAX_SKIN_ERROR_RELOADS, setToLocalStorage } from '@/core/index.js'
+import { DEFAULT_SKIN, MAX_SKIN_ERROR_RELOADS, setToLocalStorage } from '@/core/index.js'
import { SKIN_STORAGE_KEY } from '@/shared/composables/useSkin.js'
-import { DEFAULT_SKIN, skins } from '@/skins/registry.js'
+// Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
+import { skins } from '@/skins/registry.js'
defineEmits<{ retry: [] }>()
gap: 1rem;
min-height: 50vh;
padding: 2rem;
- color: var(--color-text, #e0e0e0);
+ color: var(--color-text);
font-family: system-ui, sans-serif;
}
}
.skin-load-error button:hover {
- background: rgba(255, 255, 255, 0.08);
+ background: var(--color-bg-hover);
}
</style>
gap: 1rem;
min-height: 50vh;
padding: 2rem;
- color: var(--color-text, #e0e0e0);
+ color: var(--color-text);
font-family: system-ui, sans-serif;
}
--- /dev/null
+import type { UIServerConfigurationSection } from 'ui-common'
+import type { Ref } from 'vue'
+
+import { onScopeDispose, readonly, ref } from 'vue'
+
+import {
+ getFromLocalStorage,
+ setToLocalStorage,
+ UI_SERVER_CONFIGURATION_INDEX_KEY,
+ useConfiguration,
+ useUIClient,
+} from '@/core/index.js'
+
+export interface ServerSwitchActions {
+ /** Switches the active UI server, with error rollback on connection failure. */
+ handleUIServerChange: (newIndex: number) => void
+ /** Whether a server switch operation is in progress. */
+ serverSwitchPending: Readonly<Ref<boolean>>
+}
+
+export interface ServerSwitchOptions {
+ /** Called after a successful server switch (e.g. to clear UI state). */
+ onServerSwitched?: () => void
+ /** Registers WS event listeners after connection reconfiguration. */
+ registerWSEventListeners: () => void
+ /** Unregisters WS event listeners before connection reconfiguration. */
+ unregisterWSEventListeners: () => void
+}
+
+const SERVER_SWITCH_TIMEOUT_MS = 15_000
+
+/**
+ * Composable encapsulating the UI server switch state machine.
+ *
+ * Handles optimistic connection switching with timeout-based rollback on failure.
+ * @param options - Callbacks for event listener management and switch completion
+ * @returns Server switch actions and pending state
+ */
+export function useServerSwitch (options: ServerSwitchOptions): ServerSwitchActions {
+ const $uiClient = useUIClient()
+ const $configuration = useConfiguration()
+
+ const { registerWSEventListeners, unregisterWSEventListeners } = options
+
+ const serverSwitchPending = ref(false)
+ let activeTimeoutId: ReturnType<typeof setTimeout> | undefined
+ let pendingOpenHandler: (() => void) | undefined
+ let pendingErrorHandler: (() => void) | undefined
+
+ const handleUIServerChange = (newIndex: number): void => {
+ const currentIndex = getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0)
+ if (newIndex === currentIndex || serverSwitchPending.value) return
+
+ const servers = $configuration.value.uiServer as UIServerConfigurationSection[]
+ if (newIndex < 0 || newIndex >= servers.length) return
+
+ serverSwitchPending.value = true
+
+ $uiClient.setConfiguration(servers[newIndex])
+ unregisterWSEventListeners()
+ registerWSEventListeners()
+
+ let settled = false
+
+ const openHandler = (): void => {
+ if (settled) return
+ settled = true
+ clearTimeout(activeTimeoutId)
+ $uiClient.unregisterWSEventListener('error', errorHandler)
+ pendingOpenHandler = undefined
+ pendingErrorHandler = undefined
+ setToLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, newIndex)
+ serverSwitchPending.value = false
+ options.onServerSwitched?.()
+ }
+
+ const errorHandler = (): void => {
+ if (settled) return
+ settled = true
+ clearTimeout(activeTimeoutId)
+ $uiClient.unregisterWSEventListener('open', openHandler)
+ pendingOpenHandler = undefined
+ pendingErrorHandler = undefined
+ serverSwitchPending.value = false
+ const previousIndex = getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0)
+ const rollbackServers = $configuration.value.uiServer as UIServerConfigurationSection[]
+ if (previousIndex >= 0 && previousIndex < rollbackServers.length) {
+ $uiClient.setConfiguration(rollbackServers[previousIndex])
+ }
+ unregisterWSEventListeners()
+ registerWSEventListeners()
+ }
+
+ $uiClient.registerWSEventListener('open', openHandler, { once: true })
+ $uiClient.registerWSEventListener('error', errorHandler, { once: true })
+ pendingOpenHandler = openHandler
+ pendingErrorHandler = errorHandler
+
+ activeTimeoutId = setTimeout(() => {
+ if (!settled) {
+ errorHandler()
+ }
+ }, SERVER_SWITCH_TIMEOUT_MS)
+ }
+
+ onScopeDispose(() => {
+ if (activeTimeoutId != null) {
+ clearTimeout(activeTimeoutId)
+ activeTimeoutId = undefined
+ }
+ if (pendingOpenHandler != null) {
+ $uiClient.unregisterWSEventListener('open', pendingOpenHandler)
+ pendingOpenHandler = undefined
+ }
+ if (pendingErrorHandler != null) {
+ $uiClient.unregisterWSEventListener('error', pendingErrorHandler)
+ pendingErrorHandler = undefined
+ }
+ })
+
+ return {
+ handleUIServerChange,
+ serverSwitchPending: readonly(serverSwitchPending),
+ }
+}
-import type { UIServerConfigurationSection } from 'ui-common'
import type { ComputedRef, Ref } from 'vue'
-import { computed, onScopeDispose, readonly, ref } from 'vue'
+import { computed } from 'vue'
-import {
- getFromLocalStorage,
- setToLocalStorage,
- UI_SERVER_CONFIGURATION_INDEX_KEY,
- useChargingStations,
- useConfiguration,
- useUIClient,
-} from '@/core/index.js'
+import { useChargingStations, useUIClient } from '@/core/index.js'
import { useAsyncAction } from '@/shared/composables/useAsyncAction.js'
import { type LayoutData } from '@/shared/composables/useLayoutData.js'
+import { useServerSwitch } from '@/shared/composables/useServerSwitch.js'
export interface SimulatorControlActions {
/** Switches the active UI server, with error rollback on connection failure. */
options?: SimulatorControlOptions
): SimulatorControlActions {
const $uiClient = useUIClient()
- const $configuration = useConfiguration()
const $chargingStations = useChargingStations()
const { getSimulatorState, registerWSEventListeners } = layoutData
{ simulator: false },
getSimulatorState
)
- const serverSwitchPending = ref(false)
- let activeTimeoutId: ReturnType<typeof setTimeout> | undefined
- let pendingOpenHandler: (() => void) | undefined
- let pendingErrorHandler: (() => void) | undefined
+
+ const { handleUIServerChange, serverSwitchPending } = useServerSwitch({
+ onServerSwitched: options?.onServerSwitched,
+ registerWSEventListeners,
+ unregisterWSEventListeners,
+ })
const startSimulator = (): void => {
runSimulatorAction('simulator', {
})
}
- const SERVER_SWITCH_TIMEOUT_MS = 15_000
-
- const handleUIServerChange = (newIndex: number): void => {
- const currentIndex = getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0)
- if (newIndex === currentIndex || serverSwitchPending.value) return
-
- const servers = $configuration.value.uiServer as UIServerConfigurationSection[]
- if (newIndex < 0 || newIndex >= servers.length) return
-
- serverSwitchPending.value = true
-
- $uiClient.setConfiguration(servers[newIndex])
- unregisterWSEventListeners()
- registerWSEventListeners()
-
- let settled = false
-
- const openHandler = (): void => {
- if (settled) return
- settled = true
- clearTimeout(activeTimeoutId)
- $uiClient.unregisterWSEventListener('error', errorHandler)
- pendingOpenHandler = undefined
- pendingErrorHandler = undefined
- setToLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, newIndex)
- serverSwitchPending.value = false
- options?.onServerSwitched?.()
- }
-
- const errorHandler = (): void => {
- if (settled) return
- settled = true
- clearTimeout(activeTimeoutId)
- $uiClient.unregisterWSEventListener('open', openHandler)
- pendingOpenHandler = undefined
- pendingErrorHandler = undefined
- serverSwitchPending.value = false
- const previousIndex = getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0)
- const rollbackServers = $configuration.value.uiServer as UIServerConfigurationSection[]
- if (previousIndex >= 0 && previousIndex < rollbackServers.length) {
- $uiClient.setConfiguration(rollbackServers[previousIndex])
- }
- unregisterWSEventListeners()
- registerWSEventListeners()
- }
-
- $uiClient.registerWSEventListener('open', openHandler, { once: true })
- $uiClient.registerWSEventListener('error', errorHandler, { once: true })
- pendingOpenHandler = openHandler
- pendingErrorHandler = errorHandler
-
- activeTimeoutId = setTimeout(() => {
- if (!settled) {
- errorHandler()
- }
- }, SERVER_SWITCH_TIMEOUT_MS)
- }
-
- onScopeDispose(() => {
- if (activeTimeoutId != null) {
- clearTimeout(activeTimeoutId)
- activeTimeoutId = undefined
- }
- if (pendingOpenHandler != null) {
- $uiClient.unregisterWSEventListener('open', pendingOpenHandler)
- pendingOpenHandler = undefined
- }
- if (pendingErrorHandler != null) {
- $uiClient.unregisterWSEventListener('error', pendingErrorHandler)
- pendingErrorHandler = undefined
- }
- })
-
return {
handleUIServerChange,
- serverSwitchPending: readonly(serverSwitchPending),
+ serverSwitchPending,
simulatorPending: computed(() => simulatorPendingState.simulator),
startSimulator,
stopSimulator,
import { type SKIN_IDS } from 'ui-common'
import { readonly, ref, type Ref } from 'vue'
-import { getFromLocalStorage, setToLocalStorage } from '@/core/index.js'
+import { DEFAULT_SKIN, getFromLocalStorage, setToLocalStorage } from '@/core/index.js'
import { validateTokenContract } from '@/shared/tokens/contract.js'
// Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
-import { DEFAULT_SKIN, type SkinDefinition, skins } from '@/skins/registry.js'
+import { type SkinDefinition, skins } from '@/skins/registry.js'
export const SKIN_STORAGE_KEY = 'ecs-ui-skin'
+++ /dev/null
-/** Common station identification fields used across skin components. */
-export interface StationIdentifier {
- chargingStationId: string
- hashId: string
-}
export { getSelectValue } from './dom.js'
-export { formatSupervisionUrl, type FormatSupervisionUrlOptions } from './formatSupervisionUrl.js'
+export { formatSupervisionUrl } from './formatSupervisionUrl.js'
export type { StatusVariant } from './stationStatus.js'
export {
getATGStatus,
border-color: var(--skin-border-strong);
}
+/* Light-theme pill overrides — state colours are already dark (Material 700)
+ * and readable on light backgrounds without white mixing. */
+:root[data-theme='catppuccin-latte'] .modern-pill--ok,
+:root[data-theme='sap-horizon'] .modern-pill--ok,
+:root[data-theme='teal-light'] .modern-pill--ok {
+ color: var(--color-state-ok);
+ background-color: color-mix(in srgb, var(--color-state-ok) 14%, transparent);
+ border-color: color-mix(in srgb, var(--color-state-ok) 35%, transparent);
+}
+
+:root[data-theme='catppuccin-latte'] .modern-pill--warn,
+:root[data-theme='sap-horizon'] .modern-pill--warn,
+:root[data-theme='teal-light'] .modern-pill--warn {
+ color: var(--color-state-warn);
+ background-color: color-mix(in srgb, var(--color-state-warn) 18%, transparent);
+ border-color: color-mix(in srgb, var(--color-state-warn) 50%, transparent);
+}
+
+:root[data-theme='catppuccin-latte'] .modern-pill--err,
+:root[data-theme='sap-horizon'] .modern-pill--err,
+:root[data-theme='teal-light'] .modern-pill--err {
+ color: var(--color-state-err);
+ background-color: color-mix(in srgb, var(--color-state-err) 14%, transparent);
+ border-color: color-mix(in srgb, var(--color-state-err) 35%, transparent);
+}
+
/* ── Buttons — flat Material-style ─────────────────────────────────── */
.modern-btn {
display: inline-flex;
readonly loadStyles: () => Promise<unknown>
}
-export const DEFAULT_SKIN: (typeof SKIN_IDS)[number] = 'classic'
-
export const skins: readonly SkinDefinition[] = [
{
id: 'classic',
import App from '@/App.vue'
vi.mock('@/skins/registry.js', () => ({
- DEFAULT_SKIN: 'classic',
skins: [
{
id: 'classic',
})
})
- describe('isOCPP20x', () => {
- it('should return true for VERSION_20', () => {
- expect(UIClient.isOCPP20x(OCPPVersion.VERSION_20)).toBe(true)
- })
-
- it('should return true for VERSION_201', () => {
- expect(UIClient.isOCPP20x(OCPPVersion.VERSION_201)).toBe(true)
- })
-
- it('should return false for VERSION_16', () => {
- expect(UIClient.isOCPP20x(OCPPVersion.VERSION_16)).toBe(false)
- })
-
- it('should return false for undefined', () => {
- expect(UIClient.isOCPP20x(undefined)).toBe(false)
- })
- })
-
describe('WebSocket connection', () => {
it('should connect with ws:// URL format', () => {
UIClient.getInstance(createUIServerConfig())
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
- deleteFromLocalStorage,
getFromLocalStorage,
getLocalStorage,
resetToggleButtonState,
useConfiguration,
useTemplates,
} from '@/core/index.js'
+import { deleteFromLocalStorage } from '@/core/storage.js'
import { useFetchData } from '@/shared/composables/useFetchData.js'
import { toastMock } from '../setup.js'
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { DEFAULT_SKIN } from '@/core/index.js'
import { useSkin } from '@/shared/composables/useSkin.js'
-import { DEFAULT_SKIN, skins } from '@/skins/registry.js'
+import { skins } from '@/skins/registry.js'
vi.mock('@/skins/registry.js', () => ({
- DEFAULT_SKIN: 'classic',
skins: [
{
description: 'Table-based layout with a sticky sidebar action panel.',
*/
import { describe, expect, it } from 'vitest'
-import { DEFAULT_SKIN, skins } from '@/skins/registry.js'
+import { DEFAULT_SKIN } from '@/core/index.js'
+import { skins } from '@/skins/registry.js'
describe('registry', () => {
it('should export DEFAULT_SKIN as classic', () => {