Files
IT_Tool/Frontend/src/App.tsx
Claude Agent c401ea8f29 feat: Phase 9 — Offline Detection, API Key Auth, Agent Self-Update
Offline Detection (9.1):
- AgentOfflineDetectorService: BackgroundService, prüft alle 60s
  ob Agents seit >5 min kein Heartbeat hatten → Status=Offline
- IServiceScopeFactory für korrektes Scoped-DI im Singleton
- SignalR-Push AgentStatusChanged bei jeder Offline-Markierung

API Key Auth (9.2):
- ApiKeyMiddleware: prüft X-Api-Key Header gegen Security:ApiKey Config
- Deaktiviert wenn ApiKey leer (Dev-Modus), Swagger/hubs bypassed
- Frontend: getApiKey() aus localStorage, automatisch in allen Requests
- Settings-Modal in Sidebar: API-Key eingeben + maskiert anzeigen

Agent Self-Update (9.3):
- internal/updater/updater.go: CheckForUpdate() + Update()
  Download, SHA256-Verify, Windows Batch-Neustart / Linux Shell-Neustart
- AgentReleasesController: GET /api/v1/agent/releases/latest,
  GET /api/v1/agent/releases/download/{platform}
- AgentReleaseOptions: LatestVersion, ReleasePath, Checksum in appsettings
- executeCommand() erhält cfg *Config statt agentID string
  (für ServerAddress-Ableitung im UpdateAgent-Case)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 15:41:24 +01:00

205 lines
7.4 KiB
TypeScript

import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { LayoutDashboard, Ticket, Bell, Package, Network, Menu, X, Settings } from 'lucide-react'
import { DashboardPage } from './pages/DashboardPage'
import { AgentDetailPage } from './pages/AgentDetailPage'
import TicketsPage from './pages/TicketsPage'
import AlertsPage from './pages/AlertsPage'
import SoftwarePage from './pages/SoftwarePage'
import NetworkPage from './pages/NetworkPage'
import { cn } from './lib/utils'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
},
},
})
type Page = 'dashboard' | 'agent-detail' | 'tickets' | 'alerts' | 'network' | 'software'
interface NavItem {
id: Page
label: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{ id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={18} /> },
{ id: 'tickets', label: 'Tickets', icon: <Ticket size={18} /> },
{ id: 'alerts', label: 'Alerts', icon: <Bell size={18} /> },
{ id: 'network', label: 'Netzwerk', icon: <Network size={18} /> },
{ id: 'software', label: 'Software', icon: <Package size={18} /> },
]
function AppContent() {
const [page, setPage] = useState<Page>('dashboard')
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const [apiKeyInput, setApiKeyInput] = useState('')
const storedKey = localStorage.getItem('nexusrmm_api_key') ?? ''
const maskedKey = storedKey.length > 0
? storedKey.substring(0, Math.min(8, storedKey.length)) + '...'
: '(nicht gesetzt)'
function handleSaveApiKey() {
localStorage.setItem('nexusrmm_api_key', apiKeyInput)
setSettingsOpen(false)
setApiKeyInput('')
}
function handleSelectAgent(agentId: string) {
setSelectedAgentId(agentId)
setPage('agent-detail')
}
function handleBack() {
setPage('dashboard')
setSelectedAgentId(null)
}
return (
<div className="min-h-screen bg-background text-foreground flex">
{/* Sidebar */}
<aside
className={cn(
'flex flex-col border-r border-border bg-card transition-all duration-200',
sidebarOpen ? 'w-56' : 'w-14',
)}
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-4 border-b border-border">
<div className="w-7 h-7 rounded-md bg-primary flex items-center justify-center text-primary-foreground font-bold text-sm flex-shrink-0">
N
</div>
{sidebarOpen && (
<span className="font-semibold text-foreground truncate">NexusRMM</span>
)}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="ml-auto text-muted-foreground hover:text-foreground"
>
{sidebarOpen ? <X size={16} /> : <Menu size={16} />}
</button>
</div>
{/* Nav */}
<nav className="flex-1 py-3 px-2 flex flex-col gap-1">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => {
setPage(item.id)
setSelectedAgentId(null)
}}
className={cn(
'flex items-center gap-3 px-2 py-2 rounded-md text-sm transition-colors w-full text-left',
page === item.id || (page === 'agent-detail' && item.id === 'dashboard')
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
<span className="flex-shrink-0">{item.icon}</span>
{sidebarOpen && <span>{item.label}</span>}
</button>
))}
</nav>
{/* Settings + Version */}
<div className="border-t border-border px-2 py-2 flex flex-col gap-1">
<button
onClick={() => {
setApiKeyInput(localStorage.getItem('nexusrmm_api_key') ?? '')
setSettingsOpen(true)
}}
className="flex items-center gap-3 px-2 py-2 rounded-md text-sm transition-colors w-full text-left text-muted-foreground hover:text-foreground hover:bg-accent"
>
<span className="flex-shrink-0"><Settings size={18} /></span>
{sidebarOpen && <span>Einstellungen</span>}
</button>
{sidebarOpen && (
<div className="px-2 py-1 text-xs text-muted-foreground">
NexusRMM v0.1.0
</div>
)}
</div>
</aside>
{/* Settings Modal */}
{settingsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border border-border rounded-lg shadow-lg w-96 p-6 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">Einstellungen</h2>
<button
onClick={() => setSettingsOpen(false)}
className="text-muted-foreground hover:text-foreground"
>
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground">API-Key</label>
<p className="text-xs text-muted-foreground">
Aktuell: <span className="font-mono">{maskedKey}</span>
</p>
<input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="API-Key eingeben..."
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p className="text-xs text-muted-foreground">
Leer lassen um Authentifizierung zu deaktivieren.
</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setSettingsOpen(false)}
className="px-4 py-2 text-sm rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSaveApiKey}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Speichern
</button>
</div>
</div>
</div>
)}
{/* Main content */}
<main className="flex-1 overflow-auto">
{page === 'dashboard' && (
<DashboardPage onSelectAgent={handleSelectAgent} />
)}
{page === 'agent-detail' && selectedAgentId && (
<AgentDetailPage agentId={selectedAgentId} onBack={handleBack} />
)}
{page === 'tickets' && <TicketsPage />}
{page === 'alerts' && <AlertsPage />}
{page === 'network' && <NetworkPage />}
{page === 'software' && <SoftwarePage />}
</main>
</div>
)
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppContent />
</QueryClientProvider>
)
}
export default App