001/*
002 * Copyright (C) Photon Vision.
003 *
004 * This program is free software: you can redistribute it and/or modify
005 * it under the terms of the GNU General Public License as published by
006 * the Free Software Foundation, either version 3 of the License, or
007 * (at your option) any later version.
008 *
009 * This program is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
012 * GNU General Public License for more details.
013 *
014 * You should have received a copy of the GNU General Public License
015 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
016 */
017
018package org.photonvision.common.logging;
019
020import java.io.*;
021import java.nio.file.Path;
022import java.text.ParseException;
023import java.text.SimpleDateFormat;
024import java.util.*;
025import java.util.function.Supplier;
026import org.apache.commons.lang3.tuple.Pair;
027// import org.photonvision.common.configuration.ConfigManager;
028import org.photonvision.common.configuration.PathManager;
029import org.photonvision.common.dataflow.DataChangeService;
030import org.photonvision.common.dataflow.events.OutgoingUIEvent;
031import org.photonvision.common.util.TimedTaskManager;
032
033/** TODO: get rid of static {} blocks and refactor to singleton pattern */
034public class Logger {
035    private static final HashMap<LogGroup, LogLevel> levelMap = new HashMap<>();
036    private static final List<LogAppender> currentAppenders = new ArrayList<>();
037
038    private static final UILogAppender uiLogAppender = new UILogAppender();
039
040    // // TODO why's the logger care about this? split it out
041    // private static KernelLogLogger klogListener = null;
042
043    static {
044        levelMap.put(LogGroup.Camera, LogLevel.INFO);
045        levelMap.put(LogGroup.General, LogLevel.INFO);
046        levelMap.put(LogGroup.WebServer, LogLevel.INFO);
047        levelMap.put(LogGroup.Data, LogLevel.INFO);
048        levelMap.put(LogGroup.VisionModule, LogLevel.INFO);
049        levelMap.put(LogGroup.Config, LogLevel.INFO);
050        levelMap.put(LogGroup.CSCore, LogLevel.TRACE);
051        levelMap.put(LogGroup.NetworkTables, LogLevel.DEBUG);
052        levelMap.put(LogGroup.System, LogLevel.DEBUG);
053
054        currentAppenders.add(new ConsoleLogAppender());
055        currentAppenders.add(uiLogAppender);
056        addFileAppender(PathManager.getInstance().getLogPath());
057
058        cleanLogs(PathManager.getInstance().getLogsDir());
059    }
060
061    public static final String ANSI_RESET = "\u001B[0m";
062    public static final String ANSI_BLACK = "\u001B[30m";
063    public static final String ANSI_RED = "\u001B[31m";
064    public static final String ANSI_GREEN = "\u001B[32m";
065    public static final String ANSI_YELLOW = "\u001B[33m";
066    public static final String ANSI_BLUE = "\u001B[34m";
067    public static final String ANSI_PURPLE = "\u001B[35m";
068    public static final String ANSI_CYAN = "\u001B[36m";
069    public static final String ANSI_WHITE = "\u001B[37m";
070
071    public static final int MAX_LOGS_TO_KEEP = 100;
072
073    private static final SimpleDateFormat simpleDateFormat =
074            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
075
076    private static final List<Pair<String, LogLevel>> uiBacklog = new ArrayList<>();
077    private static boolean connected = false;
078
079    private final String className;
080    private final LogGroup group;
081
082    public Logger(Class<?> clazz, LogGroup group) {
083        this.className = clazz.getSimpleName();
084        this.group = group;
085    }
086
087    public Logger(Class<?> clazz, String suffix, LogGroup group) {
088        this.className = clazz.getSimpleName() + " - " + suffix;
089        this.group = group;
090    }
091
092    public static String getDate() {
093        return simpleDateFormat.format(new Date());
094    }
095
096    public static String format(
097            String logMessage, LogLevel level, LogGroup group, String clazz, boolean color) {
098        var date = getDate();
099        var builder = new StringBuilder();
100        if (color) builder.append(level.colorCode);
101        builder
102                .append("[")
103                .append(date)
104                .append("] [")
105                .append(group)
106                .append(" - ")
107                .append(clazz)
108                .append("] [")
109                .append(level.name())
110                .append("] ")
111                .append(logMessage);
112        if (color) builder.append(ANSI_RESET);
113        return builder.toString();
114    }
115
116    @SuppressWarnings("ResultOfMethodCallIgnored")
117    public static void addFileAppender(Path logFilePath) {
118        var file = logFilePath.toFile();
119        if (!file.exists()) {
120            try {
121                file.getParentFile().mkdirs();
122                file.createNewFile();
123            } catch (IOException e) {
124                e.printStackTrace();
125            }
126        }
127        currentAppenders.add(new FileLogAppender(logFilePath));
128    }
129
130    public static void closeAllLoggers() {
131        currentAppenders.forEach(LogAppender::shutdown);
132        currentAppenders.clear();
133    }
134
135    public static void cleanLogs(Path folderToClean) {
136        File[] logs = folderToClean.toFile().listFiles();
137        if (logs == null) return;
138        LinkedList<File> logFileList = new LinkedList<>(Arrays.asList(logs));
139        HashMap<File, Date> logFileStartDateMap = new HashMap<>();
140
141        // Remove any files from the list for which we can't parse a start date from their name.
142        // Simultaneously populate our HashMap with Date objects representing the file-name
143        // indicated log start time.
144        logFileList.removeIf(
145                (File arg0) -> {
146                    try {
147                        logFileStartDateMap.put(arg0, PathManager.getInstance().logFnameToDate(arg0.getName()));
148                        return false;
149                    } catch (ParseException e) {
150                        return true;
151                    }
152                });
153
154        // Execute a sort on the log file list by date in the filename.
155        logFileList.sort(
156                (File arg0, File arg1) -> {
157                    Date date0 = logFileStartDateMap.get(arg0);
158                    Date date1 = logFileStartDateMap.get(arg1);
159                    return date1.compareTo(date0);
160                });
161
162        int logCounter = 0;
163        for (File file : logFileList) {
164            // Due to filtering above, everything in logFileList should be a log file
165            if (logCounter < MAX_LOGS_TO_KEEP) {
166                // Skip over the first MAX_LOGS_TO_KEEP files
167                logCounter++;
168            } else {
169                // Delete this file.
170                file.delete();
171            }
172        }
173    }
174
175    public static void setLevel(LogGroup group, LogLevel newLevel) {
176        levelMap.put(group, newLevel);
177    }
178
179    // TODO: profile
180    private static void log(String message, LogLevel level, LogGroup group, String clazz) {
181        for (var a : currentAppenders) {
182            var shouldColor = a instanceof ConsoleLogAppender;
183            var formattedMessage = format(message, level, group, clazz, shouldColor);
184            a.log(formattedMessage, level);
185        }
186        if (!connected) {
187            synchronized (uiBacklog) {
188                uiBacklog.add(Pair.of(format(message, level, group, clazz, false), level));
189            }
190        }
191    }
192
193    public static void sendConnectedBacklog() {
194        connected = true;
195        synchronized (uiBacklog) {
196            for (var message : uiBacklog) {
197                uiLogAppender.log(message.getLeft(), message.getRight());
198            }
199            uiBacklog.clear();
200        }
201    }
202
203    public boolean shouldLog(LogLevel logLevel) {
204        return logLevel.code <= levelMap.get(group).code;
205    }
206
207    public void log(String message, LogLevel level) {
208        if (shouldLog(level)) {
209            log(message, level, group, className);
210        }
211    }
212
213    private void log(String message, LogLevel messageLevel, LogLevel conditionalLevel) {
214        if (shouldLog(conditionalLevel)) {
215            log(message, messageLevel, group, className);
216        }
217    }
218
219    private void log(Supplier<String> messageSupplier, LogLevel level) {
220        if (shouldLog(level)) {
221            log(messageSupplier.get(), level, group, className);
222        }
223    }
224
225    private void log(
226            Supplier<String> messageSupplier, LogLevel messageLevel, LogLevel conditionalLevel) {
227        if (shouldLog(conditionalLevel)) {
228            log(messageSupplier.get(), messageLevel, group, className);
229        }
230    }
231
232    public void error(Supplier<String> messageSupplier) {
233        log(messageSupplier, LogLevel.ERROR);
234    }
235
236    public void error(String message) {
237        log(message, LogLevel.ERROR);
238    }
239
240    /**
241     * Logs an error message with the stack trace of a Throwable. The stacktrace will only be printed
242     * if the current LogLevel is TRACE
243     *
244     * @param message
245     * @param t
246     */
247    public void error(String message, Throwable t) {
248        log(message + ": " + t.getMessage(), LogLevel.ERROR);
249        log(convertStackTraceToString(t), LogLevel.ERROR, LogLevel.DEBUG);
250    }
251
252    public void warn(Supplier<String> messageSupplier) {
253        log(messageSupplier, LogLevel.WARN);
254    }
255
256    public void warn(String message) {
257        log(message, LogLevel.WARN);
258    }
259
260    public void info(Supplier<String> messageSupplier) {
261        log(messageSupplier, LogLevel.INFO);
262    }
263
264    public void info(String message) {
265        log(message, LogLevel.INFO);
266    }
267
268    public void debug(Supplier<String> messageSupplier) {
269        log(messageSupplier, LogLevel.DEBUG);
270    }
271
272    public void debug(String message) {
273        log(message, LogLevel.DEBUG);
274    }
275
276    public void trace(Supplier<String> messageSupplier) {
277        log(messageSupplier, LogLevel.TRACE);
278    }
279
280    public void trace(String message) {
281        log(message, LogLevel.TRACE);
282    }
283
284    private static String convertStackTraceToString(Throwable throwable) {
285        try (StringWriter sw = new StringWriter();
286                PrintWriter pw = new PrintWriter(sw)) {
287            throwable.printStackTrace(pw);
288            return sw.toString();
289        } catch (IOException ioe) {
290            throw new IllegalStateException(ioe);
291        }
292    }
293
294    private interface LogAppender {
295        void log(String message, LogLevel level);
296
297        /** Release any file or other resources currently held by the Logger */
298        default void shutdown() {}
299    }
300
301    private static class ConsoleLogAppender implements LogAppender {
302        @Override
303        public void log(String message, LogLevel level) {
304            System.out.println(message);
305        }
306    }
307
308    private static class UILogAppender implements LogAppender {
309        @Override
310        public void log(String message, LogLevel level) {
311            var messageMap = new HashMap<String, Object>();
312            messageMap.put("logMessage", message);
313            messageMap.put("logLevel", level.code);
314            var superMap = new HashMap<String, Object>();
315            superMap.put("logMessage", messageMap);
316            DataChangeService.getInstance().publishEvent(OutgoingUIEvent.wrappedOf("log", superMap));
317        }
318    }
319
320    private static class FileLogAppender implements LogAppender {
321        private OutputStream out;
322        private boolean wantsFlush;
323
324        public FileLogAppender(Path logFilePath) {
325            try {
326                this.out = new FileOutputStream(logFilePath.toFile());
327                TimedTaskManager.getInstance()
328                        .addTask(
329                                "FileLogAppender",
330                                () -> {
331                                    try {
332                                        if (wantsFlush) {
333                                            out.flush();
334                                            wantsFlush = false;
335                                        }
336                                    } catch (IOException ignored) {
337                                    }
338                                },
339                                3000L);
340            } catch (FileNotFoundException e) {
341                out = null;
342                System.err.println("Unable to log to file " + logFilePath);
343            }
344        }
345
346        @Override
347        public void log(String message, LogLevel level) {
348            message += "\n";
349            try {
350                out.write(message.getBytes());
351                wantsFlush = true;
352            } catch (IOException e) {
353                e.printStackTrace();
354            } catch (NullPointerException e) {
355                // Nothing to do - no stream available for writing
356            }
357        }
358
359        @Override
360        public void shutdown() {
361            try {
362                out.close();
363                out = null;
364            } catch (IOException e) {
365                // TODO Auto-generated catch block
366                e.printStackTrace();
367            }
368        }
369    }
370}