first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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, '')
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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]'
|
||||
}
|
||||
Reference in New Issue
Block a user