feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
625
frontend/src/routes/_authenticated/about.tsx
Normal file
625
frontend/src/routes/_authenticated/about.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { RugLogo } from '@/components/brand/RugLogo'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/about')({
|
||||
component: AboutPage,
|
||||
})
|
||||
|
||||
// ── Minimal QR Code Generator (no dependencies) ─────────────────────────────
|
||||
// Implements a basic QR encoder for alphanumeric/byte mode, version 1-4
|
||||
// Sufficient for encoding a Bitcoin address (~62 chars)
|
||||
|
||||
const BTC_ADDRESS = 'bc1qfw6pmyc96vrlkpc0rgun0s7xy4sqhx7a2xurkf'
|
||||
|
||||
// Generate QR matrix for a given string using a minimal implementation
|
||||
function generateQRMatrix(data: string): boolean[][] {
|
||||
// Use the canvas-based approach with a simple encoding
|
||||
// For a self-contained solution, we'll generate a basic QR-like pattern
|
||||
// This is a real QR encoder for small payloads
|
||||
|
||||
const codewords = encodeData(data)
|
||||
const version = getMinVersion(data.length)
|
||||
const size = 17 + version * 4
|
||||
const matrix: (boolean | null)[][] = Array.from({ length: size }, () =>
|
||||
Array(size).fill(null)
|
||||
)
|
||||
|
||||
// Place finder patterns
|
||||
placeFinder(matrix, 0, 0)
|
||||
placeFinder(matrix, 0, size - 7)
|
||||
placeFinder(matrix, size - 7, 0)
|
||||
|
||||
// Place timing patterns
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
matrix[6][i] = i % 2 === 0
|
||||
matrix[i][6] = i % 2 === 0
|
||||
}
|
||||
|
||||
// Place alignment pattern for version >= 2
|
||||
if (version >= 2) {
|
||||
const pos = alignmentPositions[version]
|
||||
if (pos) {
|
||||
for (const r of pos) {
|
||||
for (const c of pos) {
|
||||
if (matrix[r]?.[c] === null) {
|
||||
placeAlignment(matrix, r, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark module
|
||||
matrix[size - 8][8] = true
|
||||
|
||||
// Reserve format info
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (matrix[8][i] === null) matrix[8][i] = false
|
||||
if (matrix[i][8] === null) matrix[i][8] = false
|
||||
if (matrix[8][size - 1 - i] === null) matrix[8][size - 1 - i] = false
|
||||
if (matrix[size - 1 - i][8] === null) matrix[size - 1 - i][8] = false
|
||||
}
|
||||
if (matrix[8][8] === null) matrix[8][8] = false
|
||||
|
||||
// Place data bits
|
||||
placeData(matrix, codewords, size)
|
||||
|
||||
// Apply mask (mask 0: (row + col) % 2 === 0)
|
||||
const result: boolean[][] = matrix.map((row, r) =>
|
||||
row.map((cell, c) => {
|
||||
if (cell === null) return false
|
||||
const isData = !isReserved(matrix, r, c, size, version)
|
||||
if (isData) {
|
||||
const mask = (r + c) % 2 === 0
|
||||
return mask ? !cell : (cell as boolean)
|
||||
}
|
||||
return cell as boolean
|
||||
})
|
||||
)
|
||||
|
||||
// Place format info for mask 0, error correction L
|
||||
placeFormatInfo(result, size)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getMinVersion(len: number): number {
|
||||
// Byte mode capacities for EC level L
|
||||
if (len <= 17) return 1
|
||||
if (len <= 32) return 2
|
||||
if (len <= 53) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
const alignmentPositions: Record<number, number[]> = {
|
||||
2: [6, 18],
|
||||
3: [6, 22],
|
||||
4: [6, 26],
|
||||
}
|
||||
|
||||
function placeFinder(matrix: (boolean | null)[][], row: number, col: number) {
|
||||
for (let r = -1; r <= 7; r++) {
|
||||
for (let c = -1; c <= 7; c++) {
|
||||
const mr = row + r
|
||||
const mc = col + c
|
||||
if (mr < 0 || mc < 0 || mr >= matrix.length || mc >= matrix.length)
|
||||
continue
|
||||
if (r === -1 || r === 7 || c === -1 || c === 7) {
|
||||
matrix[mr][mc] = false // separator
|
||||
} else if (
|
||||
r === 0 ||
|
||||
r === 6 ||
|
||||
c === 0 ||
|
||||
c === 6 ||
|
||||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)
|
||||
) {
|
||||
matrix[mr][mc] = true
|
||||
} else {
|
||||
matrix[mr][mc] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placeAlignment(
|
||||
matrix: (boolean | null)[][],
|
||||
row: number,
|
||||
col: number
|
||||
) {
|
||||
for (let r = -2; r <= 2; r++) {
|
||||
for (let c = -2; c <= 2; c++) {
|
||||
const mr = row + r
|
||||
const mc = col + c
|
||||
if (mr < 0 || mc < 0 || mr >= matrix.length || mc >= matrix.length)
|
||||
continue
|
||||
if (matrix[mr][mc] !== null) continue
|
||||
if (
|
||||
Math.abs(r) === 2 ||
|
||||
Math.abs(c) === 2 ||
|
||||
(r === 0 && c === 0)
|
||||
) {
|
||||
matrix[mr][mc] = true
|
||||
} else {
|
||||
matrix[mr][mc] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function encodeData(data: string): number[] {
|
||||
const version = getMinVersion(data.length)
|
||||
// Total codewords for version + EC level L
|
||||
const totalCodewords = [0, 26, 44, 70, 100][version]!
|
||||
const ecCodewords = [0, 7, 10, 15, 20][version]!
|
||||
const dataCodewords = totalCodewords - ecCodewords
|
||||
|
||||
// Byte mode: mode indicator (0100) + char count + data
|
||||
const bits: number[] = []
|
||||
|
||||
// Mode: byte (0100)
|
||||
pushBits(bits, 0b0100, 4)
|
||||
|
||||
// Character count (8 bits for version 1-9)
|
||||
pushBits(bits, data.length, 8)
|
||||
|
||||
// Data
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
pushBits(bits, data.charCodeAt(i), 8)
|
||||
}
|
||||
|
||||
// Terminator
|
||||
const maxBits = dataCodewords * 8
|
||||
const termLen = Math.min(4, maxBits - bits.length)
|
||||
pushBits(bits, 0, termLen)
|
||||
|
||||
// Pad to byte boundary
|
||||
while (bits.length % 8 !== 0) bits.push(0)
|
||||
|
||||
// Pad codewords
|
||||
const padWords = [0xec, 0x11]
|
||||
let padIdx = 0
|
||||
while (bits.length < maxBits) {
|
||||
pushBits(bits, padWords[padIdx % 2], 8)
|
||||
padIdx++
|
||||
}
|
||||
|
||||
// Convert to bytes
|
||||
const dataBytes: number[] = []
|
||||
for (let i = 0; i < bits.length; i += 8) {
|
||||
let byte = 0
|
||||
for (let j = 0; j < 8; j++) {
|
||||
byte = (byte << 1) | (bits[i + j] || 0)
|
||||
}
|
||||
dataBytes.push(byte)
|
||||
}
|
||||
|
||||
// Generate EC codewords using Reed-Solomon
|
||||
const ecBytes = generateEC(dataBytes, ecCodewords)
|
||||
|
||||
return [...dataBytes, ...ecBytes]
|
||||
}
|
||||
|
||||
function pushBits(arr: number[], value: number, count: number) {
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
arr.push((value >> i) & 1)
|
||||
}
|
||||
}
|
||||
|
||||
// GF(256) arithmetic for Reed-Solomon
|
||||
const GF_EXP = new Uint8Array(512)
|
||||
const GF_LOG = new Uint8Array(256)
|
||||
|
||||
;(function initGF() {
|
||||
let x = 1
|
||||
for (let i = 0; i < 255; i++) {
|
||||
GF_EXP[i] = x
|
||||
GF_LOG[x] = i
|
||||
x = x << 1
|
||||
if (x >= 256) x ^= 0x11d
|
||||
}
|
||||
for (let i = 255; i < 512; i++) {
|
||||
GF_EXP[i] = GF_EXP[i - 255]
|
||||
}
|
||||
})()
|
||||
|
||||
function gfMul(a: number, b: number): number {
|
||||
if (a === 0 || b === 0) return 0
|
||||
return GF_EXP[GF_LOG[a] + GF_LOG[b]]
|
||||
}
|
||||
|
||||
function generateEC(data: number[], ecLen: number): number[] {
|
||||
// Build generator polynomial
|
||||
let gen = [1]
|
||||
for (let i = 0; i < ecLen; i++) {
|
||||
const newGen = new Array(gen.length + 1).fill(0)
|
||||
for (let j = 0; j < gen.length; j++) {
|
||||
newGen[j] ^= gen[j]
|
||||
newGen[j + 1] ^= gfMul(gen[j], GF_EXP[i])
|
||||
}
|
||||
gen = newGen
|
||||
}
|
||||
|
||||
const result = new Array(ecLen).fill(0)
|
||||
const msg = [...data, ...result]
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const coef = msg[i]
|
||||
if (coef !== 0) {
|
||||
for (let j = 0; j < gen.length; j++) {
|
||||
msg[i + j] ^= gfMul(gen[j], coef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return msg.slice(data.length)
|
||||
}
|
||||
|
||||
function isReserved(
|
||||
_matrix: (boolean | null)[][],
|
||||
r: number,
|
||||
c: number,
|
||||
size: number,
|
||||
version: number
|
||||
): boolean {
|
||||
// Finder + separator areas
|
||||
if (r <= 8 && c <= 8) return true
|
||||
if (r <= 8 && c >= size - 8) return true
|
||||
if (r >= size - 8 && c <= 8) return true
|
||||
|
||||
// Timing
|
||||
if (r === 6 || c === 6) return true
|
||||
|
||||
// Dark module
|
||||
if (r === size - 8 && c === 8) return true
|
||||
|
||||
// Alignment patterns (version >= 2)
|
||||
if (version >= 2) {
|
||||
const pos = alignmentPositions[version]
|
||||
if (pos) {
|
||||
for (const ar of pos) {
|
||||
for (const ac of pos) {
|
||||
if (ar <= 8 && ac <= 8) continue
|
||||
if (ar <= 8 && ac >= size - 8) continue
|
||||
if (ar >= size - 8 && ac <= 8) continue
|
||||
if (Math.abs(r - ar) <= 2 && Math.abs(c - ac) <= 2) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function placeData(
|
||||
matrix: (boolean | null)[][],
|
||||
codewords: number[],
|
||||
size: number
|
||||
) {
|
||||
let bitIdx = 0
|
||||
const totalBits = codewords.length * 8
|
||||
|
||||
let col = size - 1
|
||||
let upward = true
|
||||
|
||||
while (col >= 0) {
|
||||
if (col === 6) col-- // skip timing column
|
||||
|
||||
const rows = upward
|
||||
? Array.from({ length: size }, (_, i) => size - 1 - i)
|
||||
: Array.from({ length: size }, (_, i) => i)
|
||||
|
||||
for (const row of rows) {
|
||||
for (const dc of [0, -1]) {
|
||||
const c = col + dc
|
||||
if (c < 0 || c >= size) continue
|
||||
if (matrix[row][c] !== null) continue
|
||||
|
||||
if (bitIdx < totalBits) {
|
||||
const byteIdx = Math.floor(bitIdx / 8)
|
||||
const bitPos = 7 - (bitIdx % 8)
|
||||
matrix[row][c] = ((codewords[byteIdx] >> bitPos) & 1) === 1
|
||||
bitIdx++
|
||||
} else {
|
||||
matrix[row][c] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
col -= 2
|
||||
upward = !upward
|
||||
}
|
||||
}
|
||||
|
||||
function placeFormatInfo(matrix: boolean[][], size: number) {
|
||||
// Format info for EC level L (01) and mask 0 (000) = 01000
|
||||
// After BCH: 0x77c0... Let's use the precomputed value
|
||||
// EC L = 01, mask 0 = 000 -> data = 01000
|
||||
// Format string after BCH and XOR with 101010000010010:
|
||||
const formatBits = 0x77c0 // L, mask 0
|
||||
// Actually, let's compute it properly
|
||||
// data = 01 000 = 0b01000 = 8
|
||||
// Generator: 10100110111 (0x537)
|
||||
// BCH encode then XOR with mask pattern
|
||||
const formatInfo = getFormatInfo(0, 0) // ecl=L(01->index 0 in our simplified), mask=0
|
||||
|
||||
// Place format info
|
||||
const bits: boolean[] = []
|
||||
for (let i = 14; i >= 0; i--) {
|
||||
bits.push(((formatInfo >> i) & 1) === 1)
|
||||
}
|
||||
|
||||
// Around top-left finder
|
||||
const positions1 = [
|
||||
[8, 0],
|
||||
[8, 1],
|
||||
[8, 2],
|
||||
[8, 3],
|
||||
[8, 4],
|
||||
[8, 5],
|
||||
[8, 7],
|
||||
[8, 8],
|
||||
[7, 8],
|
||||
[5, 8],
|
||||
[4, 8],
|
||||
[3, 8],
|
||||
[2, 8],
|
||||
[1, 8],
|
||||
[0, 8],
|
||||
]
|
||||
|
||||
// Around other finders
|
||||
const positions2 = [
|
||||
[size - 1, 8],
|
||||
[size - 2, 8],
|
||||
[size - 3, 8],
|
||||
[size - 4, 8],
|
||||
[size - 5, 8],
|
||||
[size - 6, 8],
|
||||
[size - 7, 8],
|
||||
[8, size - 8],
|
||||
[8, size - 7],
|
||||
[8, size - 6],
|
||||
[8, size - 5],
|
||||
[8, size - 4],
|
||||
[8, size - 3],
|
||||
[8, size - 2],
|
||||
[8, size - 1],
|
||||
]
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const [r1, c1] = positions1[i]
|
||||
matrix[r1][c1] = bits[i]
|
||||
const [r2, c2] = positions2[i]
|
||||
matrix[r2][c2] = bits[i]
|
||||
}
|
||||
}
|
||||
|
||||
function getFormatInfo(_ecl: number, mask: number): number {
|
||||
// Pre-computed format info strings for EC level L with each mask
|
||||
// EC Level L = 01
|
||||
const formatInfoL = [
|
||||
0x77c4, // mask 0
|
||||
0x72f3, // mask 1
|
||||
0x7daa, // mask 2
|
||||
0x789d, // mask 3
|
||||
0x662f, // mask 4
|
||||
0x6318, // mask 5
|
||||
0x6c41, // mask 6
|
||||
0x6976, // mask 7
|
||||
]
|
||||
return formatInfoL[mask] ?? 0x77c4
|
||||
}
|
||||
|
||||
// ── QR Canvas Component ──────────────────────────────────────────────────────
|
||||
|
||||
function QRCode({
|
||||
data,
|
||||
size = 200,
|
||||
fgColor = '#e2e8f0',
|
||||
bgColor = 'transparent',
|
||||
}: {
|
||||
data: string
|
||||
size?: number
|
||||
fgColor?: string
|
||||
bgColor?: string
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
const matrix = generateQRMatrix(data)
|
||||
const moduleCount = matrix.length
|
||||
const moduleSize = size / moduleCount
|
||||
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fillRect(0, 0, size, size)
|
||||
|
||||
// Modules
|
||||
ctx.fillStyle = fgColor
|
||||
for (let r = 0; r < moduleCount; r++) {
|
||||
for (let c = 0; c < moduleCount; c++) {
|
||||
if (matrix[r][c]) {
|
||||
ctx.fillRect(
|
||||
Math.floor(c * moduleSize),
|
||||
Math.floor(r * moduleSize),
|
||||
Math.ceil(moduleSize),
|
||||
Math.ceil(moduleSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback: draw a placeholder
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fillRect(0, 0, size, size)
|
||||
ctx.fillStyle = fgColor
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('QR Code', size / 2, size / 2)
|
||||
}
|
||||
}, [data, size, fgColor, bgColor])
|
||||
|
||||
return <canvas ref={canvasRef} width={size} height={size} className="rounded" />
|
||||
}
|
||||
|
||||
// ── About Page ───────────────────────────────────────────────────────────────
|
||||
|
||||
function AboutPage() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showQR, setShowQR] = useState(false)
|
||||
|
||||
const copyAddress = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(BTC_ADDRESS)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const el = document.createElement('textarea')
|
||||
el.value = BTC_ADDRESS
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<RugLogo size={64} />
|
||||
<h1 className="text-2xl font-bold text-text-primary">TOD - The Other Dude</h1>
|
||||
<p className="text-text-secondary">
|
||||
MSP fleet management platform for RouterOS devices
|
||||
</p>
|
||||
<span className="inline-block px-3 py-1 text-xs font-mono font-medium text-accent bg-accent-muted rounded-full">
|
||||
v6.0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Features summary */}
|
||||
<div className="rounded-lg border border-border bg-surface p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Platform
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-text-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Multi-tenant with RLS
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
RouterOS binary API
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Real-time monitoring
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Safe config push
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Certificate authority
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
WireGuard VPN
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Support Development */}
|
||||
<div className="rounded-lg border border-border bg-surface p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
Support Development
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
The Other Dude is free and open-source. If you find it valuable,
|
||||
voluntary Bitcoin contributions are appreciated but never expected.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
{showQR ? 'Hide donation address' : 'Show donation address'}
|
||||
</button>
|
||||
|
||||
{showQR && (
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<div className="p-3 rounded-lg bg-background border border-border">
|
||||
<QRCode
|
||||
data={`bitcoin:${BTC_ADDRESS}`}
|
||||
size={160}
|
||||
fgColor="hsl(215 20.2% 75.1%)"
|
||||
bgColor="transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-xs text-text-muted text-center">Bitcoin Address</p>
|
||||
<button
|
||||
onClick={copyAddress}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-background border border-border text-xs font-mono text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors cursor-pointer"
|
||||
title="Click to copy"
|
||||
>
|
||||
<span className="truncate">{BTC_ADDRESS}</span>
|
||||
<span className="flex-shrink-0 text-text-muted">
|
||||
{copied ? (
|
||||
<svg
|
||||
className="w-4 h-4 text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-text-muted">
|
||||
Not affiliated with or endorsed by MikroTik (SIA Mikrotikls)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/routes/_authenticated/alert-rules.tsx
Normal file
6
frontend/src/routes/_authenticated/alert-rules.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AlertRulesPage } from '@/components/alerts/AlertRulesPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/alert-rules')({
|
||||
component: AlertRulesPage,
|
||||
})
|
||||
6
frontend/src/routes/_authenticated/alerts.tsx
Normal file
6
frontend/src/routes/_authenticated/alerts.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AlertsPage } from '@/components/alerts/AlertsPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/alerts')({
|
||||
component: AlertsPage,
|
||||
})
|
||||
64
frontend/src/routes/_authenticated/audit.tsx
Normal file
64
frontend/src/routes/_authenticated/audit.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Audit Trail page route -- /_authenticated/audit
|
||||
*
|
||||
* Displays a centralized, filterable audit log for MSP accountability.
|
||||
* Uses the global org selector in the header for tenant context.
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Building2, ClipboardList } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin, isOperator } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { AuditLogTable } from '@/components/audit/AuditLogTable'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/audit')({
|
||||
component: AuditPage,
|
||||
})
|
||||
|
||||
function AuditPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = isSuperAdmin(user)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// RBAC: require at least operator role
|
||||
if (!isOperator(user)) {
|
||||
return (
|
||||
<div className="max-w-6xl space-y-4">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-text-muted" />
|
||||
Audit Trail
|
||||
</h1>
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
You need at least operator permissions to view the audit trail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-4">
|
||||
{/* Title */}
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-text-muted" />
|
||||
Audit Trail
|
||||
</h1>
|
||||
|
||||
{/* Audit log table or empty state */}
|
||||
{tenantId ? (
|
||||
<AuditLogTable tenantId={tenantId} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface p-12 text-center">
|
||||
<Building2 className="h-8 w-8 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to view audit logs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/routes/_authenticated/batch-config.tsx
Normal file
70
frontend/src/routes/_authenticated/batch-config.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Batch Configuration page route -- /_authenticated/batch-config
|
||||
*
|
||||
* Allows operators to apply the same configuration change to multiple
|
||||
* devices at once using a 3-step wizard. Requires at least operator role.
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Wrench, ChevronRight, Building2 } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { BatchConfigPanel } from '@/components/config/BatchConfigPanel'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/batch-config')({
|
||||
component: BatchConfigPage,
|
||||
})
|
||||
|
||||
function BatchConfigPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = isSuperAdmin(user)
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// RBAC: require at least operator role
|
||||
if (!canWrite(user)) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Wrench className="h-5 w-5 text-text-muted" />
|
||||
Batch Configuration
|
||||
</h1>
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
You need at least operator permissions to use batch configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<span>Home</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Batch Config</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Wrench className="h-5 w-5 text-text-muted" />
|
||||
Batch Configuration
|
||||
</h1>
|
||||
|
||||
{/* Panel */}
|
||||
{tenantId ? (
|
||||
<BatchConfigPanel tenantId={tenantId} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-2">
|
||||
<Building2 className="h-6 w-6 mx-auto text-text-muted" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
frontend/src/routes/_authenticated/bulk-commands.tsx
Normal file
74
frontend/src/routes/_authenticated/bulk-commands.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Bulk Commands page route -- /_authenticated/bulk-commands
|
||||
*
|
||||
* Allows operators to execute a RouterOS CLI command across multiple
|
||||
* devices at once using a 3-step wizard. Requires at least operator role.
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Building2, Terminal, ChevronRight } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { BulkCommandWizard } from '@/components/operations/BulkCommandWizard'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/bulk-commands')({
|
||||
component: BulkCommandsPage,
|
||||
})
|
||||
|
||||
function BulkCommandsPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = isSuperAdmin(user)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// RBAC: require at least operator role
|
||||
if (!canWrite(user)) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-text-muted" />
|
||||
Bulk Commands
|
||||
</h1>
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
You need at least operator permissions to use bulk commands.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<span>Home</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Bulk Commands</span>
|
||||
</div>
|
||||
|
||||
{/* Title + tenant selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-text-muted" />
|
||||
Bulk Commands
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Panel */}
|
||||
{tenantId ? (
|
||||
<BulkCommandWizard tenantId={tenantId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Building2 className="h-10 w-10 text-text-muted mb-3" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to view bulk commands.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/routes/_authenticated/certificates.tsx
Normal file
6
frontend/src/routes/_authenticated/certificates.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CertificatesPage } from '@/components/certificates/CertificatesPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/certificates')({
|
||||
component: CertificatesPage,
|
||||
})
|
||||
6
frontend/src/routes/_authenticated/config-editor.tsx
Normal file
6
frontend/src/routes/_authenticated/config-editor.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ConfigEditorPage } from '@/components/config-editor/ConfigEditorPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/config-editor')({
|
||||
component: ConfigEditorPage,
|
||||
})
|
||||
6
frontend/src/routes/_authenticated/firmware.tsx
Normal file
6
frontend/src/routes/_authenticated/firmware.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { FirmwarePage } from '@/components/firmware/FirmwarePage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/firmware')({
|
||||
component: FirmwarePage,
|
||||
})
|
||||
43
frontend/src/routes/_authenticated/index.tsx
Normal file
43
frontend/src/routes/_authenticated/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FleetDashboard } from '@/components/fleet/FleetDashboard'
|
||||
import { CardGridSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { tenantsApi } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/')({
|
||||
component: FleetDashboardPage,
|
||||
})
|
||||
|
||||
function FleetDashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Only check for super_admin -- regular users always have a tenant
|
||||
const shouldCheck = isSuperAdmin(user)
|
||||
|
||||
const { data: tenants, isLoading: tenantsLoading } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: tenantsApi.list,
|
||||
enabled: shouldCheck,
|
||||
})
|
||||
|
||||
// Filter out the System (Internal) tenant — only real customer tenants count
|
||||
const realTenants = tenants?.filter(
|
||||
(t) => t.id !== '00000000-0000-0000-0000-000000000000',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldCheck && !tenantsLoading && tenants && realTenants && realTenants.length === 0) {
|
||||
void navigate({ to: '/setup' })
|
||||
}
|
||||
}, [shouldCheck, tenantsLoading, tenants, realTenants, navigate])
|
||||
|
||||
// Show skeleton while checking (super_admin only)
|
||||
if (shouldCheck && tenantsLoading) {
|
||||
return <CardGridSkeleton />
|
||||
}
|
||||
|
||||
return <FleetDashboard />
|
||||
}
|
||||
75
frontend/src/routes/_authenticated/maintenance.tsx
Normal file
75
frontend/src/routes/_authenticated/maintenance.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Maintenance Windows page route -- /_authenticated/maintenance
|
||||
*
|
||||
* Allows operators to schedule maintenance windows with alert suppression.
|
||||
* Shows active, upcoming, and past windows in a timeline layout.
|
||||
* Requires at least operator role.
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Building2, Calendar, ChevronRight } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { MaintenanceList } from '@/components/maintenance/MaintenanceList'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/maintenance')({
|
||||
component: MaintenancePage,
|
||||
})
|
||||
|
||||
function MaintenancePage() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = isSuperAdmin(user)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// RBAC: require at least operator role
|
||||
if (!canWrite(user)) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-text-muted" />
|
||||
Maintenance Windows
|
||||
</h1>
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
You need at least operator permissions to manage maintenance windows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<span>Home</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Maintenance</span>
|
||||
</div>
|
||||
|
||||
{/* Title + tenant selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-text-muted" />
|
||||
Maintenance Windows
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
{tenantId ? (
|
||||
<MaintenanceList tenantId={tenantId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Building2 className="h-10 w-10 text-text-muted mb-3" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to view maintenance windows.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/routes/_authenticated/map.tsx
Normal file
6
frontend/src/routes/_authenticated/map.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { MapPage } from '@/components/map/MapPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/map')({
|
||||
component: MapPage,
|
||||
})
|
||||
73
frontend/src/routes/_authenticated/reports.tsx
Normal file
73
frontend/src/routes/_authenticated/reports.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Reports page route -- /_authenticated/reports
|
||||
*
|
||||
* Allows operators to generate and download device inventory,
|
||||
* metrics summary, alert history, and change log reports.
|
||||
* Uses the global org selector in the header for tenant context.
|
||||
* Requires at least operator role.
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Building2, FileText, ChevronRight } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { ReportsPage } from '@/components/reports/ReportsPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/reports')({
|
||||
component: ReportsPageRoute,
|
||||
})
|
||||
|
||||
function ReportsPageRoute() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = isSuperAdmin(user)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// RBAC: require at least operator role
|
||||
if (!canWrite(user)) {
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-text-muted" />
|
||||
Reports
|
||||
</h1>
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
You need at least operator permissions to generate reports.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<span>Home</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Reports</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-text-muted" />
|
||||
Reports
|
||||
</h1>
|
||||
|
||||
{/* Reports panel */}
|
||||
{tenantId ? (
|
||||
<ReportsPage tenantId={tenantId} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface p-12 text-center">
|
||||
<Building2 className="h-8 w-8 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to view reports.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
frontend/src/routes/_authenticated/settings.api-keys.tsx
Normal file
46
frontend/src/routes/_authenticated/settings.api-keys.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ShieldAlert, Building2 } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { ApiKeysPage } from '@/components/settings/ApiKeysPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/settings/api-keys')({
|
||||
component: ApiKeysRoute,
|
||||
})
|
||||
|
||||
function ApiKeysRoute() {
|
||||
const { user } = useAuth()
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// RBAC: only tenant_admin+ can manage API keys
|
||||
if (!isTenantAdmin(user)) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="rounded-lg border border-border bg-surface px-6 py-12 text-center">
|
||||
<ShieldAlert className="h-10 w-10 text-text-muted mx-auto mb-3" />
|
||||
<h2 className="text-sm font-medium mb-1">Access Denied</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
You need tenant admin or higher permissions to manage API keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
{!tenantId ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-2">
|
||||
<Building2 className="h-6 w-6 mx-auto text-text-muted" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to manage API keys.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ApiKeysPage tenantId={tenantId} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/routes/_authenticated/settings.tsx
Normal file
6
frontend/src/routes/_authenticated/settings.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SettingsPage } from '@/components/settings/SettingsPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
10
frontend/src/routes/_authenticated/setup.tsx
Normal file
10
frontend/src/routes/_authenticated/setup.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { SetupWizard } from '@/components/setup/SetupWizard'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/setup')({
|
||||
component: SetupPage,
|
||||
})
|
||||
|
||||
function SetupPage() {
|
||||
return <SetupWizard />
|
||||
}
|
||||
6
frontend/src/routes/_authenticated/templates.tsx
Normal file
6
frontend/src/routes/_authenticated/templates.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TemplatesPage } from '@/components/templates/TemplatesPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/templates')({
|
||||
component: TemplatesPage,
|
||||
})
|
||||
@@ -0,0 +1,883 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Circle,
|
||||
Tag,
|
||||
FolderOpen,
|
||||
BellOff,
|
||||
BellRing,
|
||||
CheckCircle,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
Shield,
|
||||
} from 'lucide-react'
|
||||
import { devicesApi, deviceGroupsApi, deviceTagsApi, tenantsApi, type DeviceResponse, type DeviceUpdate } from '@/lib/api'
|
||||
import { alertsApi } from '@/lib/alertsApi'
|
||||
import { useAuth, canWrite, canDelete } from '@/lib/auth'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatUptime, formatDateTime, formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { DetailPageSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { InterfaceGauges } from '@/components/network/InterfaceGauges'
|
||||
// Phase 27: Simple Configuration Interface
|
||||
import { useSimpleConfigMode } from '@/hooks/useSimpleConfig'
|
||||
import { SimpleModeToggle } from '@/components/simple-config/SimpleModeToggle'
|
||||
import { SimpleConfigView } from '@/components/simple-config/SimpleConfigView'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_authenticated/tenants/$tenantId/devices/$deviceId',
|
||||
)({
|
||||
component: DeviceDetailPage,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Device Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditDeviceDialog({
|
||||
device,
|
||||
tenantId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
device: DeviceResponse
|
||||
tenantId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState<DeviceUpdate>({
|
||||
hostname: device.hostname,
|
||||
ip_address: device.ip_address,
|
||||
api_port: device.api_port,
|
||||
api_ssl_port: device.api_ssl_port,
|
||||
username: '',
|
||||
password: '',
|
||||
latitude: device.latitude ?? undefined,
|
||||
longitude: device.longitude ?? undefined,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: DeviceUpdate) => devicesApi.update(tenantId, device.id, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, device.id] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
|
||||
toast({ title: 'Device updated' })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to update device', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Only send fields that are non-empty strings / defined numbers
|
||||
const payload: DeviceUpdate = {
|
||||
hostname: form.hostname || undefined,
|
||||
ip_address: form.ip_address || undefined,
|
||||
api_port: form.api_port,
|
||||
api_ssl_port: form.api_ssl_port,
|
||||
latitude: form.latitude,
|
||||
longitude: form.longitude,
|
||||
}
|
||||
// Only include credentials if the user typed something
|
||||
if (form.username) payload.username = form.username
|
||||
if (form.password) payload.password = form.password
|
||||
updateMutation.mutate(payload)
|
||||
}
|
||||
|
||||
const field = (
|
||||
id: string,
|
||||
label: string,
|
||||
value: string | number | undefined,
|
||||
onChange: (v: string) => void,
|
||||
opts?: { type?: string; placeholder?: string },
|
||||
) => (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id} className="text-xs text-text-secondary">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type={opts?.type ?? 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={opts?.placeholder}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="dialog-edit-device">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Device</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{field('hostname', 'Hostname', form.hostname, (v) => setForm((f) => ({ ...f, hostname: v })))}
|
||||
{field('ip_address', 'IP Address', form.ip_address, (v) => setForm((f) => ({ ...f, ip_address: v })))}
|
||||
{field('api_port', 'API Port', form.api_port, (v) => setForm((f) => ({ ...f, api_port: parseInt(v) || undefined })), { type: 'number' })}
|
||||
{field('api_ssl_port', 'API TLS Port', form.api_ssl_port, (v) => setForm((f) => ({ ...f, api_ssl_port: parseInt(v) || undefined })), { type: 'number' })}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 space-y-1">
|
||||
<p className="text-xs text-text-muted mb-2">Leave blank to keep existing credentials</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{field('username', 'Username', form.username, (v) => setForm((f) => ({ ...f, username: v })), { placeholder: 'unchanged' })}
|
||||
{field('password', 'Password', form.password, (v) => setForm((f) => ({ ...f, password: v })), { type: 'password', placeholder: 'unchanged' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3">
|
||||
<p className="text-xs text-text-muted mb-2">GPS coordinates (optional)</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{field('latitude', 'Latitude', form.latitude, (v) => setForm((f) => ({ ...f, latitude: v ? parseFloat(v) : undefined })), { type: 'number', placeholder: '0.000000' })}
|
||||
{field('longitude', 'Longitude', form.longitude, (v) => setForm((f) => ({ ...f, longitude: v ? parseFloat(v) : undefined })), { type: 'number', placeholder: '0.000000' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
online: { label: 'Online', className: 'text-success border-success/50 bg-success/10' },
|
||||
offline: { label: 'Offline', className: 'text-error border-error/50 bg-error/10' },
|
||||
unknown: { label: 'Unknown', className: 'text-text-muted border-border bg-elevated/50' },
|
||||
}
|
||||
const c = config[status] ?? config.unknown
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border', c.className)}>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
{c.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TlsSecurityBadge({ tlsMode }: { tlsMode: string }) {
|
||||
const config: Record<string, { label: string; icon: React.ElementType; className: string }> = {
|
||||
portal_ca: {
|
||||
label: 'CA Verified',
|
||||
icon: ShieldCheck,
|
||||
className: 'text-success border-success/50 bg-success/10',
|
||||
},
|
||||
auto: {
|
||||
label: 'Self-Signed TLS',
|
||||
icon: Shield,
|
||||
className: 'text-warning border-warning/50 bg-warning/10',
|
||||
},
|
||||
insecure: {
|
||||
label: 'Insecure TLS',
|
||||
icon: ShieldAlert,
|
||||
className: 'text-orange-400 border-orange-400/50 bg-orange-400/10',
|
||||
},
|
||||
plain: {
|
||||
label: 'Plain-Text (Insecure)',
|
||||
icon: ShieldOff,
|
||||
className: 'text-error border-error/50 bg-error/10',
|
||||
},
|
||||
}
|
||||
const c = config[tlsMode] ?? config.auto
|
||||
const Icon = c.icon
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border', c.className)}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{c.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TlsModeSelector({
|
||||
tenantId,
|
||||
deviceId,
|
||||
currentMode,
|
||||
}: {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
currentMode: string
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmPlain, setConfirmPlain] = useState(false)
|
||||
const [pendingMode, setPendingMode] = useState<string | null>(null)
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (mode: string) => devicesApi.update(tenantId, deviceId, { tls_mode: mode }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
|
||||
toast({ title: 'TLS mode updated' })
|
||||
setConfirmPlain(false)
|
||||
setPendingMode(null)
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to update TLS mode', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (value === 'plain') {
|
||||
setPendingMode(value)
|
||||
setConfirmPlain(true)
|
||||
} else {
|
||||
updateMutation.mutate(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select value={currentMode} onValueChange={handleChange}>
|
||||
<SelectTrigger className="h-7 text-xs w-36" data-testid="select-tls-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto (recommended)</SelectItem>
|
||||
<SelectItem value="portal_ca">CA Verified</SelectItem>
|
||||
<SelectItem value="insecure">Insecure TLS</SelectItem>
|
||||
<SelectItem value="plain">Plain-Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={confirmPlain} onOpenChange={setConfirmPlain}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-error">
|
||||
<ShieldOff className="h-5 w-5" />
|
||||
Enable Plain-Text Connection?
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm text-text-secondary">
|
||||
<p>
|
||||
Plain-text mode sends credentials and all data unencrypted over the network.
|
||||
This is a serious security risk and should only be used for devices that
|
||||
do not support TLS at all.
|
||||
</p>
|
||||
<div className="rounded border border-error/30 bg-error/5 px-3 py-2 text-xs text-error">
|
||||
Credentials will be transmitted in clear text. Anyone on the network
|
||||
can intercept them.
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmPlain(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => pendingMode && updateMutation.mutate(pendingMode)}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Enable Plain-Text'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 py-2 border-b border-border/50 last:border-0">
|
||||
<span className="text-xs text-text-muted w-32 flex-shrink-0 pt-0.5">{label}</span>
|
||||
<span className="text-sm text-text-primary flex-1">{value ?? '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceDetailPage() {
|
||||
const { tenantId, deviceId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const [showCreds, setShowCreds] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const { mode, toggleMode } = useSimpleConfigMode(deviceId)
|
||||
|
||||
const { data: device, isLoading } = useQuery({
|
||||
queryKey: ['device', tenantId, deviceId],
|
||||
queryFn: () => devicesApi.get(tenantId, deviceId),
|
||||
})
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ['tenants', tenantId],
|
||||
queryFn: () => tenantsApi.get(tenantId),
|
||||
})
|
||||
|
||||
const { data: groups } = useQuery({
|
||||
queryKey: ['device-groups', tenantId],
|
||||
queryFn: () => deviceGroupsApi.list(tenantId),
|
||||
enabled: canWrite(user),
|
||||
})
|
||||
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ['device-tags', tenantId],
|
||||
queryFn: () => deviceTagsApi.list(tenantId),
|
||||
enabled: canWrite(user),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => devicesApi.delete(tenantId, deviceId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['tenants'] })
|
||||
toast({ title: 'Device deleted' })
|
||||
void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId } })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to delete device', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const addToGroupMutation = useMutation({
|
||||
mutationFn: (groupId: string) => devicesApi.addToGroup(tenantId, deviceId, groupId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to add to group', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const removeFromGroupMutation = useMutation({
|
||||
mutationFn: (groupId: string) => devicesApi.removeFromGroup(tenantId, deviceId, groupId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to remove from group', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const addTagMutation = useMutation({
|
||||
mutationFn: (tagId: string) => devicesApi.addTag(tenantId, deviceId, tagId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to add tag', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const removeTagMutation = useMutation({
|
||||
mutationFn: (tagId: string) => devicesApi.removeTag(tenantId, deviceId, tagId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to remove tag', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(`Delete device "${device?.hostname}"? This cannot be undone.`)) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <DetailPageSkeleton />
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return <div className="text-text-muted text-sm">Device not found</div>
|
||||
}
|
||||
|
||||
const deviceGroupIds = new Set(device.groups.map((g) => g.id))
|
||||
const deviceTagIds = new Set(device.tags.map((t) => t.id))
|
||||
|
||||
const availableGroups = groups?.filter((g) => !deviceGroupIds.has(g.id)) ?? []
|
||||
const availableTags = tags?.filter((t) => !deviceTagIds.has(t.id)) ?? []
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', mode === 'simple' ? 'max-w-5xl' : 'max-w-3xl')} data-testid="device-detail">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
|
||||
Tenants
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
{tenant?.name ?? tenantId}
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">{device.hostname}</span>
|
||||
</div>
|
||||
|
||||
{/* Device header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold" data-testid="device-hostname">{device.hostname}</h1>
|
||||
<StatusBadge status={device.status} />
|
||||
<TlsSecurityBadge tlsMode={device.tls_mode} />
|
||||
</div>
|
||||
<p className="font-mono text-sm text-text-secondary">{device.ip_address}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SimpleModeToggle mode={mode} onModeChange={toggleMode} />
|
||||
<div className="flex gap-2">
|
||||
{canWrite(user) && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)} data-testid="button-edit-device">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{canDelete(user) && (
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} data-testid="button-delete-device">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config View (Simple or Standard) */}
|
||||
<SimpleConfigView
|
||||
tenantId={tenantId}
|
||||
deviceId={deviceId}
|
||||
device={device}
|
||||
mode={mode}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onModeChange={toggleMode}
|
||||
overviewContent={
|
||||
<>
|
||||
{/* Device info */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-2">
|
||||
<InfoRow label="Model" value={device.model} />
|
||||
<InfoRow label="RouterOS" value={device.routeros_version} />
|
||||
<InfoRow label="Firmware" value={device.firmware_version || 'N/A'} />
|
||||
<InfoRow label="Uptime" value={formatUptime(device.uptime_seconds)} />
|
||||
<InfoRow label="Last Seen" value={formatDateTime(device.last_seen)} />
|
||||
<InfoRow label="Serial" value={device.serial_number || 'N/A'} />
|
||||
<InfoRow label="API Port" value={`${device.api_port} (plain) / ${device.api_ssl_port} (TLS)`} />
|
||||
<InfoRow
|
||||
label="TLS Mode"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<TlsSecurityBadge tlsMode={device.tls_mode} />
|
||||
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||
<TlsModeSelector
|
||||
tenantId={tenantId}
|
||||
deviceId={device.id}
|
||||
currentMode={device.tls_mode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Added" value={formatDate(device.created_at)} />
|
||||
</div>
|
||||
|
||||
{/* Credentials (masked) */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-text-secondary">Credentials</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreds((v) => !v)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{showCreds ? (
|
||||
<>
|
||||
<EyeOff className="h-3 w-3" /> Hide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3 w-3" /> Reveal
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex gap-4">
|
||||
<span className="text-xs text-text-muted w-20">Username</span>
|
||||
<span className="font-mono">
|
||||
{showCreds ? '(stored \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<span className="text-xs text-text-muted w-20">Password</span>
|
||||
<span className="font-mono">
|
||||
{showCreds ? '(encrypted at rest \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-text-muted" />
|
||||
<h3 className="text-sm font-medium text-text-secondary">Groups</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{device.groups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="flex items-center gap-1 text-xs border border-border-bright rounded px-2 py-1"
|
||||
>
|
||||
{group.name}
|
||||
{canWrite(user) && (
|
||||
<button
|
||||
onClick={() => removeFromGroupMutation.mutate(group.id)}
|
||||
className="text-text-muted hover:text-text-secondary ml-1"
|
||||
title="Remove from group"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{device.groups.length === 0 && (
|
||||
<span className="text-xs text-text-muted">No groups assigned</span>
|
||||
)}
|
||||
</div>
|
||||
{canWrite(user) && availableGroups.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select onValueChange={(id) => addToGroupMutation.mutate(id)}>
|
||||
<SelectTrigger className="h-7 text-xs w-48">
|
||||
<SelectValue placeholder="Add to group..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableGroups.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-text-muted" />
|
||||
<h3 className="text-sm font-medium text-text-secondary">Tags</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{device.tags.map((tag) => (
|
||||
<div key={tag.id} className="flex items-center gap-1">
|
||||
<Badge color={tag.color}>
|
||||
{tag.name}
|
||||
{canWrite(user) && (
|
||||
<button
|
||||
onClick={() => removeTagMutation.mutate(tag.id)}
|
||||
className="ml-1 opacity-60 hover:opacity-100"
|
||||
title="Remove tag"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{device.tags.length === 0 && (
|
||||
<span className="text-xs text-text-muted">No tags assigned</span>
|
||||
)}
|
||||
</div>
|
||||
{canWrite(user) && availableTags.length > 0 && (
|
||||
<Select onValueChange={(id) => addTagMutation.mutate(id)}>
|
||||
<SelectTrigger className="h-7 text-xs w-48">
|
||||
<SelectValue placeholder="Add tag..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTags.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interface Utilization */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">Interface Utilization</h3>
|
||||
<InterfaceGauges tenantId={tenantId} deviceId={deviceId} active={activeTab === 'overview'} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
alertsContent={
|
||||
<DeviceAlertsSection tenantId={tenantId} deviceId={deviceId} active={activeTab === 'alerts'} />
|
||||
}
|
||||
/>
|
||||
|
||||
{canWrite(user) && (
|
||||
<EditDeviceDialog
|
||||
device={device}
|
||||
tenantId={tenantId}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device Alerts Section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config: Record<string, string> = {
|
||||
critical: 'bg-error/20 text-error border-error/40',
|
||||
warning: 'bg-warning/20 text-warning border-warning/40',
|
||||
info: 'bg-info/20 text-info border-info/40',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
|
||||
config[severity] ?? config.info,
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceAlertsSection({
|
||||
tenantId,
|
||||
deviceId,
|
||||
active,
|
||||
}: {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
active: boolean
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const [showResolved, setShowResolved] = useState(false)
|
||||
|
||||
const { data: alertsData, isLoading } = useQuery({
|
||||
queryKey: ['device-alerts', tenantId, deviceId],
|
||||
queryFn: () => alertsApi.getDeviceAlerts(tenantId, deviceId, { per_page: 20 }),
|
||||
enabled: active,
|
||||
refetchInterval: active ? 30_000 : undefined,
|
||||
})
|
||||
|
||||
const acknowledgeMutation = useMutation({
|
||||
mutationFn: (alertId: string) => alertsApi.acknowledgeAlert(tenantId, alertId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device-alerts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
|
||||
toast({ title: 'Alert acknowledged' })
|
||||
},
|
||||
})
|
||||
|
||||
const silenceMutation = useMutation({
|
||||
mutationFn: ({ alertId, minutes }: { alertId: string; minutes: number }) =>
|
||||
alertsApi.silenceAlert(tenantId, alertId, minutes),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['device-alerts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
|
||||
toast({ title: 'Alert silenced' })
|
||||
},
|
||||
})
|
||||
|
||||
const alerts = alertsData?.items ?? []
|
||||
const firingAlerts = alerts.filter((a) => a.status === 'firing')
|
||||
const resolvedAlerts = alerts.filter((a) => a.status === 'resolved').slice(0, 5)
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={3} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Active alerts */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text-secondary mb-2 flex items-center gap-2">
|
||||
<BellRing className="h-4 w-4" />
|
||||
Active Alerts
|
||||
{firingAlerts.length > 0 && (
|
||||
<span className="bg-error/20 text-error text-xs px-1.5 rounded-full">
|
||||
{firingAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{firingAlerts.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
<CheckCircle className="h-6 w-6 text-success/50 mx-auto mb-1" />
|
||||
<p className="text-xs text-text-muted">No active alerts for this device.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
{firingAlerts.map((alert) => {
|
||||
const isSilenced =
|
||||
alert.silenced_until && new Date(alert.silenced_until) > new Date()
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border/50 last:border-0"
|
||||
>
|
||||
<BellRing className="h-4 w-4 text-error flex-shrink-0" />
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-text-primary truncate block">
|
||||
{alert.message ?? `${alert.metric} ${alert.value ?? ''}`}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{alert.rule_name && `${alert.rule_name} — `}
|
||||
{alert.threshold != null &&
|
||||
`${alert.value != null ? Number(alert.value).toFixed(1) : '?'} / ${alert.threshold}`}
|
||||
{' — '}
|
||||
{timeAgo(alert.fired_at)}
|
||||
{isSilenced && ' (silenced)'}
|
||||
</span>
|
||||
</div>
|
||||
{!alert.acknowledged_at && canWrite(user) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => acknowledgeMutation.mutate(alert.id)}
|
||||
>
|
||||
Ack
|
||||
</Button>
|
||||
)}
|
||||
{canWrite(user) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
<BellOff className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
silenceMutation.mutate({ alertId: alert.id, minutes: 15 })
|
||||
}
|
||||
>
|
||||
15 min
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
silenceMutation.mutate({ alertId: alert.id, minutes: 60 })
|
||||
}
|
||||
>
|
||||
1 hour
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
silenceMutation.mutate({ alertId: alert.id, minutes: 240 })
|
||||
}
|
||||
>
|
||||
4 hours
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
silenceMutation.mutate({ alertId: alert.id, minutes: 1440 })
|
||||
}
|
||||
>
|
||||
24 hours
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resolved alerts */}
|
||||
{resolvedAlerts.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowResolved((v) => !v)}
|
||||
className="text-sm font-medium text-text-muted hover:text-text-secondary flex items-center gap-2 mb-2"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Recent Resolved ({resolvedAlerts.length})
|
||||
<span className="text-xs">{showResolved ? '(hide)' : '(show)'}</span>
|
||||
</button>
|
||||
|
||||
{showResolved && (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
{resolvedAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center gap-3 px-4 py-2 border-b border-border/50 last:border-0 opacity-60"
|
||||
>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-success flex-shrink-0" />
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
<span className="text-xs text-text-secondary flex-1 truncate">
|
||||
{alert.message ?? alert.metric ?? 'System alert'}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{alert.resolved_at ? timeAgo(alert.resolved_at) : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link to full alerts page */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/alerts"
|
||||
className="text-xs text-info hover:text-accent"
|
||||
>
|
||||
View all alerts for this device
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { tenantsApi } from '@/lib/api'
|
||||
import { AddDeviceForm } from '@/components/fleet/AddDeviceForm'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/devices/add')({
|
||||
component: AddDevicePage,
|
||||
})
|
||||
|
||||
function AddDevicePage() {
|
||||
const { tenantId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ['tenants', tenantId],
|
||||
queryFn: () => tenantsApi.get(tenantId),
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
|
||||
Tenants
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
{tenant?.name ?? tenantId} — Devices
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Add Device</span>
|
||||
</div>
|
||||
|
||||
<AddDeviceForm tenantId={tenantId} open={open} onClose={handleClose} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Device Adoption page route -- /_authenticated/tenants/$tenantId/devices/adopt
|
||||
*
|
||||
* 5-step wizard for discovering, configuring, and importing MikroTik devices.
|
||||
*/
|
||||
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { tenantsApi } from '@/lib/api'
|
||||
import { AdoptionWizard } from '@/components/fleet/AdoptionWizard'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_authenticated/tenants/$tenantId/devices/adopt',
|
||||
)({
|
||||
component: AdoptPage,
|
||||
})
|
||||
|
||||
function AdoptPage() {
|
||||
const { tenantId } = Route.useParams()
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ['tenants', tenantId],
|
||||
queryFn: () => tenantsApi.get(tenantId),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
|
||||
Tenants
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
{tenant?.name ?? tenantId}
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
Devices
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Adopt Devices</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-lg font-semibold">Adopt Devices</h1>
|
||||
|
||||
<AdoptionWizard tenantId={tenantId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { Plus, Scan, ChevronRight } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { tenantsApi } from '@/lib/api'
|
||||
import { useAuth, canWrite } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FleetTable } from '@/components/fleet/FleetTable'
|
||||
import { DeviceFilters } from '@/components/fleet/DeviceFilters'
|
||||
import { AddDeviceForm } from '@/components/fleet/AddDeviceForm'
|
||||
|
||||
const searchSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
sort_by: z.string().optional(),
|
||||
sort_dir: z.enum(['asc', 'desc']).optional(),
|
||||
page: z.number().int().positive().optional(),
|
||||
page_size: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/devices/')({
|
||||
validateSearch: searchSchema,
|
||||
component: DevicesPage,
|
||||
})
|
||||
|
||||
function DevicesPage() {
|
||||
const { tenantId } = Route.useParams()
|
||||
const search = Route.useSearch()
|
||||
const { user } = useAuth()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ['tenants', tenantId],
|
||||
queryFn: () => tenantsApi.get(tenantId),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
|
||||
Tenants
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
{tenant?.name ?? tenantId}
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Devices</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<DeviceFilters tenantId={tenantId} />
|
||||
{canWrite(user) && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Link to="/tenants/$tenantId/devices/scan" params={{ tenantId }}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Scan className="h-3.5 w-3.5" />
|
||||
Scan Subnet
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Device
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FleetTable
|
||||
tenantId={tenantId}
|
||||
search={search.search}
|
||||
status={search.status}
|
||||
sortBy={search.sort_by}
|
||||
sortDir={search.sort_dir}
|
||||
page={search.page}
|
||||
pageSize={search.page_size}
|
||||
/>
|
||||
|
||||
<AddDeviceForm tenantId={tenantId} open={addOpen} onClose={() => setAddOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { tenantsApi, type SubnetScanResponse } from '@/lib/api'
|
||||
import { SubnetScanForm } from '@/components/fleet/SubnetScanForm'
|
||||
import { ScanResultsList } from '@/components/fleet/ScanResultsList'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/devices/scan')({
|
||||
component: ScanPage,
|
||||
})
|
||||
|
||||
function ScanPage() {
|
||||
const { tenantId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const [results, setResults] = useState<SubnetScanResponse | null>(null)
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ['tenants', tenantId],
|
||||
queryFn: () => tenantsApi.get(tenantId),
|
||||
})
|
||||
|
||||
const handleDone = () => {
|
||||
void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
|
||||
Tenants
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
{tenant?.name ?? tenantId}
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
Devices
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Scan Subnet</span>
|
||||
</div>
|
||||
|
||||
<SubnetScanForm tenantId={tenantId} onResults={setResults} />
|
||||
|
||||
{results && (
|
||||
<ScanResultsList tenantId={tenantId} results={results} onDone={handleDone} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Users, Monitor, Building2 } from 'lucide-react'
|
||||
import { tenantsApi } from '@/lib/api'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { CardGridSkeleton } from '@/components/ui/page-skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/')({
|
||||
component: TenantDetailPage,
|
||||
})
|
||||
|
||||
function TenantDetailPage() {
|
||||
const { tenantId } = Route.useParams()
|
||||
|
||||
const { data: tenant, isLoading } = useQuery({
|
||||
queryKey: ['tenants', tenantId],
|
||||
queryFn: () => tenantsApi.get(tenantId),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <CardGridSkeleton cards={2} />
|
||||
}
|
||||
|
||||
if (!tenant) {
|
||||
return <div className="text-text-muted text-sm">Tenant not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-elevated flex items-center justify-center flex-shrink-0">
|
||||
<Building2 className="h-5 w-5 text-text-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">{tenant.name}</h1>
|
||||
{tenant.description && (
|
||||
<p className="text-sm text-text-secondary mt-0.5">{tenant.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-text-muted mt-1">Created {formatDate(tenant.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/tenants/$tenantId/users"
|
||||
params={{ tenantId }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-surface p-4 hover:bg-elevated/50 transition-colors group"
|
||||
>
|
||||
<Users className="h-8 w-8 text-text-muted group-hover:text-text-muted transition-colors" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{tenant.user_count}</p>
|
||||
<p className="text-xs text-text-secondary">Users</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices"
|
||||
params={{ tenantId }}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-surface p-4 hover:bg-elevated/50 transition-colors group"
|
||||
>
|
||||
<Monitor className="h-8 w-8 text-text-muted group-hover:text-text-muted transition-colors" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{tenant.device_count}</p>
|
||||
<p className="text-xs text-text-secondary">Devices</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to="/tenants/$tenantId/users"
|
||||
params={{ tenantId }}
|
||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
Manage users
|
||||
</Link>
|
||||
<span className="text-text-muted">·</span>
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices"
|
||||
params={{ tenantId }}
|
||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
View fleet
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { tenantsApi } from '@/lib/api'
|
||||
import { UserList } from '@/components/users/UserList'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/users')({
|
||||
component: UsersPage,
|
||||
})
|
||||
|
||||
function UsersPage() {
|
||||
const { tenantId } = Route.useParams()
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ['tenants', tenantId],
|
||||
queryFn: () => tenantsApi.get(tenantId),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
|
||||
Tenants
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
to="/tenants/$tenantId"
|
||||
params={{ tenantId }}
|
||||
className="hover:text-text-secondary transition-colors"
|
||||
>
|
||||
{tenant?.name ?? tenantId}
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Users</span>
|
||||
</div>
|
||||
|
||||
<UserList tenantId={tenantId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
frontend/src/routes/_authenticated/tenants/index.tsx
Normal file
10
frontend/src/routes/_authenticated/tenants/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TenantList } from '@/components/tenants/TenantList'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/')({
|
||||
component: TenantsPage,
|
||||
})
|
||||
|
||||
function TenantsPage() {
|
||||
return <TenantList />
|
||||
}
|
||||
59
frontend/src/routes/_authenticated/topology.tsx
Normal file
59
frontend/src/routes/_authenticated/topology.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Topology page route -- /_authenticated/topology
|
||||
*
|
||||
* Renders a full-height reactflow network topology map for the user's tenant.
|
||||
* Uses the global org selector in the header for tenant context.
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Building2, Network, ChevronRight } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { TopologyMap } from '@/components/network/TopologyMap'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/topology')({
|
||||
component: TopologyPage,
|
||||
})
|
||||
|
||||
function TopologyPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = isSuperAdmin(user)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 pt-4 pb-3 space-y-3">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<span>Home</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span className="text-text-secondary">Topology</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-text-muted" />
|
||||
Network Topology
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Topology map (full remaining height) */}
|
||||
<div className="flex-1 min-h-0 mx-4 mb-4 rounded-lg border border-border bg-surface overflow-hidden">
|
||||
{tenantId ? (
|
||||
<TopologyMap tenantId={tenantId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Building2 className="h-8 w-8 text-text-muted mb-3" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to view the network topology.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
frontend/src/routes/_authenticated/transparency.tsx
Normal file
73
frontend/src/routes/_authenticated/transparency.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Data Transparency page route -- /_authenticated/transparency
|
||||
*
|
||||
* Displays a filterable timeline of every KMS credential access event.
|
||||
* Uses the global org selector in the header for tenant context.
|
||||
* Admin-only access (tenant_admin, super_admin).
|
||||
*
|
||||
* Phase 31 -- TRUST-01, TRUST-02
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Building2, Eye } from 'lucide-react'
|
||||
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { TransparencyLogTable } from '@/components/transparency/TransparencyLogTable'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/transparency')({
|
||||
component: TransparencyPage,
|
||||
})
|
||||
|
||||
function TransparencyPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuper = isSuperAdmin(user)
|
||||
const isAdmin = isTenantAdmin(user)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// RBAC: require at least admin role (tenant_admin or super_admin)
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="max-w-6xl space-y-4">
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-text-muted" />
|
||||
Data Transparency
|
||||
</h1>
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
You need admin permissions to view the transparency dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-text-muted" />
|
||||
Data Transparency
|
||||
</h1>
|
||||
<p className="text-sm text-text-muted mt-0.5">
|
||||
Track every credential access event across your devices
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transparency log table or empty state */}
|
||||
{tenantId ? (
|
||||
<TransparencyLogTable tenantId={tenantId} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface p-12 text-center">
|
||||
<Building2 className="h-8 w-8 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm text-text-muted">
|
||||
Select an organization from the header to view transparency logs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/routes/_authenticated/vpn.tsx
Normal file
6
frontend/src/routes/_authenticated/vpn.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { VpnPage } from '@/components/vpn/VpnPage'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/vpn')({
|
||||
component: VpnPage,
|
||||
})
|
||||
Reference in New Issue
Block a user