feat: create logs
ci-front / build (push) Successful in 2m24s

This commit is contained in:
2026-04-04 06:13:12 +03:00
parent 96f82b4162
commit 95a6902dae
5 changed files with 784 additions and 0 deletions
@@ -7,6 +7,7 @@ import {
FaUsers,
FaRocket,
FaKey,
FaFileAlt,
} from "react-icons/fa";
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
@@ -19,6 +20,7 @@ export const Navigation = () => {
{ path: "/", label: "Главная", icon: FaHome },
{ path: "/add-agents", label: "Деплой", icon: FaRocket },
{ path: "/registration", label: "Регистрация", icon: FaKey },
{ path: "/logs", label: "Логи", icon: FaFileAlt },
{ path: "/admin", label: "Админка", icon: FaUsers, adminOnly: true },
{ path: "/themes", label: "Темы", icon: FaPalette },
];
@@ -11,6 +11,7 @@ import { AddAgentsPage } from "@/pages/add-agents.page";
import { IDEPage } from "@/pages/ide.page";
import { AdminPage } from "@/pages/admin.page";
import { RegistrationTokenPage } from "@/pages/registration.page";
import { LogsPage } from "@/pages/logs.page";
export const mockGraphData: GraphData = {
nodes: [
@@ -122,6 +123,7 @@ export const Routing = () => {
<Route path="/themes" element={<ThemesPage />} />
<Route path="/add-agents" element={<AddAgentsPage />} />
<Route path="/registration" element={<RegistrationTokenPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/IDE" element={<IDEPage />} />
</Route>
@@ -0,0 +1,86 @@
import { create } from "zustand";
export type LogLevel = "INFO" | "WARNING" | "ERROR" | "FATAL";
interface LogFilterState {
searchQuery: string;
startDate: Date | null;
endDate: Date | null;
selectedLogLevels: LogLevel[];
selectedService: string;
selectedAgent: string;
limit: number;
offset: number;
setSearchQuery: (query: string) => void;
setStartDate: (date: Date | null) => void;
setEndDate: (date: Date | null) => void;
toggleLogLevel: (level: LogLevel) => void;
setSelectedService: (service: string) => void;
setSelectedAgent: (agent: string) => void;
setLimit: (limit: number) => void;
setOffset: (offset: number) => void;
resetFilters: () => void;
getFilters: () => {
level?: string;
service?: string;
agent?: string;
date_from?: string;
date_to?: string;
limit: number;
offset: number;
};
}
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
searchQuery: "",
startDate: null,
endDate: null,
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
selectedService: "",
selectedAgent: "",
limit: 100,
offset: 0,
setSearchQuery: (query) => set({ searchQuery: query }),
setStartDate: (date) => set({ startDate: date }),
setEndDate: (date) => set({ endDate: date }),
toggleLogLevel: (level) => {
const { selectedLogLevels } = get();
if (selectedLogLevels.includes(level)) {
set({ selectedLogLevels: selectedLogLevels.filter((l) => l !== level) });
} else {
set({ selectedLogLevels: [...selectedLogLevels, level] });
}
},
setSelectedService: (service) => set({ selectedService: service }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
setLimit: (limit) => set({ limit }),
setOffset: (offset) => set({ offset }),
resetFilters: () => {
set({
searchQuery: "",
startDate: null,
endDate: null,
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
selectedService: "",
selectedAgent: "",
limit: 100,
offset: 0,
});
},
getFilters: () => {
const { selectedLogLevels, selectedService, selectedAgent, startDate, endDate, limit, offset } = get();
return {
level: selectedLogLevels.length > 0 ? selectedLogLevels.join(",") : undefined,
service: selectedService || undefined,
agent: selectedAgent || undefined,
date_from: startDate ? startDate.toISOString() : undefined,
date_to: endDate ? endDate.toISOString() : undefined,
limit,
offset,
};
},
}));
@@ -0,0 +1,399 @@
import React, { useState, useEffect, useCallback } from "react";
import {
FiSearch,
FiX,
FiFilter,
FiCalendar,
FiTag,
FiCheck,
} from "react-icons/fi";
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
const logLevelColors: Record<LogLevel, { bg: string; text: string; border: string }> = {
INFO: { bg: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
};
interface LogFiltersProps {
onApply: () => void;
availableServices: string[];
availableAgents: string[];
}
export const LogFilters: React.FC<LogFiltersProps> = ({ onApply, availableServices, availableAgents }) => {
const {
searchQuery,
startDate,
endDate,
selectedLogLevels,
selectedService,
selectedAgent,
setSearchQuery,
setStartDate,
setEndDate,
toggleLogLevel,
setSelectedService,
setSelectedAgent,
resetFilters,
} = useLogFilterStore();
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
const [localService, setLocalService] = useState(selectedService);
const [localAgent, setLocalAgent] = useState(selectedAgent);
useEffect(() => {
setLocalSearchQuery(searchQuery);
}, [searchQuery]);
useEffect(() => {
setLocalStartDate(startDate);
}, [startDate]);
useEffect(() => {
setLocalEndDate(endDate);
}, [endDate]);
useEffect(() => {
setLocalService(selectedService);
}, [selectedService]);
useEffect(() => {
setLocalAgent(selectedAgent);
}, [selectedAgent]);
const handleApply = useCallback(() => {
setSearchQuery(localSearchQuery);
setStartDate(localStartDate);
setEndDate(localEndDate);
setSelectedService(localService);
setSelectedAgent(localAgent);
onApply();
}, [localSearchQuery, localStartDate, localEndDate, localService, localAgent, onApply]);
const handleReset = useCallback(() => {
setLocalSearchQuery("");
setLocalStartDate(null);
setLocalEndDate(null);
setLocalService("");
setLocalAgent("");
resetFilters();
onApply();
}, [resetFilters, onApply]);
const getActiveFiltersCount = () => {
let count = 0;
if (searchQuery) count++;
if (startDate) count++;
if (endDate) count++;
if (selectedService) count++;
if (selectedAgent) count++;
if (selectedLogLevels.length < 4) count++;
return count;
};
const formatDate = (date: Date | null) => {
if (!date) return null;
return date.toLocaleDateString("ru-RU");
};
const activeFiltersCount = getActiveFiltersCount();
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "6px",
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
fontSize: "13px",
};
const selectStyle: React.CSSProperties = {
...inputStyle,
cursor: "pointer",
};
return (
<div
className="rounded-xl border"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FiFilter size={14} style={{ color: "var(--accent)" }} />
<h3 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
Фильтры логов
</h3>
</div>
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
Активно: {activeFiltersCount}
</span>
</div>
{/* Filters Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
{/* Search */}
<div className="relative">
<FiSearch
style={{
position: "absolute",
left: "10px",
top: "50%",
transform: "translateY(-50%)",
color: "var(--text-muted)",
fontSize: "14px",
}}
/>
<input
type="text"
value={localSearchQuery}
onChange={(e) => setLocalSearchQuery(e.target.value)}
placeholder="Поиск по сообщению..."
style={{ ...inputStyle, paddingLeft: "32px" }}
onKeyDown={(e) => e.key === "Enter" && handleApply()}
/>
</div>
{/* Service Select */}
<select
value={localService}
onChange={(e) => setLocalService(e.target.value)}
style={selectStyle}
>
<option value="">Все сервисы</option>
{availableServices.map((service) => (
<option key={service} value={service}>
{service}
</option>
))}
</select>
{/* Agent Select */}
<select
value={localAgent}
onChange={(e) => setLocalAgent(e.target.value)}
style={selectStyle}
>
<option value="">Все агенты</option>
{availableAgents.map((agent) => (
<option key={agent} value={agent}>
{agent}
</option>
))}
</select>
{/* Date Range */}
<div className="flex gap-2">
<input
type="date"
value={localStartDate ? localStartDate.toISOString().split("T")[0] : ""}
onChange={(e) => setLocalStartDate(e.target.value ? new Date(e.target.value) : null)}
style={inputStyle}
placeholder="Дата от"
/>
<input
type="date"
value={localEndDate ? localEndDate.toISOString().split("T")[0] : ""}
onChange={(e) => setLocalEndDate(e.target.value ? new Date(e.target.value) : null)}
style={inputStyle}
placeholder="Дата до"
/>
</div>
</div>
{/* Log Levels */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
<span className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
Уровни логов
</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
({selectedLogLevels.length}/4)
</span>
</div>
<div className="flex flex-wrap gap-2">
{(["INFO", "WARNING", "ERROR", "FATAL"] as LogLevel[]).map((level) => {
const isSelected = selectedLogLevels.includes(level);
const colors = logLevelColors[level];
return (
<button
key={level}
onClick={() => toggleLogLevel(level)}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all border"
style={{
backgroundColor: isSelected ? colors.bg : "transparent",
color: isSelected ? colors.text : "var(--text-secondary)",
borderColor: isSelected ? colors.border : "var(--border)",
}}
>
{isSelected && <FiCheck size={10} className="inline mr-1" />}
{level}
</button>
);
})}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={handleApply}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium"
style={{
backgroundColor: "var(--button-primary)",
color: "var(--button-primary-text)",
}}
>
<FiCheck size={14} />
Применить
</button>
<button
onClick={handleReset}
className="px-4 py-2 rounded-lg transition-all text-sm font-medium border"
style={{
backgroundColor: "transparent",
color: "var(--text-secondary)",
borderColor: "var(--border)",
}}
>
<FiX size={14} />
</button>
</div>
{/* Active Filters Display */}
{activeFiltersCount > 0 && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border)" }}>
<div className="flex items-center gap-2 mb-2">
<FiFilter size={10} style={{ color: "var(--accent)" }} />
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
Активные фильтры:
</span>
</div>
<div className="flex flex-wrap gap-2">
{searchQuery && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiSearch size={10} />
<span style={{ color: "var(--text-primary)" }}>Поиск: {searchQuery}</span>
<button
onClick={() => {
setLocalSearchQuery("");
setSearchQuery("");
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{selectedService && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiTag size={10} />
<span style={{ color: "var(--text-primary)" }}>Сервис: {selectedService}</span>
<button
onClick={() => {
setLocalService("");
setSelectedService("");
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{selectedAgent && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiTag size={10} />
<span style={{ color: "var(--text-primary)" }}>Агент: {selectedAgent}</span>
<button
onClick={() => {
setLocalAgent("");
setSelectedAgent("");
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{startDate && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiCalendar size={10} />
<span style={{ color: "var(--text-primary)" }}>С: {formatDate(startDate)}</span>
<button
onClick={() => {
setLocalStartDate(null);
setStartDate(null);
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
{endDate && (
<div
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<FiCalendar size={10} />
<span style={{ color: "var(--text-primary)" }}>По: {formatDate(endDate)}</span>
<button
onClick={() => {
setLocalEndDate(null);
setEndDate(null);
onApply();
}}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
>
<FiX size={10} />
</button>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
};
+295
View File
@@ -0,0 +1,295 @@
import React, { useState, useEffect, useCallback } from "react";
import { agentApiService } from "@/modules/agent";
import type { LogEntry } from "@/modules/agent";
import { LogFilters } from "@/modules/agent/ui/LogFilters";
import { useLogFilterStore } from "@/modules/agent/store/logFilter.store";
import {
FiFileText,
FiRefreshCw,
FiChevronLeft,
FiChevronRight,
FiInfo,
FiAlertTriangle,
FiAlertCircle,
FiXOctagon,
} from "react-icons/fi";
const logLevelIcons: Record<string, React.ReactNode> = {
INFO: <FiInfo size={14} />,
WARNING: <FiAlertTriangle size={14} />,
ERROR: <FiAlertCircle size={14} />,
FATAL: <FiXOctagon size={14} />,
};
const logLevelColors: Record<string, { bg: string; text: string; border: string }> = {
INFO: { bg: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
};
export const LogsPage: React.FC = () => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [availableServices, setAvailableServices] = useState<string[]>([]);
const [availableAgents, setAvailableAgents] = useState<string[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const { getFilters, limit, offset, setOffset } = useLogFilterStore();
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const filters = getFilters();
const data = await agentApiService.searchLogs(filters);
setLogs(data);
setTotalLogs(data.length);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка при загрузке логов");
} finally {
setIsLoading(false);
}
}, [getFilters]);
const fetchDistinctData = useCallback(async () => {
try {
const [services, agents] = await Promise.all([
agentApiService.getDistinctServices(),
agentApiService.getDistinctAgents(),
]);
setAvailableServices(services);
setAvailableAgents(agents);
} catch (err) {
console.error("Failed to fetch distinct data:", err);
}
}, []);
useEffect(() => {
fetchDistinctData();
}, [fetchDistinctData]);
useEffect(() => {
fetchLogs();
}, [fetchLogs, offset, limit]);
const handleFilterApply = () => {
setOffset(0);
fetchLogs();
};
const handleNextPage = () => {
setOffset(offset + limit);
};
const handlePrevPage = () => {
setOffset(Math.max(0, offset - limit));
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString("ru-RU", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
return (
<div
className="min-h-screen py-8 px-4"
style={{ backgroundColor: "var(--bg-primary)" }}
>
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<div
className="w-14 h-14 rounded-xl flex items-center justify-center"
style={{ backgroundColor: "var(--bg-secondary)" }}
>
<FiFileText className="w-7 h-7" style={{ color: "var(--accent)" }} />
</div>
<div>
<h1
className="text-3xl font-bold mb-1"
style={{ color: "var(--text-primary)" }}
>
Поиск логов
</h1>
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
Фильтрация и анализ логов системы
</p>
</div>
</div>
</div>
{/* Filters */}
<div className="mb-6">
<LogFilters
onApply={handleFilterApply}
availableServices={availableServices}
availableAgents={availableAgents}
/>
</div>
{/* Error Message */}
{error && (
<div
className="mb-6 p-4 rounded-lg border text-sm"
style={{
backgroundColor: "var(--error-bg)",
borderColor: "var(--error-border)",
color: "var(--error-text)",
}}
>
{error}
</div>
)}
{/* Logs Table */}
<div
className="rounded-xl border overflow-hidden"
style={{
backgroundColor: "var(--card-bg)",
borderColor: "var(--border)",
}}
>
{/* Table Header */}
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: "var(--border)" }}>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
Найдено: {totalLogs} записей
</span>
<button
onClick={fetchLogs}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium border"
style={{
backgroundColor: "transparent",
color: "var(--accent)",
borderColor: "var(--border)",
}}
>
<FiRefreshCw size={12} className={isLoading ? "animate-spin" : ""} />
Обновить
</button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-secondary)" }}>
<FiRefreshCw size={24} className="animate-spin mr-3" />
Загрузка логов...
</div>
) : logs.length === 0 ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
Логи не найдены
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)" }}>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Время
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Уровень
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Сервис
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Агент
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
Сообщение
</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => {
const colors = logLevelColors[log.level] || logLevelColors.INFO;
return (
<tr
key={index}
className="border-t"
style={{
borderColor: "var(--border)",
backgroundColor: index % 2 === 0 ? "var(--card-bg)" : "var(--bg-secondary)",
}}
>
<td className="px-4 py-3 text-sm font-mono whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
{formatTimestamp(log.timestamp)}
</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border"
style={{
backgroundColor: colors.bg,
color: colors.text,
borderColor: colors.border,
}}
>
{logLevelIcons[log.level]}
{log.level}
</span>
</td>
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>
{log.service}
</td>
<td className="px-4 py-3 text-sm font-mono" style={{ color: "var(--text-primary)" }}>
{log.agent}
</td>
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>
{log.message}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: "var(--border)" }}>
<button
onClick={handlePrevPage}
disabled={offset === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
borderColor: "var(--border)",
}}
>
<FiChevronLeft size={16} />
Назад
</button>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
Показано {logs.length} записей (смещение: {offset})
</span>
<button
onClick={handleNextPage}
disabled={logs.length < limit}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
style={{
backgroundColor: "var(--input-bg)",
color: "var(--text-primary)",
borderColor: "var(--border)",
}}
>
Далее
<FiChevronRight size={16} />
</button>
</div>
</>
)}
</div>
</div>
</div>
);
};