Files
GhostGrid/src/components/Logbook.tsx

284 lines
12 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { LogEntry, Device, User } from '../types';
import {
History, Search, Plus, Hammer, UserIcon, Server,
Info, Save, ChevronRight
} from 'lucide-react';
interface LogbookProps {
logs: LogEntry[];
devices: Device[];
users: User[];
currentUser: User;
onAddLog: (log: Omit<LogEntry, 'id' | 'timestamp'>) => void;
}
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [showSystem, setShowSystem] = useState(false);
// Custom Maintenance Log state
const [showAddLog, setShowAddLog] = useState(false);
const [targetDeviceId, setTargetDeviceId] = useState('');
const [logMessage, setLogMessage] = useState('');
// Sorted list: latest logs first
const sortedLogs = [...logs].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Filter logs
const filteredLogs = sortedLogs.filter(log => {
if (!showSystem && log.type === 'system') return false;
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || log.type === typeFilter;
return matchesSearch && matchesType;
});
const handleSubmitLog = (e: React.FormEvent) => {
e.preventDefault();
if (!logMessage.trim()) return;
let finalMsg = logMessage;
if (targetDeviceId) {
const dev = devices.find(x => x.id === targetDeviceId);
if (dev) {
finalMsg = `[Maintenance on ${dev.hostname}] ${logMessage}`;
}
}
onAddLog({
type: 'maintenance',
message: `${currentUser.name} registered the following maintenance update: ${finalMsg}`,
deviceId: targetDeviceId || undefined,
userId: currentUser.id
});
setLogMessage('');
setTargetDeviceId('');
setShowAddLog(false);
};
const getLogTypeBadge = (type: string) => {
switch (type) {
case 'maintenance':
return 'bg-amber-950 border border-amber-900/60 text-amber-400';
case 'booking':
return 'bg-emerald-950 border border-emerald-900/60 text-emerald-400';
case 'status':
return 'bg-cyan-950 border border-cyan-900/60 text-cyan-400';
case 'system':
default:
return 'bg-slate-900 border border-slate-800 text-slate-350';
}
};
const getLogTypeLabel = (type: string) => {
switch (type) {
case 'maintenance': return 'Maintenance';
case 'booking': return 'Booking';
case 'status': return 'Status';
case 'system': default: return 'System';
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-sans" id="logbook-dashboard">
{/* LEFT COLUMN: Chronological Log List */}
<div className="lg:col-span-8 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="logbook-ledger-card">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<History className="w-5 h-5 text-emerald-400" />
Audit Log & Maintenance Journal
</h2>
<p className="text-xs text-slate-400">Append-only history of who touched what. git blame, but for the lab.</p>
</div>
<button
onClick={() => setShowAddLog(!showAddLog)}
className="px-3 py-1.5 bg-slate-900 border border-slate-800 text-slate-200 hover:text-emerald-400 hover:border-emerald-500 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all font-sans hover:cursor-pointer"
id="btn-toggle-add-log"
>
<Plus className="w-4 h-4 text-emerald-400" />
File Maintenance Report
</button>
</div>
{/* Search and Filters toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-850 rounded-lg">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-550">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Filter audit log entries..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
/>
</div>
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
{['all', 'booking', 'maintenance', 'status'].map((type) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium capitalize transition-all ${
typeFilter === type
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : getLogTypeLabel(type)}
</button>
))}
<button
onClick={() => setShowSystem(v => !v)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
showSystem
? 'bg-slate-700/40 border border-slate-600 text-slate-300'
: 'bg-slate-950 text-slate-600 border border-slate-850 hover:text-slate-400'
}`}
title="Show/hide automated system & CheckMK entries"
>
System
</button>
</div>
</div>
{/* Audit Log Sheet */}
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
{filteredLogs.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">
No audit records match the selected filtering rules.
</p>
) : (
filteredLogs.map((log) => {
const dev = devices.find(d => d.id === log.deviceId);
const user = users.find(u => u.id === log.userId);
const timestampFormatted = new Date(log.timestamp).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'medium'
});
return (
<div key={log.id} className="p-3 bg-slate-900/40 border border-slate-855 rounded-xl hover:border-slate-800 hover:bg-slate-900/70 transition-all flex items-start gap-3.5">
<div className="flex flex-col items-center gap-1.5 shrink-0 pt-0.5 min-w-[80px]">
<span className={`text-[9px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded font-sans scale-90 text-center block w-full ${getLogTypeBadge(log.type)}`}>
{getLogTypeLabel(log.type)}
</span>
<span className="text-[9px] font-mono text-slate-500 leading-none">
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex-1">
<p className="text-xs text-slate-200 leading-relaxed font-sans">{log.message}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-[10px] font-mono text-slate-500 pt-1.5 border-t border-slate-900/40">
<span>Calendar Time: {timestampFormatted}</span>
{user && (
<span className="flex items-center gap-1 text-slate-400">
<UserIcon className="w-3 h-3 text-slate-500" />
Operator: {user.name}
</span>
)}
{dev && (
<span className="flex items-center gap-1 text-emerald-450 font-semibold">
<Server className="w-3 h-3 text-slate-500" />
Node: {dev.hostname}
</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
<div className="lg:col-span-4" id="logbook-forms-side">
{showAddLog ? (
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm space-y-4 animate-in fade-in slide-in-from-right-3 duration-200 font-sans">
<div className="flex items-center justify-between pb-2 border-b border-slate-800">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5">
<Hammer className="w-4 h-4 text-amber-500" />
Journal Maintenance Work
</h3>
<button
onClick={() => setShowAddLog(false)}
className="text-slate-400 hover:text-white"
>
Cancel
</button>
</div>
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
<div>
<label className="block text-slate-300 font-semibold mb-1">Target Network Host (Optional)</label>
<select
value={targetDeviceId}
onChange={(e) => setTargetDeviceId(e.target.value)}
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
>
<option value="">-- Complete Lab Cluster / General Event --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.hostname} ({d.ip})
</option>
))}
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Documented Actions / Findings</label>
<textarea
required
rows={4}
value={logMessage}
onChange={(e) => setLogMessage(e.target.value)}
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
placeholder="Describe SFP fiber replacements, port trunking alterations, routing table diagnostic evaluations..."
/>
</div>
<div className="bg-slate-900 border border-slate-800 p-2.5 rounded text-[11px] text-slate-450 leading-normal flex gap-2">
<Info className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
<span>This writes straight to the shared ledger - everyone on the team sees it, so spell it like the next on-call has to read it at 3am.</span>
</div>
<button
type="submit"
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center justify-center gap-1.5 transition-colors"
>
<Save className="w-3.5 h-3.5" />
Publish to Shared Log Book
</button>
</form>
</div>
) : (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 shadow-sm text-xs text-slate-400 font-sans leading-relaxed">
<h3 className="font-bold text-white mb-2 text-sm flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-emerald-400 shrink-0" />
Shared Audit & Fault Logging
</h3>
<p>
Labs drift. If you hit an STP block, a bridging loop or that one port that mysteriously err-disables itself, write down how you dug out. future-you (and the next on-call) will thank you.
</p>
<div className="mt-4 p-3 bg-amber-950/20 border border-amber-900/40 rounded-lg text-amber-300 font-mono text-[10px]">
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
</div>
</div>
)}
</div>
</div>
);
}