first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import {useSyncExternalStore} from 'react'
// Floci isolates resources per AWS account, and selects the account from the
// 12-digit access key it is given (see the API's aws.ts). The console mirrors
// that: a single active account at a time, sent on every request via the
// `x-floci-account-id` header. This is a context switch, not a filter — accounts
// do not overlap in Floci.
export const DEFAULT_ACCOUNT_ID = '000000000000'
/** Request header the API reads to scope a request to an account. */
export const ACCOUNT_HEADER = 'x-floci-account-id'
const ACCOUNT_KEY = 'floci.accountId'
const RECENTS_KEY = 'floci.accountId.recents'
const MAX_RECENTS = 8
export function isAccountId(value: string | null | undefined): value is string {
return typeof value === 'string' && /^\d{12}$/.test(value)
}
function readAccount(): string {
try {
const stored = localStorage.getItem(ACCOUNT_KEY)
if (isAccountId(stored)) return stored
} catch {
// localStorage unavailable (private mode / SSR) — fall back to default.
}
return DEFAULT_ACCOUNT_ID
}
function readRecents(): string[] {
try {
const raw = localStorage.getItem(RECENTS_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter(isAccountId).slice(0, MAX_RECENTS)
}
} catch {
// ignore malformed storage
}
return [DEFAULT_ACCOUNT_ID]
}
let currentAccount = readAccount()
let recents = readRecents()
if (!recents.includes(currentAccount)) recents = [currentAccount, ...recents].slice(0, MAX_RECENTS)
const listeners = new Set<() => void>()
function persist(): void {
try {
localStorage.setItem(ACCOUNT_KEY, currentAccount)
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents))
} catch {
// ignore persistence failures
}
}
function emit(): void {
for (const listener of listeners) listener()
}
/** Current account id — read synchronously by the API request interceptor. */
export function getAccountId(): string {
return currentAccount
}
export function getRecents(): string[] {
return recents
}
/**
* Switch the active account. Returns false for invalid ids (not 12 digits).
* The reference of getRecents()/getAccountId() only changes when something
* actually changed, keeping useSyncExternalStore snapshots stable.
*/
export function setAccountId(id: string): boolean {
if (!isAccountId(id)) return false
const sameAccount = id === currentAccount
const alreadyTop = recents[0] === id
if (sameAccount && alreadyTop) return true
currentAccount = id
recents = [id, ...recents.filter((r) => r !== id)].slice(0, MAX_RECENTS)
persist()
emit()
return true
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function useAccountId(): string {
return useSyncExternalStore(subscribe, getAccountId, getAccountId)
}
export function useAccountRecents(): string[] {
return useSyncExternalStore(subscribe, getRecents, getRecents)
}
+21
View File
@@ -0,0 +1,21 @@
export function assert(
condition: unknown,
message = "Assertion failed",
): asserts condition {
if (!condition) {
throw new Error(message);
}
}
export function assertDefined<T>(
value: T,
message = "Expected value to be defined",
): asserts value is NonNullable<T> {
if (value == null) {
throw new Error(message);
}
}
export function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${String(value)}`);
}
+82
View File
@@ -0,0 +1,82 @@
import type {CapabilitySchema, CapabilityStatus, DatabaseActionName, KubernetesActionName, ObjectActionName, ResourceActionName} from '@/types/schema'
import type {CloudAvailability} from '@/types/cloud'
export type CapabilityActionName = ResourceActionName | ObjectActionName | KubernetesActionName | DatabaseActionName
export type AnyCapability = CapabilitySchema<CapabilityActionName>
export type CapabilityInput<TAction extends CapabilityActionName> = CapabilitySchema<TAction> | TAction
const actionLabels: Record<CapabilityActionName, string> = {
list: 'List',
create: 'Create',
update: 'Update',
delete: 'Delete',
inspect: 'Inspect',
encrypt: 'Encrypt',
decrypt: 'Decrypt',
upload: 'Upload',
download: 'Download',
createFolder: 'Create folder',
copy: 'Copy object',
invoke: 'Invoke',
start: 'Start',
stop: 'Stop',
reboot: 'Reboot',
updateTags: 'Edit tags',
listSnapshots: 'List DB snapshots',
createSnapshot: 'Create DB snapshot',
listNodegroups: 'List nodegroups',
createNodegroup: 'Create nodegroup',
deleteNodegroup: 'Delete nodegroup',
listFargateProfiles: 'List Fargate profiles',
createFargateProfile: 'Create Fargate profile',
deleteFargateProfile: 'Delete Fargate profile',
}
export function normalizeCapabilities<TAction extends CapabilityActionName>(capabilities: Array<CapabilityInput<TAction>> = []): Array<CapabilitySchema<TAction>> {
return capabilities.map((capability) => {
if (typeof capability !== 'string') return capability
return {
name: capability,
label: actionLabels[capability],
enabled: true,
status: 'available' as CapabilityStatus,
runtimeRequired: true,
}
})
}
export function capabilityFor<TAction extends CapabilityActionName>(capabilities: Array<CapabilitySchema<TAction>>, name: TAction): CapabilitySchema<TAction> | undefined {
return capabilities.find((capability) => capability.name === name)
}
export function capabilityEnabled(capability: CapabilitySchema<CapabilityActionName> | undefined): boolean {
return Boolean(capability?.enabled && capability.status !== 'blocked' && capability.status !== 'coming_soon')
}
export function withRuntimeState<TAction extends CapabilityActionName>(
capabilities: Array<CapabilitySchema<TAction>>,
runtimeReachable: boolean,
): Array<CapabilitySchema<TAction>> {
return capabilities.map((capability) => {
if (runtimeReachable || !capability.runtimeRequired) return capability
return {
...capability,
enabled: false,
status: 'blocked',
reason: 'Start the selected runtime before using this action.',
}
})
}
export function withServiceAvailability<TAction extends CapabilityActionName>(
capabilities: Array<CapabilitySchema<TAction>>,
serviceAvailability: CloudAvailability,
): Array<CapabilitySchema<TAction>> {
if (serviceAvailability === 'available') return capabilities
return capabilities.map((capability) => ({
...capability,
enabled: false,
status: 'coming_soon' as CapabilityStatus,
reason: 'This provider service schema is available, but no runtime adapter is registered yet.',
}))
}
@@ -0,0 +1,35 @@
import type {ReactNode} from 'react'
import type {TableColumnSchema} from '@/types/schema'
import {formatBytes, formatDateTime, formatRelativeTime, slugify} from '@/lib/format'
/**
* Render one table cell from a schema-declared column.
*
* Formats are server-declared, so an unknown one degrades to plain text rather
* than breaking the table.
*/
export function renderColumnValue(value: unknown, column: TableColumnSchema): ReactNode {
const empty = column.emptyText ?? '-'
if (value === null || value === undefined || value === '') return empty
switch (column.format) {
case 'datetime':
return formatDateTime(value) ?? empty
case 'relative':
return formatRelativeTime(value) ?? empty
case 'bytes':
return typeof value === 'number' ? formatBytes(value) : empty
case 'boolean':
return value ? 'Yes' : 'No'
case 'badge':
return <span className={`badge ${slugify(String(value))}`}>{String(value)}</span>
case 'code':
return <code>{String(value)}</code>
case 'list':
return Array.isArray(value) ? value.join(', ') || empty : String(value)
case 'text':
default:
return String(value)
}
}
+13
View File
@@ -0,0 +1,13 @@
import type {CloudResource} from '@/types/resource'
export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos' | 'sql' | undefined {
if (resource.cloud === 'aws' && resource.service === 'nosql' && resource.type === 'dynamodb-table') return 'dynamodb'
if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos'
if (resource.cloud === 'azure' && resource.service === 'database' &&
(resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql'
}
export function dataExplorerPath(resource: CloudResource): string | undefined {
if (!dataExplorerKind(resource)) return undefined
return `/cloud-explorer/${resource.cloud}/${resource.service}/${encodeURIComponent(resource.id)}/data`
}
+105
View File
@@ -0,0 +1,105 @@
/** Shared value formatters for tables, inspectors and the object browser. */
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes === 0) return '0 B'
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1)
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${BYTE_UNITS[index]}`
}
/** Locale date-time, or null when the value is not a usable timestamp. */
export function formatDateTime(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toLocaleString()
}
const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['year', 31_536_000_000],
['month', 2_592_000_000],
['day', 86_400_000],
['hour', 3_600_000],
['minute', 60_000],
['second', 1_000],
]
/** "3 minutes ago", or null when the value is not a usable timestamp. */
export function formatRelativeTime(value: unknown, now: number = Date.now()): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
const deltaMs = date.getTime() - now
const formatter = new Intl.RelativeTimeFormat(undefined, {numeric: 'auto'})
for (const [unit, unitMs] of RELATIVE_UNITS) {
if (Math.abs(deltaMs) >= unitMs) {
return formatter.format(Math.round(deltaMs / unitMs), unit)
}
}
return formatter.format(0, 'second')
}
/** Kinds that render inside the preview modal. */
export type ObjectPreviewKind = 'image' | 'video' | 'audio' | 'text'
/**
* Kinds recognized for a distinct row icon. A superset of ObjectPreviewKind —
* these get an icon but no in-browser preview:
* - 'pdf': native PDF-in-iframe support is unreliable across browsers (some
* ship without a built-in PDF viewer, and browsers/ad-blockers can flag an
* iframe navigation that falls back to a download as an automatic-download
* attack pattern and block it outright). Download still works normally.
* - 'document' (Word) / 'spreadsheet' (Excel): rendering needs a client-side
* parser (mammoth, xlsx) that this project doesn't depend on yet.
*/
export type ObjectIconKind = ObjectPreviewKind | 'pdf' | 'document' | 'spreadsheet'
const PREVIEW_EXTENSIONS: Record<ObjectPreviewKind, Set<string>> = {
image: new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']),
video: new Set(['mp4', 'webm', 'ogv', 'mov']),
audio: new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'flac']),
text: new Set(['txt', 'json', 'log', 'md', 'yaml', 'yml', 'csv', 'xml', 'html', 'css', 'js', 'ts']),
}
const ICON_ONLY_EXTENSIONS: Record<Exclude<ObjectIconKind, ObjectPreviewKind>, Set<string>> = {
pdf: new Set(['pdf']),
document: new Set(['doc', 'docx', 'rtf', 'odt']),
spreadsheet: new Set(['xls', 'xlsx', 'xlsm', 'ods']),
}
/**
* Best-effort preview/icon-kind detection from an object key/name. Storage
* list APIs (S3 ListObjectsV2, Azure list blobs) do not return content-type
* per item — only a HEAD/GET on the object would — so this is an extension
* heuristic. A wrong guess just fails to render the thumbnail/preview, it
* never blocks any other action.
*/
export function objectIconKind(name: string): ObjectIconKind | null {
const ext = name.split('.').pop()?.toLowerCase()
if (!ext) return null
for (const kind of Object.keys(PREVIEW_EXTENSIONS) as ObjectPreviewKind[]) {
if (PREVIEW_EXTENSIONS[kind].has(ext)) return kind
}
for (const kind of Object.keys(ICON_ONLY_EXTENSIONS) as Array<'pdf' | 'document' | 'spreadsheet'>) {
if (ICON_ONLY_EXTENSIONS[kind].has(ext)) return kind
}
return null
}
const ICON_ONLY_KINDS = new Set(Object.keys(ICON_ONLY_EXTENSIONS))
/** Narrower than {@link objectIconKind}: only kinds the preview modal can render. */
export function objectPreviewKind(name: string): ObjectPreviewKind | null {
const kind = objectIconKind(name)
return kind && !ICON_ONLY_KINDS.has(kind) ? kind as ObjectPreviewKind : null
}
/** Stable class-name fragment for a status value. */
export function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Cross-cloud network validation utilities.
* CIDR and port logic applies equally to AWS VPCs, Azure VNets, GCP VPCs, OCI VCNs, etc.
*/
// ─── Internal helpers ─────────────────────────────────────────────────────────
const CIDR_RE = /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/
function parseIpAndPrefix(cidr: string): [number[], number] | null {
const m = cidr.trim().match(CIDR_RE)
if (!m) return null
const octs = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
const prefix = Number(m[5])
if (octs.some((o) => o > 255) || prefix > 32) return null
return [octs, prefix]
}
/** Zero out host bits of an IP array up to the given prefix length. */
function maskIp(octs: number[], prefix: number): number[] {
const result = [...octs]
let bits = prefix
for (let i = 0; i < 4; i++) {
if (bits >= 8) {
bits -= 8
} else if (bits > 0) {
result[i] = result[i] & ((0xff << (8 - bits)) & 0xff)
bits = 0
} else {
result[i] = 0
}
}
return result
}
// ─── Exports ──────────────────────────────────────────────────────────────────
/** True when `cidr` is a syntactically valid IPv4 CIDR (e.g. 10.0.0.0/16). */
export function isValidCidr(cidr: string): boolean {
return parseIpAndPrefix(cidr) !== null
}
/**
* True when `innerCidr` fits entirely inside `outerCidr`.
*
* Conditions:
* 1. Both must be valid CIDRs.
* 2. inner prefix >= outer prefix (inner block cannot be larger than outer).
* 3. inner's network address, masked to the outer prefix, equals outer's
* network address — confirming inner is actually inside the outer range.
*
* Example: 10.0.1.0/24 is within 10.0.0.0/16. 10.1.0.0/24 is not.
*/
export function isCidrWithinBlock(innerCidr: string, outerCidr: string): boolean {
const inner = parseIpAndPrefix(innerCidr)
const outer = parseIpAndPrefix(outerCidr)
if (!inner || !outer) return false
const [innerOcts, innerPrefix] = inner
const [outerOcts, outerPrefix] = outer
if (innerPrefix < outerPrefix) return false
const innerMasked = maskIp(innerOcts, outerPrefix)
const outerMasked = maskIp(outerOcts, outerPrefix)
return innerMasked.every((b, i) => b === outerMasked[i])
}
/** True when `port` is an integer in [0, 65535]. */
export function isValidPort(port: string): boolean {
const n = Number(port)
return Number.isInteger(n) && n >= 0 && n <= 65535
}
/**
* True when fromPort <= toPort and both are valid.
* Always true for protocols that don't use ports ('-1', 'icmp').
*/
export function isValidPortRange(protocol: string, from: string, to: string): boolean {
if (protocol === '-1' || protocol === 'icmp') return true
if (!isValidPort(from) || !isValidPort(to)) return false
return Number(from) <= Number(to)
}
// ─── Subnet group CIDR calculation ───────────────────────────────────────────
export type SubnetGroupDef = {
name: string
count: number
isPublic: boolean
/** Subnet prefix length override, e.g. 24 for /24. Defaults to min(vpcPrefix+4, 28). */
prefix?: number
}
export type SubnetGroupPreview = {
name: string
isPublic: boolean
cidrs: string[]
}
/**
* Allocates non-overlapping subnet CIDRs for each named group sequentially.
* subnetPrefix = min(vpcPrefix + 4, 28). Groups are laid out in declaration
* order without gaps — suitable for both the wizard preview and the backend.
*
* Example: vpcCidr=10.0.0.0/16, groups=[{public,2},{private-app,2},{private-db,1}]
* public-1 = 10.0.0.0/20
* public-2 = 10.0.16.0/20
* private-app-1 = 10.0.32.0/20
* private-app-2 = 10.0.48.0/20
* private-db-1 = 10.0.64.0/20
*/
export function calculateSubnetCidrs(
vpcCidr: string,
groups: SubnetGroupDef[],
): SubnetGroupPreview[] {
const parsed = parseIpAndPrefix(vpcCidr)
if (!parsed) return groups.map((g) => ({ name: g.name, isPublic: g.isPublic, cidrs: [] }))
const [octs, vpcPrefix] = parsed
const vpcBase = octs[0] * 16777216 + octs[1] * 65536 + octs[2] * 256 + octs[3]
const defaultPrefix = Math.min(vpcPrefix + 4, 28)
function baseToCidr(base: number, prefix: number): string {
return (
`${Math.floor(base / 16777216)}.` +
`${Math.floor((base % 16777216) / 65536)}.` +
`${Math.floor((base % 65536) / 256)}.` +
`${base % 256}/${prefix}`
)
}
let currentBase = vpcBase
return groups.map((g) => {
const prefix = g.prefix ?? defaultPrefix
const size = Math.pow(2, 32 - prefix)
const cidrs = Array.from({ length: g.count }, (_, i) => baseToCidr(currentBase + i * size, prefix))
currentBase += g.count * size
return { name: g.name, isPublic: g.isPublic, cidrs }
})
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Read a dotted path out of a resource.
*
* Table columns default to a top-level lookup, but most provider detail lives
* under `metadata`, so a column like `metadata.runtime` needs to walk. Returns
* undefined on any non-object hop rather than throwing, because schemas are
* server-supplied and may name a field a given runtime does not populate.
*/
export function getPath(source: unknown, path: string): unknown {
if (!path) return undefined
let current = source
for (const segment of path.split('.')) {
if (current === null || current === undefined) return undefined
if (typeof current !== 'object') return undefined
if (Array.isArray(current)) {
const index = Number(segment)
if (!Number.isInteger(index)) return undefined
current = current[index]
continue
}
current = (current as Record<string, unknown>)[segment]
}
return current
}
+103
View File
@@ -0,0 +1,103 @@
import {useCallback, useEffect, useRef, useState} from 'react'
type SidebarPref = 'expanded' | 'collapsed'
const STORAGE_KEY = 'floci-sidebar'
/** Same breakpoint the rest of the console reflows at. */
const NARROW_QUERY = '(max-width: 980px)'
function getStored(): SidebarPref | null {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored === 'collapsed' || stored === 'expanded' ? stored : null
} catch {
// localStorage unavailable (private mode) — let the viewport decide.
return null
}
}
function matchesNarrow(): boolean {
return window.matchMedia(NARROW_QUERY).matches
}
/**
* True when focus sits inside the sidebar nav — the state that holds the rail
* open via the `.sidebar:has(.nav:focus-within)` rule in index.css.
*/
function navHasFocus(): boolean {
const active = document.activeElement
return active instanceof HTMLElement && active.closest('.sidebar .nav') !== null
}
/** The shortcut must not fire while the user is typing in the topbar search. */
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT'
}
/**
* Sidebar collapse state.
*
* The preference stays null until the user makes an explicit choice, so the
* viewport decides on a first visit. Once they toggle, that choice persists and
* the breakpoint stops applying — resizing never undoes a deliberate decision.
*/
export function useSidebar() {
const [pref, setPref] = useState<SidebarPref | null>(getStored)
const [narrow, setNarrow] = useState(matchesNarrow)
/** Attach to the footer toggle so the shortcut can park focus outside .nav. */
const toggleRef = useRef<HTMLButtonElement>(null)
const collapsed = pref !== null ? pref === 'collapsed' : narrow
const toggle = useCallback(() => {
const next: SidebarPref = collapsed ? 'expanded' : 'collapsed'
setPref(next)
// Collapsing while focus sits in the nav leaves
// `.sidebar:has(.nav:focus-within)` matching, which holds the panel at
// full width — so the shortcut looks like it did nothing. Park focus on
// the toggle instead: it is outside .nav, stays visible in the rail, and
// is one Shift+Tab from where the user was. Blurring would drop them
// back to the start of the document.
if (next === 'collapsed' && navHasFocus()) {
toggleRef.current?.focus({preventScroll: true})
}
}, [collapsed])
useEffect(() => {
const query = window.matchMedia(NARROW_QUERY)
const onChange = (event: MediaQueryListEvent) => setNarrow(event.matches)
query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}, [])
useEffect(() => {
document.documentElement.setAttribute('data-sidebar', collapsed ? 'collapsed' : 'expanded')
}, [collapsed])
useEffect(() => {
if (pref === null) return
try {
localStorage.setItem(STORAGE_KEY, pref)
} catch {
// ignore persistence failures
}
}, [pref])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'b' && event.key !== 'B') return
if (!event.metaKey && !event.ctrlKey) return
if (event.altKey || event.shiftKey) return
if (isTypingTarget(event.target)) return
event.preventDefault()
toggle()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [toggle])
return {collapsed, toggle, toggleRef}
}
+21
View File
@@ -0,0 +1,21 @@
import {useEffect, useState} from 'react'
type Theme = 'dark' | 'light'
function getStored(): Theme {
const v = localStorage.getItem('floci-theme')
return v === 'light' ? 'light' : 'dark'
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(getStored)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('floci-theme', theme)
}, [theme])
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
return {theme, toggle}
}
+37
View File
@@ -0,0 +1,37 @@
import {type ClassValue, clsx} from 'clsx'
import {twMerge} from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatNumber(value: number | undefined): string {
return new Intl.NumberFormat().format(value ?? 0)
}
export function formatLatency(value: number | undefined): string {
if (value == null || value <= 0) return '-'
return value < 1000 ? `${Math.round(value)}ms` : `${(value / 1000).toFixed(1)}s`
}
export function timeAgo(value?: string | number): string {
if (!value) return '-'
const ts = typeof value === 'number' ? value : new Date(value).getTime()
if (!Number.isFinite(ts)) return '-'
const diff = Math.max(0, Date.now() - ts)
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
export function statusColor(status: string): string {
if (status === 'healthy' || status === 'active' || status === 'OK') return 'text-green-400'
if (status === 'degraded' || status === 'INSUFFICIENT_DATA') return 'text-yellow-400'
if (status === 'unavailable' || status === 'ALARM') return 'text-red-400'
return 'text-[#8d9cad]'
}