refactor(operator): derive env badge from hostname, not from user choice
A toggle-able env badge is a sticker, not a safety signal. Move env to
useEnv() which reads window.location.hostname:
*.local / localhost → 'dev'
*staging* → 'staging'
everything else → 'prod' (safest default)
- New composable: apps/operator/composables/useEnv.ts
- Topbar reads useEnv() instead of useTweaks().env
- useTweaks loses the env field; hydrate strips it from stale
localStorage payloads so old entries don't break
- TweaksPanel: env section removed (theme + density remain)
- Settings: env section removed from Appearance; added a read-only
Environment row to the Profile card showing the detected env +
hostname source ("auto-detected from operator.dezky.local")
This commit is contained in:
@@ -1,16 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{ oncall?: boolean }>(), { oncall: true })
|
withDefaults(defineProps<{ oncall?: boolean }>(), { oncall: true })
|
||||||
|
|
||||||
const { state: tweaks } = useTweaks()
|
|
||||||
const { open: openPalette } = useCommandPalette()
|
const { open: openPalette } = useCommandPalette()
|
||||||
|
const { env } = useEnv()
|
||||||
|
|
||||||
const ENVS = {
|
const ENVS = {
|
||||||
prod: { label: 'PROD', fg: 'var(--text)', bg: 'rgba(244,243,238,0.08)', border: 'rgba(244,243,238,0.15)' },
|
prod: { label: 'PROD', fg: 'var(--text)', bg: 'rgba(244,243,238,0.08)', border: 'rgba(244,243,238,0.15)' },
|
||||||
staging: { label: 'STAGING', fg: '#FFC872', bg: 'rgba(232,154,31,0.16)', border: 'rgba(232,154,31,0.36)' },
|
staging: { label: 'STAGING', fg: '#FFC872', bg: 'rgba(232,154,31,0.16)', border: 'rgba(232,154,31,0.36)' },
|
||||||
dev: { label: 'DEV', fg: '#D4AAFF', bg: 'rgba(159,98,212,0.18)', border: 'rgba(159,98,212,0.36)' },
|
dev: { label: 'DEV', fg: '#D4AAFF', bg: 'rgba(159,98,212,0.18)', border: 'rgba(159,98,212,0.36)' },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const env = computed(() => tweaks.value.env)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Floating cosmetic-tweaks panel. Lives in the bottom-right corner. Lets the
|
// Floating cosmetic-tweaks panel. Lives in the bottom-right corner. Quick
|
||||||
// operator flip theme/density/env without touching settings pages. All three
|
// theme + density toggle without leaving the page. The env badge is NOT
|
||||||
// are pure-cosmetic — env in particular is just a colored chip in the topbar,
|
// here — it's derived from the hostname (see useEnv) so the operator can
|
||||||
// not a real environment switch.
|
// trust it as a real environment signal, not a sticker they flipped.
|
||||||
|
|
||||||
const { state, setTheme, setDensity, setEnv } = useTweaks()
|
const { state, setTheme, setDensity } = useTweaks()
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -39,15 +39,6 @@ const open = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<label class="row-label">Env badge</label>
|
|
||||||
<div class="seg three">
|
|
||||||
<button :class="{ on: state.env === 'prod' }" type="button" @click="setEnv('prod')">PROD</button>
|
|
||||||
<button :class="{ on: state.env === 'staging' }" type="button" @click="setEnv('staging')">STAGING</button>
|
|
||||||
<button :class="{ on: state.env === 'dev' }" type="button" @click="setEnv('dev')">DEV</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<Mono dim>// cosmetic only — saved to localStorage</Mono>
|
<Mono dim>// cosmetic only — saved to localStorage</Mono>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -129,7 +120,6 @@ section { display: flex; flex-direction: column; gap: 6px; }
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
.seg.three { grid-template-columns: 1fr 1fr 1fr; }
|
|
||||||
.seg button {
|
.seg button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Detect which Dezky environment we're running against, from the browser
|
||||||
|
// hostname. The env badge in the topbar reads this and uses it as a
|
||||||
|
// "you're about to make changes in prod" warning — so it must NOT be
|
||||||
|
// user-controllable.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - hostname ends in `.local` or is `localhost` → 'dev'
|
||||||
|
// - hostname contains `staging` → 'staging'
|
||||||
|
// - anything else → 'prod' (safest default — show the prod styling)
|
||||||
|
//
|
||||||
|
// SSR returns 'dev' by default because the server has no concept of the
|
||||||
|
// browser hostname. The first client tick re-evaluates and the env pill
|
||||||
|
// updates without a visible flash for the typical operator workflow
|
||||||
|
// (signed-in client-side navigation).
|
||||||
|
|
||||||
|
export type Env = 'prod' | 'staging' | 'dev'
|
||||||
|
|
||||||
|
function detect(hostname: string): Env {
|
||||||
|
const h = hostname.toLowerCase()
|
||||||
|
if (h === 'localhost' || h === '127.0.0.1' || h.endsWith('.local')) return 'dev'
|
||||||
|
if (h.includes('staging')) return 'staging'
|
||||||
|
return 'prod'
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = ref<Env>('dev')
|
||||||
|
const hostname = ref<string>('')
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!import.meta.client || initialized) return
|
||||||
|
hostname.value = window.location.hostname
|
||||||
|
current.value = detect(hostname.value)
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEnv = () => {
|
||||||
|
if (import.meta.client) init()
|
||||||
|
return { env: current, hostname }
|
||||||
|
}
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
// Cosmetic tweaks for the operator shell — theme (dark/light), density
|
// Cosmetic user-controllable preferences for the operator shell — theme
|
||||||
// (comfy/compact), env badge (prod/staging/dev). Persisted in localStorage so
|
// (dark/light) and density (comfy/compact). Persisted in localStorage so
|
||||||
// the choices survive page reloads. The values are applied to <html> as
|
// the choices survive page reloads. The values are applied to <html> as
|
||||||
// data-* attributes; tokens.css picks them up via selector overrides.
|
// data-* attributes; tokens.css picks them up via selector overrides.
|
||||||
|
//
|
||||||
|
// NOTE: `env` used to live here but is now derived from the hostname via
|
||||||
|
// `useEnv()` so it's a real environment signal, not a sticker the operator
|
||||||
|
// can flip.
|
||||||
|
|
||||||
export type ThemeMode = 'dark' | 'light'
|
export type ThemeMode = 'dark' | 'light'
|
||||||
export type Density = 'comfy' | 'compact'
|
export type Density = 'comfy' | 'compact'
|
||||||
export type Env = 'prod' | 'staging' | 'dev'
|
|
||||||
|
|
||||||
interface TweakState {
|
interface TweakState {
|
||||||
theme: ThemeMode
|
theme: ThemeMode
|
||||||
density: Density
|
density: Density
|
||||||
env: Env
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'dezky-operator-tweaks'
|
const STORAGE_KEY = 'dezky-operator-tweaks'
|
||||||
|
|
||||||
const DEFAULTS: TweakState = { theme: 'dark', density: 'comfy', env: 'dev' }
|
const DEFAULTS: TweakState = { theme: 'dark', density: 'comfy' }
|
||||||
|
|
||||||
const state = ref<TweakState>({ ...DEFAULTS })
|
const state = ref<TweakState>({ ...DEFAULTS })
|
||||||
const hydrated = ref(false)
|
const hydrated = ref(false)
|
||||||
@@ -41,8 +43,13 @@ function hydrate() {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
// Old payloads carried an `env` field — pick fields we still care about
|
||||||
|
// and ignore the rest so a stale localStorage entry doesn't break.
|
||||||
const parsed = JSON.parse(raw) as Partial<TweakState>
|
const parsed = JSON.parse(raw) as Partial<TweakState>
|
||||||
state.value = { ...DEFAULTS, ...parsed }
|
state.value = {
|
||||||
|
theme: parsed.theme ?? DEFAULTS.theme,
|
||||||
|
density: parsed.density ?? DEFAULTS.density,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore corrupt JSON
|
// ignore corrupt JSON
|
||||||
@@ -64,6 +71,5 @@ export const useTweaks = () => {
|
|||||||
state,
|
state,
|
||||||
setTheme: (v: ThemeMode) => set('theme', v),
|
setTheme: (v: ThemeMode) => set('theme', v),
|
||||||
setDensity: (v: Density) => set('density', v),
|
setDensity: (v: Density) => set('density', v),
|
||||||
setEnv: (v: Env) => set('env', v),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ interface VerifyResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { user } = useOidcAuth()
|
const { user } = useOidcAuth()
|
||||||
const { state: tweaks, setTheme, setDensity, setEnv } = useTweaks()
|
const { state: tweaks, setTheme, setDensity } = useTweaks()
|
||||||
|
const { env, hostname } = useEnv()
|
||||||
|
|
||||||
|
const ENV_LABEL: Record<'prod' | 'staging' | 'dev', string> = {
|
||||||
|
prod: 'Production',
|
||||||
|
staging: 'Staging',
|
||||||
|
dev: 'Development',
|
||||||
|
}
|
||||||
|
|
||||||
const { data: token } = useLazyFetch<VerifyResponse>('/api/_verify-token', {
|
const { data: token } = useLazyFetch<VerifyResponse>('/api/_verify-token', {
|
||||||
server: false,
|
server: false,
|
||||||
@@ -102,6 +109,15 @@ const links = [
|
|||||||
<Mono v-if="!groups.length" dim>—</Mono>
|
<Mono v-if="!groups.length" dim>—</Mono>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<dt>Environment</dt>
|
||||||
|
<dd class="env-row">
|
||||||
|
<Badge :tone="env === 'prod' ? 'bad' : env === 'staging' ? 'warn' : 'info'" dot>
|
||||||
|
{{ ENV_LABEL[env] }}
|
||||||
|
</Badge>
|
||||||
|
<Mono dim>auto-detected from {{ hostname || '—' }}</Mono>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -147,14 +163,6 @@ const links = [
|
|||||||
<button :class="{ on: tweaks.density === 'compact' }" type="button" @click="setDensity('compact')">Compact</button>
|
<button :class="{ on: tweaks.density === 'compact' }" type="button" @click="setDensity('compact')">Compact</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
|
||||||
<span class="label">Env badge</span>
|
|
||||||
<div class="seg three">
|
|
||||||
<button :class="{ on: tweaks.env === 'prod' }" type="button" @click="setEnv('prod')">PROD</button>
|
|
||||||
<button :class="{ on: tweaks.env === 'staging' }" type="button" @click="setEnv('staging')">STAGING</button>
|
|
||||||
<button :class="{ on: tweaks.env === 'dev' }" type="button" @click="setEnv('dev')">DEV</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +201,7 @@ const links = [
|
|||||||
}
|
}
|
||||||
.kv dd { margin: 0; font-size: 13px; }
|
.kv dd { margin: 0; font-size: 13px; }
|
||||||
.kv dd.groups { display: flex; flex-wrap: wrap; gap: 6px; }
|
.kv dd.groups { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.kv dd.env-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.links { display: flex; flex-direction: column; }
|
.links { display: flex; flex-direction: column; }
|
||||||
.link {
|
.link {
|
||||||
@@ -242,7 +251,6 @@ const links = [
|
|||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
.seg.three { grid-template-columns: 1fr 1fr 1fr; max-width: 360px; }
|
|
||||||
.seg button {
|
.seg button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user