From 8895e296b97e9b2c8df04abd4df10ccf27992986 Mon Sep 17 00:00:00 2001 From: Warp Agent Date: Wed, 24 Dec 2025 20:02:54 +0000 Subject: [PATCH] still working on frontend UI --- PHASE-E-TAPE-COMPLETE.md | 125 +++++++ frontend/src/App.tsx | 8 + frontend/src/api/scst.ts | 113 ++++++ frontend/src/api/tape.ts | 168 +++++++++ frontend/src/pages/ISCSITargetDetail.tsx | 435 +++++++++++++++++++++++ frontend/src/pages/ISCSITargets.tsx | 250 +++++++++++++ frontend/src/pages/TapeLibraries.tsx | 190 ++++++++++ frontend/src/pages/VTLDetail.tsx | 317 +++++++++++++++++ 8 files changed, 1606 insertions(+) create mode 100644 PHASE-E-TAPE-COMPLETE.md create mode 100644 frontend/src/api/scst.ts create mode 100644 frontend/src/api/tape.ts create mode 100644 frontend/src/pages/ISCSITargetDetail.tsx create mode 100644 frontend/src/pages/ISCSITargets.tsx create mode 100644 frontend/src/pages/TapeLibraries.tsx create mode 100644 frontend/src/pages/VTLDetail.tsx diff --git a/PHASE-E-TAPE-COMPLETE.md b/PHASE-E-TAPE-COMPLETE.md new file mode 100644 index 0000000..350f9fa --- /dev/null +++ b/PHASE-E-TAPE-COMPLETE.md @@ -0,0 +1,125 @@ +# Phase E: Tape Library Management UI - Complete ✅ + +## 🎉 Implementation Complete! + +**Date**: 2025-12-24 +**Status**: ✅ **COMPLETE** + +--- + +## ✅ What's Been Implemented + +### 1. API Client (`frontend/src/api/tape.ts`) +- ✅ Complete TypeScript types for all tape library entities +- ✅ Physical Tape Library API functions +- ✅ Virtual Tape Library (VTL) API functions +- ✅ Tape drive and tape management functions +- ✅ Load/unload operations + +### 2. Tape Libraries List Page (`frontend/src/pages/TapeLibraries.tsx`) +- ✅ Tabbed interface (Physical / VTL) +- ✅ Library cards with key information +- ✅ Status indicators (Active/Inactive) +- ✅ Empty states with helpful messages +- ✅ Navigation to detail pages +- ✅ Create VTL button +- ✅ Discover libraries button (physical) + +### 3. VTL Detail Page (`frontend/src/pages/VTLDetail.tsx`) +- ✅ Library overview with status and capacity +- ✅ Drive list with status indicators +- ✅ Tape inventory table +- ✅ Load/unload tape functionality (UI ready) +- ✅ Delete library functionality +- ✅ Real-time data fetching with TanStack Query + +### 4. Routing (`frontend/src/App.tsx`) +- ✅ `/tape` - Tape libraries list +- ✅ `/tape/vtl/:id` - VTL detail page +- ✅ Navigation integrated in Layout + +--- + +## 📊 Features + +### Tape Libraries List +- **Dual View**: Switch between Physical and VTL libraries +- **Library Cards**: Show slots, drives, vendor info +- **Status Badges**: Visual indicators for active/inactive +- **Quick Actions**: Create VTL, Discover libraries +- **Empty States**: Helpful messages when no libraries exist + +### VTL Detail Page +- **Library Info**: Status, mhVTL ID, storage path +- **Capacity Overview**: Total/used/free slots +- **Drive Management**: + - Drive status (idle, ready, loaded) + - Current tape information + - Select drive for operations +- **Tape Inventory**: + - Barcode, slot, size, status + - Load tape to selected drive + - Create new tapes +- **Library Actions**: Delete library + +--- + +## 🎨 UI Components Used + +- **Card**: Library cards, info panels +- **Button**: Actions, navigation +- **Table**: Tape inventory +- **Badges**: Status indicators +- **Icons**: Lucide React icons + +--- + +## 🔌 API Integration + +All endpoints integrated: +- `GET /api/v1/tape/vtl/libraries` - List VTL libraries +- `GET /api/v1/tape/vtl/libraries/:id` - Get VTL details +- `GET /api/v1/tape/vtl/libraries/:id/drives` - List drives +- `GET /api/v1/tape/vtl/libraries/:id/tapes` - List tapes +- `POST /api/v1/tape/vtl/libraries` - Create VTL +- `DELETE /api/v1/tape/vtl/libraries/:id` - Delete VTL +- `POST /api/v1/tape/vtl/libraries/:id/tapes` - Create tape +- `POST /api/v1/tape/vtl/libraries/:id/load` - Load tape +- `POST /api/v1/tape/vtl/libraries/:id/unload` - Unload tape +- `GET /api/v1/tape/physical/libraries` - List physical libraries +- `POST /api/v1/tape/physical/libraries/discover` - Discover libraries + +--- + +## 🚀 Next Steps + +### Remaining Phase E Tasks: +1. **iSCSI Targets UI** - SCST target management +2. **Tasks & Jobs UI** - Task monitoring and management +3. **System Settings UI** - Service management, logs, support bundles +4. **IAM/Users UI** - User and role management (admin only) +5. **Enhanced Alerts UI** - Real-time updates, filters, actions + +### Potential Enhancements: +- Create VTL wizard page +- Create tape wizard +- Physical library detail page +- Real-time drive/tape status updates via WebSocket +- Bulk operations (load multiple tapes) +- Tape library statistics and charts + +--- + +## ✅ Summary + +**Tape Library Management UI**: ✅ **COMPLETE** + +- ✅ API client with full type safety +- ✅ List page with tabs +- ✅ VTL detail page with full functionality +- ✅ Routing configured +- ✅ All TypeScript errors resolved +- ✅ Build successful + +**Ready for**: Testing and next Phase E components! 🎉 + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5699208..37e3b32 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,10 @@ import LoginPage from '@/pages/Login' import Dashboard from '@/pages/Dashboard' import StoragePage from '@/pages/Storage' import AlertsPage from '@/pages/Alerts' +import TapeLibrariesPage from '@/pages/TapeLibraries' +import VTLDetailPage from '@/pages/VTLDetail' +import ISCSITargetsPage from '@/pages/ISCSITargets' +import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail' import Layout from '@/components/Layout' // Create a client @@ -46,6 +50,10 @@ function App() { > } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/api/scst.ts b/frontend/src/api/scst.ts new file mode 100644 index 0000000..cc47a9f --- /dev/null +++ b/frontend/src/api/scst.ts @@ -0,0 +1,113 @@ +import apiClient from './client' + +// ============================================================================ +// Types +// ============================================================================ + +export interface SCSTTarget { + id: string + iqn: string + alias?: string + is_active: boolean + created_at: string + updated_at: string +} + +export interface SCSTLUN { + id: string + target_id: string + lun_number: number + handler: string + device_path: string + device_type: string + is_active: boolean + created_at: string + updated_at: string +} + +export interface SCSTInitiator { + id: string + group_id: string + iqn: string + is_active: boolean + created_at: string + updated_at: string +} + +export interface SCSTInitiatorGroup { + id: string + target_id: string + group_name: string + initiators: SCSTInitiator[] + created_at: string + updated_at: string +} + +export interface SCSTHandler { + name: string + description?: string +} + +export interface CreateTargetRequest { + iqn: string + target_type: string + name: string + description?: string + single_initiator_only?: boolean +} + +export interface AddLUNRequest { + device_name: string + device_path: string + lun_number: number + handler_type: string +} + +export interface AddInitiatorRequest { + initiator_iqn: string +} + +// ============================================================================ +// SCST API +// ============================================================================ + +export const scstAPI = { + listTargets: async (): Promise => { + const response = await apiClient.get('/scst/targets') + return response.data.targets || [] + }, + + getTarget: async (id: string): Promise<{ + target: SCSTTarget + luns: SCSTLUN[] + }> => { + const response = await apiClient.get(`/scst/targets/${id}`) + return response.data + }, + + createTarget: async (data: CreateTargetRequest): Promise => { + const response = await apiClient.post('/scst/targets', data) + return response.data.target + }, + + addLUN: async (targetId: string, data: AddLUNRequest): Promise<{ task_id: string }> => { + const response = await apiClient.post(`/scst/targets/${targetId}/luns`, data) + return response.data + }, + + addInitiator: async (targetId: string, data: AddInitiatorRequest): Promise<{ task_id: string }> => { + const response = await apiClient.post(`/scst/targets/${targetId}/initiators`, data) + return response.data + }, + + applyConfig: async (): Promise<{ task_id: string }> => { + const response = await apiClient.post('/scst/config/apply') + return response.data + }, + + listHandlers: async (): Promise => { + const response = await apiClient.get('/scst/handlers') + return response.data.handlers || [] + }, +} + diff --git a/frontend/src/api/tape.ts b/frontend/src/api/tape.ts new file mode 100644 index 0000000..df8d2d1 --- /dev/null +++ b/frontend/src/api/tape.ts @@ -0,0 +1,168 @@ +import apiClient from './client' + +// ============================================================================ +// Types +// ============================================================================ + +export interface PhysicalTapeLibrary { + id: string + name: string + serial_number: string + vendor: string + model: string + changer_device_path: string + changer_stable_path: string + slot_count: number + drive_count: number + is_active: boolean + discovered_at: string + last_inventory_at?: string + created_at: string + updated_at: string +} + +export interface VirtualTapeLibrary { + id: string + name: string + mhvtl_library_id: number + storage_path: string + slot_count: number + drive_count: number + is_active: boolean + created_at: string + updated_at: string +} + +export interface TapeDrive { + id: string + library_id: string + drive_number: number + device_path?: string + status: string + current_tape_id?: string + created_at: string + updated_at: string +} + +export interface VirtualTape { + id: string + library_id: string + barcode: string + slot_number: number + size_bytes: number + status: string + created_at: string + updated_at: string +} + +export interface CreateVTLRequest { + name: string + slot_count: number + drive_count: number + storage_path?: string +} + +export interface CreateTapeRequest { + barcode: string + size_bytes: number +} + +export interface LoadTapeRequest { + drive_id: string + tape_id: string +} + +export interface UnloadTapeRequest { + drive_id: string +} + +// ============================================================================ +// Physical Tape Libraries +// ============================================================================ + +export const physicalTapeAPI = { + listLibraries: async (): Promise => { + const response = await apiClient.get('/tape/physical/libraries') + return response.data.libraries || [] + }, + + getLibrary: async (id: string): Promise => { + const response = await apiClient.get(`/tape/physical/libraries/${id}`) + return response.data.library + }, + + discoverLibraries: async (): Promise<{ task_id: string }> => { + const response = await apiClient.post('/tape/physical/libraries/discover') + return response.data + }, + + performInventory: async (id: string): Promise<{ task_id: string }> => { + const response = await apiClient.post(`/tape/physical/libraries/${id}/inventory`) + return response.data + }, + + loadTape: async (libraryId: string, data: LoadTapeRequest): Promise<{ task_id: string }> => { + const response = await apiClient.post(`/tape/physical/libraries/${libraryId}/load`, data) + return response.data + }, + + unloadTape: async (libraryId: string, data: UnloadTapeRequest): Promise<{ task_id: string }> => { + const response = await apiClient.post(`/tape/physical/libraries/${libraryId}/unload`, data) + return response.data + }, +} + +// ============================================================================ +// Virtual Tape Libraries (VTL) +// ============================================================================ + +export const vtlAPI = { + listLibraries: async (): Promise => { + const response = await apiClient.get('/tape/vtl/libraries') + return response.data.libraries || [] + }, + + getLibrary: async (id: string): Promise<{ + library: VirtualTapeLibrary + drives: TapeDrive[] + tapes: VirtualTape[] + }> => { + const response = await apiClient.get(`/tape/vtl/libraries/${id}`) + return response.data + }, + + createLibrary: async (data: CreateVTLRequest): Promise => { + const response = await apiClient.post('/tape/vtl/libraries', data) + return response.data.library + }, + + deleteLibrary: async (id: string): Promise => { + await apiClient.delete(`/tape/vtl/libraries/${id}`) + }, + + getLibraryDrives: async (id: string): Promise => { + const response = await apiClient.get(`/tape/vtl/libraries/${id}/drives`) + return response.data.drives || [] + }, + + getLibraryTapes: async (id: string): Promise => { + const response = await apiClient.get(`/tape/vtl/libraries/${id}/tapes`) + return response.data.tapes || [] + }, + + createTape: async (libraryId: string, data: CreateTapeRequest): Promise => { + const response = await apiClient.post(`/tape/vtl/libraries/${libraryId}/tapes`, data) + return response.data.tape + }, + + loadTape: async (libraryId: string, data: LoadTapeRequest): Promise<{ task_id: string }> => { + const response = await apiClient.post(`/tape/vtl/libraries/${libraryId}/load`, data) + return response.data + }, + + unloadTape: async (libraryId: string, data: UnloadTapeRequest): Promise<{ task_id: string }> => { + const response = await apiClient.post(`/tape/vtl/libraries/${libraryId}/unload`, data) + return response.data + }, +} + diff --git a/frontend/src/pages/ISCSITargetDetail.tsx b/frontend/src/pages/ISCSITargetDetail.tsx new file mode 100644 index 0000000..70afac1 --- /dev/null +++ b/frontend/src/pages/ISCSITargetDetail.tsx @@ -0,0 +1,435 @@ +import { useParams, useNavigate } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { scstAPI, type SCSTHandler } from '@/api/scst' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { ArrowLeft, Plus, RefreshCw, HardDrive, Users } from 'lucide-react' +import { useState } from 'react' + +export default function ISCSITargetDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [showAddLUN, setShowAddLUN] = useState(false) + const [showAddInitiator, setShowAddInitiator] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['scst-target', id], + queryFn: () => scstAPI.getTarget(id!), + enabled: !!id, + }) + + const { data: handlers } = useQuery({ + queryKey: ['scst-handlers'], + queryFn: scstAPI.listHandlers, + }) + + if (isLoading) { + return
Loading target details...
+ } + + if (!data) { + return
Target not found
+ } + + const { target, luns } = data + + return ( +
+ {/* Header */} +
+
+ +
+

{target.iqn}

+ {target.alias && ( +

{target.alias}

+ )} +
+
+ +
+ + {/* Target Info */} +
+ + + Target Status + + +
+
+ Status: + + {target.is_active ? 'Active' : 'Inactive'} + +
+
+ IQN: + {target.iqn} +
+
+
+
+ + + + LUNs + + +
+
+ Total LUNs: + {luns.length} +
+
+ Active: + + {luns.filter((l) => l.is_active).length} + +
+
+
+
+ + + + Actions + + +
+ + +
+
+
+
+ + {/* LUNs */} + + +
+
+ LUNs (Logical Unit Numbers) + Storage devices exported by this target +
+ +
+
+ + {luns.length > 0 ? ( +
+ + + + + + + + + + + + {luns.map((lun) => ( + + + + + + + + ))} + +
+ LUN # + + Handler + + Device Path + + Type + + Status +
+ {lun.lun_number} + + {lun.handler} + + {lun.device_path} + + {lun.device_type} + + + {lun.is_active ? 'Active' : 'Inactive'} + +
+
+ ) : ( +
+ +

No LUNs configured

+ +
+ )} +
+
+ + {/* Add LUN Form */} + {showAddLUN && ( + setShowAddLUN(false)} + onSuccess={() => { + setShowAddLUN(false) + queryClient.invalidateQueries({ queryKey: ['scst-target', id] }) + }} + /> + )} + + {/* Add Initiator Form */} + {showAddInitiator && ( + setShowAddInitiator(false)} + onSuccess={() => { + setShowAddInitiator(false) + queryClient.invalidateQueries({ queryKey: ['scst-target', id] }) + }} + /> + )} +
+ ) +} + +interface AddLUNFormProps { + targetId: string + handlers: SCSTHandler[] + onClose: () => void + onSuccess: () => void +} + +function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps) { + const [handlerType, setHandlerType] = useState('') + const [devicePath, setDevicePath] = useState('') + const [deviceName, setDeviceName] = useState('') + const [lunNumber, setLunNumber] = useState(0) + + const addLUNMutation = useMutation({ + mutationFn: (data: { device_name: string; device_path: string; lun_number: number; handler_type: string }) => + scstAPI.addLUN(targetId, data), + onSuccess: () => { + onSuccess() + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!handlerType || !devicePath || !deviceName || lunNumber < 0) { + alert('All fields are required') + return + } + + addLUNMutation.mutate({ + handler_type: handlerType.trim(), + device_path: devicePath.trim(), + device_name: deviceName.trim(), + lun_number: lunNumber, + }) + } + + return ( + + + Add LUN + Add a storage device to this target + + +
+
+ + +
+ +
+ + setDeviceName(e.target.value)} + placeholder="device1" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + required + /> +
+ +
+ + setDevicePath(e.target.value)} + placeholder="/dev/sda or /dev/calypso/vg1/lv1" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" + required + /> +
+ +
+ + setLunNumber(parseInt(e.target.value) || 0)} + min="0" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + required + /> +
+ +
+ + +
+
+
+
+ ) +} + +interface AddInitiatorFormProps { + targetId: string + onClose: () => void + onSuccess: () => void +} + +function AddInitiatorForm({ targetId, onClose, onSuccess }: AddInitiatorFormProps) { + const [iqn, setIqn] = useState('') + + const addInitiatorMutation = useMutation({ + mutationFn: (data: { initiator_iqn: string }) => + scstAPI.addInitiator(targetId, data), + onSuccess: () => { + onSuccess() + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!iqn) { + alert('Initiator IQN is required') + return + } + + addInitiatorMutation.mutate({ + initiator_iqn: iqn.trim(), + }) + } + + return ( + + + Add Initiator + Allow an iSCSI initiator to access this target + + +
+
+ + setIqn(e.target.value)} + placeholder="iqn.2024-01.com.client:initiator1" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm" + required + /> +

+ Format: iqn.YYYY-MM.reverse.domain:identifier +

+
+ +
+ + +
+
+
+
+ ) +} + diff --git a/frontend/src/pages/ISCSITargets.tsx b/frontend/src/pages/ISCSITargets.tsx new file mode 100644 index 0000000..aff6ef3 --- /dev/null +++ b/frontend/src/pages/ISCSITargets.tsx @@ -0,0 +1,250 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { scstAPI, type SCSTTarget } from '@/api/scst' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Plus, RefreshCw, Server, CheckCircle, XCircle } from 'lucide-react' +import { Link } from 'react-router-dom' + +export default function ISCSITargets() { + const queryClient = useQueryClient() + const [showCreateForm, setShowCreateForm] = useState(false) + + const { data: targets, isLoading } = useQuery({ + queryKey: ['scst-targets'], + queryFn: scstAPI.listTargets, + }) + + const applyConfigMutation = useMutation({ + mutationFn: scstAPI.applyConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + alert('Configuration applied successfully!') + }, + }) + + return ( +
+
+
+

iSCSI Targets

+

+ Manage SCST iSCSI targets, LUNs, and initiators +

+
+
+ + +
+
+ + {/* Create Target Form */} + {showCreateForm && ( + setShowCreateForm(false)} + onSuccess={() => { + setShowCreateForm(false) + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + }} + /> + )} + + {/* Targets List */} + {isLoading ? ( +

Loading targets...

+ ) : targets && targets.length > 0 ? ( +
+ {targets.map((target) => ( + + ))} +
+ ) : ( + + + +

No iSCSI Targets

+

+ Create your first iSCSI target to start exporting storage +

+ +
+
+ )} +
+ ) +} + +interface TargetCardProps { + target: SCSTTarget +} + +function TargetCard({ target }: TargetCardProps) { + return ( + + + +
+ {target.iqn} + {target.is_active ? ( + + ) : ( + + )} +
+ {target.alias && ( + {target.alias} + )} +
+ +
+
+ Status: + + {target.is_active ? 'Active' : 'Inactive'} + +
+
+ Created: + + {new Date(target.created_at).toLocaleDateString()} + +
+
+
+
+ + ) +} + +interface CreateTargetFormProps { + onClose: () => void + onSuccess: () => void +} + +function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) { + const [iqn, setIqn] = useState('') + const [name, setName] = useState('') + const [targetType, setTargetType] = useState('disk') + const [description, setDescription] = useState('') + + const createMutation = useMutation({ + mutationFn: scstAPI.createTarget, + onSuccess: () => { + onSuccess() + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!iqn.trim() || !name.trim()) { + alert('IQN and Name are required') + return + } + + createMutation.mutate({ + iqn: iqn.trim(), + target_type: targetType, + name: name.trim(), + description: description.trim() || undefined, + }) + } + + return ( + + + Create iSCSI Target + Create a new SCST iSCSI target + + +
+
+ + setIqn(e.target.value)} + placeholder="iqn.2024-01.com.example:target1" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + required + /> +

+ Format: iqn.YYYY-MM.reverse.domain:identifier +

+
+ +
+ + setName(e.target.value)} + placeholder="My Target" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + required + /> +
+ +
+ + +
+ +
+ +