Compare commits

...

3 Commits

Author SHA1 Message Date
SikongJueluo
66b46c6d70 refactor(logging): migrate from CCLog to structured logger 2025-11-21 21:15:52 +08:00
SikongJueluo
de97fb4858 refactor(logger): simplify logger configuration and improve file rotation 2025-11-21 21:15:06 +08:00
SikongJueluo
0612477325 feat(logging): add auto-cleanup functionality to FileStream 2025-11-21 15:58:42 +08:00
22 changed files with 3745 additions and 3400 deletions

View File

@@ -1,6 +1,5 @@
import { Command, createCli } from "@/lib/ccCLI"; import { Command, createCli } from "@/lib/ccCLI";
import { Ok } from "@/lib/thirdparty/ts-result-es"; import { Ok } from "@/lib/thirdparty/ts-result-es";
import { CCLog } from "@/lib/ccLog";
import { import {
AccessConfig, AccessConfig,
UserGroupConfig, UserGroupConfig,
@@ -8,12 +7,13 @@ import {
saveConfig, saveConfig,
} from "./config"; } from "./config";
import { parseBoolean } from "@/lib/common"; import { parseBoolean } from "@/lib/common";
import { Logger } from "@/lib/ccStructLog";
// 1. Define AppContext // 1. Define AppContext
export interface AppContext { export interface AppContext {
configFilepath: string; configFilepath: string;
reloadConfig: () => void; reloadConfig: () => void;
logger: CCLog; logger: Logger;
print: ( print: (
message: string | MinecraftTextComponent | MinecraftTextComponent[], message: string | MinecraftTextComponent | MinecraftTextComponent[],
) => void; ) => void;
@@ -48,7 +48,9 @@ const addCommand: Command<AppContext> = {
config.adminGroupConfig.groupUsers.push(playerName); config.adminGroupConfig.groupUsers.push(playerName);
} }
} else { } else {
const group = config.usersGroups.find((g) => g.groupName === groupName); const group = config.usersGroups.find(
(g) => g.groupName === groupName,
);
if (!group) { if (!group) {
const groupNames = getGroupNames(config); const groupNames = getGroupNames(config);
context.print({ context.print({
@@ -107,7 +109,9 @@ const delCommand: Command<AppContext> = {
} }
if (group.groupUsers !== undefined) { if (group.groupUsers !== undefined) {
group.groupUsers = group.groupUsers.filter((user) => user !== playerName); group.groupUsers = group.groupUsers.filter(
(user) => user !== playerName,
);
} }
saveConfig(config, context.configFilepath); saveConfig(config, context.configFilepath);
@@ -238,7 +242,10 @@ const configCommand: Command<AppContext> = {
{ name: "value", description: "要设置的值", required: true }, { name: "value", description: "要设置的值", required: true },
], ],
action: ({ args, context }) => { action: ({ args, context }) => {
const [option, valueStr] = [args.option as string, args.value as string]; const [option, valueStr] = [
args.option as string,
args.value as string,
];
const config = loadConfig(context.configFilepath)!; const config = loadConfig(context.configFilepath)!;
// Check if it's a group property (contains a dot) // Check if it's a group property (contains a dot)
@@ -251,7 +258,9 @@ const configCommand: Command<AppContext> = {
if (groupName === "admin") { if (groupName === "admin") {
groupConfig = config.adminGroupConfig; groupConfig = config.adminGroupConfig;
} else { } else {
groupConfig = config.usersGroups.find((g) => g.groupName === groupName); groupConfig = config.usersGroups.find(
(g) => g.groupName === groupName,
);
} }
if (!groupConfig) { if (!groupConfig) {
@@ -321,7 +330,9 @@ const configCommand: Command<AppContext> = {
const value = parseInt(valueStr); const value = parseInt(valueStr);
if (isNaN(value)) { if (isNaN(value)) {
context.print({ text: `无效的值: ${valueStr}. 必须是一个数字。` }); context.print({
text: `无效的值: ${valueStr}. 必须是一个数字。`,
});
return Ok.EMPTY; return Ok.EMPTY;
} }

View File

@@ -11,9 +11,12 @@ import {
ConsoleStream, ConsoleStream,
DAY, DAY,
FileStream, FileStream,
Logger, getStructLogger,
LoggerOptions,
LogLevel, LogLevel,
MB,
processor, processor,
setStructLoggerConfig,
textRenderer, textRenderer,
} from "@/lib/ccStructLog"; } from "@/lib/ccStructLog";
@@ -21,7 +24,7 @@ const args = [...$vararg];
// Init Log // Init Log
let isOnConsoleStream = true; let isOnConsoleStream = true;
const logger = new Logger({ const loggerConfig: LoggerOptions = {
processors: [ processors: [
processor.filterByLevel(LogLevel.Info), processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(), processor.addTimestamp(),
@@ -29,9 +32,19 @@ const logger = new Logger({
renderer: textRenderer, renderer: textRenderer,
streams: [ streams: [
new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream), new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream),
new FileStream("accesscontrol.log", DAY), new FileStream({
filePath: "accesscontrol.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 7,
maxSizeBytes: MB,
},
}),
], ],
}); };
setStructLoggerConfig(loggerConfig);
const logger = getStructLogger();
// Load Config // Load Config
const configFilepath = `${shell.dir()}/access.config.json`; const configFilepath = `${shell.dir()}/access.config.json`;
@@ -70,6 +83,11 @@ function reloadConfig() {
gWatchPlayersInfo = []; gWatchPlayersInfo = [];
releaser.release(); releaser.release();
logger.info("Reload config successfully!"); logger.info("Reload config successfully!");
const tutorial: string[] = [];
tutorial.push("Access Control System started.");
tutorial.push("\tPress 'c' to open configuration TUI.");
tutorial.push("\tPress 'r' to reload configuration.");
print(tutorial.join("\n"));
} }
function safeParseTextComponent( function safeParseTextComponent(

View File

@@ -99,25 +99,31 @@ const AccessControlTUI = () => {
// Validate numbers // Validate numbers
if ( if (
validateNumber(currentConfig.detectInterval?.toString() ?? "") === null validateNumber(
currentConfig.detectInterval?.toString() ?? "",
) === null
) { ) {
showError("Invalid Detect Interval: must be a number"); showError("Invalid Detect Interval: must be a number");
return; return;
} }
if ( if (
validateNumber(currentConfig.watchInterval?.toString() ?? "") === null validateNumber(
currentConfig.watchInterval?.toString() ?? "",
) === null
) { ) {
showError("Invalid Watch Interval: must be a number"); showError("Invalid Watch Interval: must be a number");
return; return;
} }
if ( if (
validateNumber(currentConfig.noticeTimes?.toString() ?? "") === null validateNumber(currentConfig.noticeTimes?.toString() ?? "") ===
null
) { ) {
showError("Invalid Notice Times: must be a number"); showError("Invalid Notice Times: must be a number");
return; return;
} }
if ( if (
validateNumber(currentConfig.detectRange?.toString() ?? "") === null validateNumber(currentConfig.detectRange?.toString() ?? "") ===
null
) { ) {
showError("Invalid Detect Range: must be a number"); showError("Invalid Detect Range: must be a number");
return; return;
@@ -153,7 +159,9 @@ const AccessControlTUI = () => {
for (const toastConfig of toastConfigs) { for (const toastConfig of toastConfigs) {
if (toastConfig.value != undefined) { if (toastConfig.value != undefined) {
const serialized = textutils.serialiseJSON(toastConfig.value); const serialized = textutils.serialiseJSON(
toastConfig.value,
);
if (!validateTextComponent(serialized)) { if (!validateTextComponent(serialized)) {
showError( showError(
`Invalid ${toastConfig.name}: must be valid MinecraftTextComponent JSON`, `Invalid ${toastConfig.name}: must be valid MinecraftTextComponent JSON`,
@@ -210,7 +218,9 @@ const AccessControlTUI = () => {
const currentAdmin = config().adminGroupConfig; const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", { setConfig("adminGroupConfig", {
...currentAdmin, ...currentAdmin,
groupUsers: currentAdmin.groupUsers.filter((user) => user !== userName), groupUsers: currentAdmin.groupUsers.filter(
(user) => user !== userName,
),
}); });
} else { } else {
// Regular group // Regular group
@@ -268,7 +278,10 @@ const AccessControlTUI = () => {
onFocusChanged: () => { onFocusChanged: () => {
const num = validateNumber(getDetectInterval()); const num = validateNumber(getDetectInterval());
if (num !== null) setConfig("detectInterval", num); if (num !== null) setConfig("detectInterval", num);
else setDetectInterval(config().detectInterval.toString()); else
setDetectInterval(
config().detectInterval.toString(),
);
}, },
}), }),
), ),
@@ -282,7 +295,8 @@ const AccessControlTUI = () => {
onFocusChanged: () => { onFocusChanged: () => {
const num = validateNumber(getWatchInterval()); const num = validateNumber(getWatchInterval());
if (num !== null) setConfig("watchInterval", num); if (num !== null) setConfig("watchInterval", num);
else setWatchInterval(config().watchInterval.toString()); else
setWatchInterval(config().watchInterval.toString());
}, },
}), }),
), ),
@@ -347,11 +361,15 @@ const AccessControlTUI = () => {
div( div(
{ class: "flex flex-col" }, { class: "flex flex-col" },
label({}, "Groups:"), label({}, "Groups:"),
For({ each: () => groups, class: "flex flex-col" }, (group, index) => For(
{ each: () => groups, class: "flex flex-col" },
(group, index) =>
button( button(
{ {
class: class:
selectedGroupIndex() === index() ? "bg-blue text-white" : "", selectedGroupIndex() === index()
? "bg-blue text-white"
: "",
onClick: () => setSelectedGroupIndex(index()), onClick: () => setSelectedGroupIndex(index()),
}, },
group.groupName, group.groupName,
@@ -489,7 +507,10 @@ const AccessControlTUI = () => {
* Toast Configuration Tab Factory * Toast Configuration Tab Factory
*/ */
const createToastTab = ( const createToastTab = (
toastType: "welcomeToastConfig" | "warnToastConfig" | "noticeToastConfig", toastType:
| "welcomeToastConfig"
| "warnToastConfig"
| "noticeToastConfig",
) => { ) => {
return () => { return () => {
const toastConfig = config()[toastType]; const toastConfig = config()[toastType];
@@ -533,7 +554,9 @@ const AccessControlTUI = () => {
} catch { } catch {
setTempToastConfig({ setTempToastConfig({
...getTempToastConfig(), ...getTempToastConfig(),
title: textutils.serialiseJSON(currentToastConfig.title), title: textutils.serialiseJSON(
currentToastConfig.title,
),
}); });
} }
}, },
@@ -545,7 +568,10 @@ const AccessControlTUI = () => {
type: "text", type: "text",
value: () => getTempToastConfig().msg, value: () => getTempToastConfig().msg,
onInput: (value) => onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), msg: value }), setTempToastConfig({
...getTempToastConfig(),
msg: value,
}),
onFocusChanged: () => { onFocusChanged: () => {
const currentToastConfig = config()[toastType]; const currentToastConfig = config()[toastType];
@@ -566,7 +592,9 @@ const AccessControlTUI = () => {
} catch { } catch {
setTempToastConfig({ setTempToastConfig({
...getTempToastConfig(), ...getTempToastConfig(),
msg: textutils.serialiseJSON(currentToastConfig.msg), msg: textutils.serialiseJSON(
currentToastConfig.msg,
),
}); });
// Invalid JSON, ignore // Invalid JSON, ignore
} }
@@ -579,13 +607,19 @@ const AccessControlTUI = () => {
input({ input({
type: "text", type: "text",
value: () => { value: () => {
const str = textutils.serialiseJSON(getTempToastConfig().prefix, { const str = textutils.serialiseJSON(
getTempToastConfig().prefix,
{
unicode_strings: true, unicode_strings: true,
}); },
);
return str.substring(1, str.length - 1); return str.substring(1, str.length - 1);
}, },
onInput: (value) => onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), prefix: value }), setTempToastConfig({
...getTempToastConfig(),
prefix: value,
}),
onFocusChanged: () => { onFocusChanged: () => {
const currentToastConfig = config()[toastType]; const currentToastConfig = config()[toastType];
setConfig(toastType, { setConfig(toastType, {
@@ -603,7 +637,10 @@ const AccessControlTUI = () => {
type: "text", type: "text",
value: () => getTempToastConfig().brackets, value: () => getTempToastConfig().brackets,
onInput: (value) => onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), brackets: value }), setTempToastConfig({
...getTempToastConfig(),
brackets: value,
}),
onFocusChanged: () => { onFocusChanged: () => {
const currentToastConfig = config()[toastType]; const currentToastConfig = config()[toastType];
setConfig(toastType, { setConfig(toastType, {
@@ -678,7 +715,10 @@ const AccessControlTUI = () => {
{ when: () => currentTab() === TABS.WELCOME_TOAST }, { when: () => currentTab() === TABS.WELCOME_TOAST },
WelcomeToastTab(), WelcomeToastTab(),
), ),
Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()), Match(
{ when: () => currentTab() === TABS.WARN_TOAST },
WarnToastTab(),
),
Match( Match(
{ when: () => currentTab() === TABS.NOTICE_TOAST }, { when: () => currentTab() === TABS.NOTICE_TOAST },
NoticeToastTab(), NoticeToastTab(),
@@ -703,7 +743,10 @@ const AccessControlTUI = () => {
For({ each: () => tabNames }, (tabName, index) => For({ each: () => tabNames }, (tabName, index) =>
button( button(
{ {
class: currentTab() === index() ? "bg-blue text-white" : "", class:
currentTab() === index()
? "bg-blue text-white"
: "",
onClick: () => setCurrentTab(index() as TabIndex), onClick: () => setCurrentTab(index() as TabIndex),
}, },
tabName, tabName,

View File

@@ -6,6 +6,8 @@ import {
import { Queue } from "@/lib/datatype/Queue"; import { Queue } from "@/lib/datatype/Queue";
import { import {
ConsoleStream, ConsoleStream,
DAY,
FileStream,
Logger, Logger,
LogLevel, LogLevel,
processor, processor,
@@ -18,7 +20,17 @@ const logger = new Logger({
processor.addTimestamp(), processor.addTimestamp(),
], ],
renderer: textRenderer, renderer: textRenderer,
streams: [new ConsoleStream()], streams: [
new ConsoleStream(),
new FileStream({
filePath: "autocraft.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 3,
},
}),
],
}); });
const peripheralsNames = { const peripheralsNames = {
@@ -47,7 +59,8 @@ enum State {
} }
function main() { function main() {
while (true) { let isFinishedInitPeripheral = false;
while (!isFinishedInitPeripheral) {
try { try {
packsInventory = peripheral.wrap( packsInventory = peripheral.wrap(
peripheralsNames.packsInventory, peripheralsNames.packsInventory,
@@ -67,7 +80,7 @@ function main() {
turtleLocalName = wiredModem.getNameLocal(); turtleLocalName = wiredModem.getNameLocal();
logger.info("Peripheral initialization complete..."); logger.info("Peripheral initialization complete...");
break; isFinishedInitPeripheral = true;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`Peripheral initialization failed for ${String(error)}, try again...`, `Peripheral initialization failed for ${String(error)}, try again...`,

View File

@@ -4,6 +4,9 @@
*/ */
import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types"; import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
import { processor } from "./processors";
import { ConsoleStream } from "./streams";
import { textRenderer } from "./renderers";
/** /**
* The main Logger class that orchestrates the logging pipeline. * The main Logger class that orchestrates the logging pipeline.
@@ -13,26 +16,19 @@ import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
*/ */
export class Logger implements ILogger { export class Logger implements ILogger {
private options: LoggerOptions; private options: LoggerOptions;
private loggerName?: string;
/** /**
* Create a new Logger instance. * Create a new Logger instance.
* *
* @param options - Configuration options for the logger * @param options - Configuration options for the logger
* @param name - The name of the logger
*/ */
constructor(options: Partial<LoggerOptions>) { constructor(options: LoggerOptions, name?: string) {
this.options = { this.options = options;
processors: options.processors ?? [], this.loggerName = name;
renderer: options.renderer ?? this.defaultRenderer,
streams: options.streams ?? [],
};
} }
/**
* Default renderer that returns an empty string.
* Used as fallback when no renderer is provided.
*/
private defaultRenderer = (): string => "";
/** /**
* Main logging method that handles the complete logging pipeline. * Main logging method that handles the complete logging pipeline.
* *
@@ -51,6 +47,8 @@ export class Logger implements ILogger {
["message", message], ["message", message],
...Object.entries(context), ...Object.entries(context),
]); ]);
if (this.loggerName !== undefined)
event.set("loggerName", this.loggerName);
// 2. Process through the processor chain // 2. Process through the processor chain
for (const processor of this.options.processors) { for (const processor of this.options.processors) {
@@ -62,12 +60,11 @@ export class Logger implements ILogger {
// 3. Render and output if event wasn't dropped // 3. Render and output if event wasn't dropped
if (event !== undefined) { if (event !== undefined) {
const finalEvent = event; const output = this.options.renderer(event);
const output = this.options.renderer(finalEvent);
// Send to all configured streams // Send to all configured streams
for (const stream of this.options.streams) { for (const stream of this.options.streams) {
stream.write(output, finalEvent); stream.write(output, event);
} }
} }
} }
@@ -163,3 +160,17 @@ export class Logger implements ILogger {
} }
} }
} }
let globalLoggerConfig: LoggerOptions = {
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [new ConsoleStream()],
};
export function getStructLogger(name?: string): Logger {
return new Logger(globalLoggerConfig, name);
}
export function setStructLoggerConfig(config: LoggerOptions): void {
globalLoggerConfig = config;
}

View File

@@ -61,7 +61,7 @@ export namespace processor {
return event; // Pass through if no level is set return event; // Pass through if no level is set
} }
if (eventLevel !== undefined && eventLevel < minLevel) { if (eventLevel < minLevel) {
return undefined; // Drop the log event return undefined; // Drop the log event
} }
@@ -69,22 +69,6 @@ export namespace processor {
}; };
} }
/**
* Adds a logger name/source to the log event.
*
* This processor is useful when you have multiple loggers in your application
* and want to identify which component generated each log entry.
*
* @param name - The name/source to add to log events
* @returns A processor function that adds the source name
*/
export function addSource(name: string): Processor {
return (event) => {
event.set("source", name);
return event;
};
}
/** /**
* Adds the current computer ID to the log event. * Adds the current computer ID to the log event.
* *

View File

@@ -53,14 +53,20 @@ export const textRenderer: Renderer = (event) => {
const timeStr = event.get("timestamp") as string | undefined; const timeStr = event.get("timestamp") as string | undefined;
const level: string | undefined = LogLevel[event.get("level") as LogLevel]; const level: string | undefined = LogLevel[event.get("level") as LogLevel];
const message = (event.get("message") as string) ?? ""; const message = (event.get("message") as string) ?? "";
const loggerName = event.get("loggerName") as string | undefined;
// Start building the output // Start building the output
let output = `[${timeStr}] [${level}] ${message} \t`; let output = `${timeStr} [${level}] ${message} \t ${loggerName !== undefined ? "[" + loggerName + "]" : ""}`;
// Add context fields (excluding the core fields we already used) // Add context fields (excluding the core fields we already used)
const contextFields: string[] = []; const contextFields: string[] = [];
for (const [key, value] of event.entries()) { for (const [key, value] of event.entries()) {
if (key !== "timestamp" && key !== "level" && key !== "message") { if (
key !== "timestamp" &&
key !== "level" &&
key !== "message" &&
key !== "loggerName"
) {
contextFields.push(`${key}=${tostring(value)}`); contextFields.push(`${key}=${tostring(value)}`);
} }
} }

View File

@@ -8,6 +8,33 @@
import { LogLevel, Stream, LogEvent } from "./types"; import { LogLevel, Stream, LogEvent } from "./types";
/**
* Configuration interface for FileStream with auto-cleanup options.
*/
interface FileStreamConfig {
/** Path to the log file */
filePath: string;
/**
* Time in seconds between file rotations (0 = no rotation)
* Time must larger than one DAY
* @default 0
*/
rotationInterval?: number;
/** Auto-cleanup configuration */
autoCleanup?: {
/** Whether to enable auto-cleanup */
enabled: boolean;
/** Maximum number of log files to keep */
maxFiles?: number;
/** Maximum total size in bytes for all log files */
maxSizeBytes?: number;
/** Directory to search for log files (defaults to log file directory) */
logDir?: string;
/** File pattern to match (defaults to base filename pattern) */
pattern?: string;
};
}
/** /**
* Console stream that outputs to the CC:Tweaked terminal. * Console stream that outputs to the CC:Tweaked terminal.
* *
@@ -59,20 +86,20 @@ export class FileStream implements Stream {
private filePath: string; private filePath: string;
private rotationInterval: number; private rotationInterval: number;
private lastRotationTime: number; private lastRotationTime: number;
private baseFilename: string; private autoCleanupConfig?: FileStreamConfig["autoCleanup"];
/** /**
* Create a new file stream. * Create a new file stream with configuration object.
* *
* @param filePath - Path to the log file * @param config - FileStream configuration object
* @param rotationInterval - Time in seconds between file rotations (0 = no rotation)
*/ */
constructor(filePath: string, rotationInterval: number = 0) { constructor(config: FileStreamConfig) {
this.filePath = filePath; this.filePath = config.filePath;
this.rotationInterval = rotationInterval; this.rotationInterval = config.rotationInterval || 0;
if (this.rotationInterval !== 0 && this.rotationInterval < DAY)
throw Error("Rotation interval must be at least one day");
this.autoCleanupConfig = config.autoCleanup;
this.lastRotationTime = os.time(); this.lastRotationTime = os.time();
this.baseFilename = filePath;
this.openFile(); this.openFile();
} }
@@ -94,24 +121,27 @@ export class FileStream implements Stream {
return; return;
} }
this.fileHandle = handle; this.fileHandle = handle;
// Perform auto-cleanup when opening file
this.performAutoCleanup();
} }
/** /**
* Generate a filename with timestamp for file rotation. * Generate a filename with timestamp for file rotation.
*/ */
private getRotatedFilename(): string { private getRotatedFilename(): string {
const currentTime = os.time(); const currentTime = os.time(os.date("*t"));
const rotationPeriod = const rotationPeriod =
Math.floor(currentTime / this.rotationInterval) * Math.floor(currentTime / this.rotationInterval) *
this.rotationInterval; this.rotationInterval;
const date = os.date("*t", rotationPeriod) as LuaDate; const date = os.date("*t", rotationPeriod) as LuaDate;
const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}_${string.format("%02d", date.hour)}-${string.format("%02d", date.min)}`; const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}`;
// Split filename and extension // Split filename and extension
const splitStrs = this.baseFilename.split("."); const splitStrs = this.filePath.split(".");
if (splitStrs.length === 1) { if (splitStrs.length === 1) {
return `${this.baseFilename}_${timestamp}.log`; return `${this.filePath}_${timestamp}.log`;
} }
const name = splitStrs[0]; const name = splitStrs[0];
@@ -126,19 +156,50 @@ export class FileStream implements Stream {
if (this.rotationInterval <= 0) return; if (this.rotationInterval <= 0) return;
const currentTime = os.time(); const currentTime = os.time();
const currentPeriod = Math.floor(currentTime / this.rotationInterval); if (
const lastPeriod = Math.floor( Math.floor(
this.lastRotationTime / this.rotationInterval, (currentTime - this.lastRotationTime) / this.rotationInterval,
); ) > 0
) {
if (currentPeriod > lastPeriod) {
// Time to rotate // Time to rotate
this.close(); this.close();
this.lastRotationTime = currentTime; this.lastRotationTime = currentTime;
this.openFile(); this.openFile();
// Auto-cleanup is performed in openFile()
} }
} }
/**
* Perform auto-cleanup based on configuration.
* This method is called automatically when opening files or rotating.
*/
private performAutoCleanup(): void {
if (!this.autoCleanupConfig || !this.autoCleanupConfig.enabled) {
return;
}
const config = this.autoCleanupConfig;
// Cleanup by file count if configured
if (config.maxFiles !== undefined && config.maxFiles > 0) {
this.cleanupOldLogFiles(config.maxFiles, config.logDir);
}
// Cleanup by total size if configured
if (config.maxSizeBytes !== undefined && config.maxSizeBytes > 0) {
this.cleanupLogFilesBySize(config.maxSizeBytes, config.logDir);
}
}
/**
* Enable or update auto-cleanup configuration at runtime.
*
* @param config - Auto-cleanup configuration
*/
public setAutoCleanup(config: FileStreamConfig["autoCleanup"]): void {
this.autoCleanupConfig = config;
}
/** /**
* Write a formatted log message to the file. * Write a formatted log message to the file.
* *
@@ -163,6 +224,123 @@ export class FileStream implements Stream {
this.fileHandle = undefined; this.fileHandle = undefined;
} }
} }
/**
* Search for log files matching the specified pattern in a directory.
*
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @returns Array of log file information including path, size, and modification time
*/
private searchLogFiles(
logDir?: string,
): Array<{ path: string; size: number; modified: number }> {
const directory = logDir || fs.getDir(this.filePath);
const splitStrs = this.filePath.split(".");
const name = splitStrs[0] + "_";
const ext = splitStrs.length > 1 ? splitStrs[1] : "log";
if (!fs.exists(directory) || !fs.isDir(directory)) {
return [];
}
const logFiles: Array<{
path: string;
size: number;
modified: number;
}> = [];
const files = fs.list(directory);
for (const file of files) {
const fullPath = fs.combine(directory, file);
if (
fs.isDir(fullPath) ||
!file.startsWith(name) ||
!file.endsWith(ext)
)
continue;
const attributes = fs.attributes(fullPath);
if (attributes !== undefined) {
logFiles.push({
path: fullPath,
size: attributes.size,
modified: attributes.modified,
});
}
}
return logFiles;
}
/**
* Clean up old log files by keeping only the specified number of most recent files.
*
* @param maxFiles - Maximum number of log files to keep
* @param logDir - Directory containing log files (defaults to directory of current log file)
*/
public cleanupOldLogFiles(maxFiles: number, logDir?: string): void {
if (maxFiles <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length <= maxFiles) return;
// Sort by modification time (newest first)
logFiles.sort((a, b) => b.modified - a.modified);
// Delete files beyond the limit
for (let i = maxFiles; i < logFiles.length; i++) {
try {
fs.delete(logFiles[i].path);
} catch (err) {
printError(
`Failed to delete old log file ${logFiles[i].path}: ${err}`,
);
}
}
}
/**
* Clean up log files by total size, deleting oldest files until total size is under limit.
*
* @param maxSizeBytes - Maximum total size in bytes for all log files
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @param fileName - Base File Name
*/
public cleanupLogFilesBySize(maxSizeBytes: number, logDir?: string): void {
if (maxSizeBytes <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length === 0) return;
// Calculate total size
let totalSize = 0;
for (const logFile of logFiles) {
totalSize += logFile.size;
}
// If total size is within limit, no cleanup needed
if (totalSize <= maxSizeBytes) {
return;
}
// Sort by modification time (oldest first for deletion)
logFiles.sort((a, b) => a.modified - b.modified);
// Delete oldest files until we're under the size limit
for (const logFile of logFiles) {
if (totalSize <= maxSizeBytes) {
break;
}
try {
fs.delete(logFile.path);
totalSize -= logFile.size;
} catch (err) {
printError(`Failed to delete log file ${logFile.path}: ${err}`);
}
}
}
} }
/** /**
@@ -307,3 +485,7 @@ export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE; export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR; export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY; export const WEEK = 7 * DAY;
// Byte constants for file rotation
export const MB = 1024 * 1024;
export const KB = 1024;

View File

@@ -398,11 +398,17 @@ export class UIObject {
const newScrollX = Math.max( const newScrollX = Math.max(
0, 0,
Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX), Math.min(
this.scrollProps.maxScrollX,
this.scrollProps.scrollX + deltaX,
),
); );
const newScrollY = Math.max( const newScrollY = Math.max(
0, 0,
Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY), Math.min(
this.scrollProps.maxScrollY,
this.scrollProps.scrollY + deltaY,
),
); );
this.scrollProps.scrollX = newScrollX; this.scrollProps.scrollX = newScrollX;

View File

@@ -5,10 +5,10 @@
import { UIObject } from "./UIObject"; import { UIObject } from "./UIObject";
import { calculateLayout } from "./layout"; import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer"; import { render as renderTree, clearScreen } from "./renderer";
import { CCLog, DAY, LogLevel } from "../ccLog";
import { setLogger } from "./context";
import { InputProps } from "./components"; import { InputProps } from "./components";
import { Setter } from "./reactivity"; import { Setter } from "./reactivity";
import { getStructLogger, Logger } from "@/lib/ccStructLog";
import { setLogger } from "./context";
/** /**
* Main application class * Main application class
@@ -21,7 +21,7 @@ export class Application {
private focusedNode?: UIObject; private focusedNode?: UIObject;
private termWidth: number; private termWidth: number;
private termHeight: number; private termHeight: number;
private logger: CCLog; private logger: Logger;
private cursorBlinkState = false; private cursorBlinkState = false;
private lastBlinkTime = 0; private lastBlinkTime = 0;
private readonly BLINK_INTERVAL = 0.5; // seconds private readonly BLINK_INTERVAL = 0.5; // seconds
@@ -30,11 +30,7 @@ export class Application {
const [width, height] = term.getSize(); const [width, height] = term.getSize();
this.termWidth = width; this.termWidth = width;
this.termHeight = height; this.termHeight = height;
this.logger = new CCLog("tui_debug.log", { this.logger = getStructLogger("ccTUI");
printTerminal: false,
logInterval: DAY,
outputMinLevel: LogLevel.Info,
});
setLogger(this.logger); setLogger(this.logger);
this.logger.debug("Application constructed."); this.logger.debug("Application constructed.");
} }
@@ -99,7 +95,6 @@ export class Application {
this.root.unmount(); this.root.unmount();
} }
this.logger.close();
clearScreen(); clearScreen();
} }
@@ -224,8 +219,10 @@ export class Application {
| undefined; | undefined;
if (type === "checkbox") { if (type === "checkbox") {
// Toggle checkbox // Toggle checkbox
const onChangeProp = (this.focusedNode.props as InputProps).onChange; const onChangeProp = (this.focusedNode.props as InputProps)
const checkedProp = (this.focusedNode.props as InputProps).checked; .onChange;
const checkedProp = (this.focusedNode.props as InputProps)
.checked;
if ( if (
typeof onChangeProp === "function" && typeof onChangeProp === "function" &&
@@ -260,7 +257,10 @@ export class Application {
const valueProp = (this.focusedNode.props as InputProps).value; const valueProp = (this.focusedNode.props as InputProps).value;
const onInputProp = (this.focusedNode.props as InputProps).onInput; const onInputProp = (this.focusedNode.props as InputProps).onInput;
if (typeof valueProp !== "function" || typeof onInputProp !== "function") { if (
typeof valueProp !== "function" ||
typeof onInputProp !== "function"
) {
return; return;
} }
@@ -273,7 +273,10 @@ export class Application {
this.needsRender = true; this.needsRender = true;
} else if (key === keys.right) { } else if (key === keys.right) {
// Move cursor right // Move cursor right
this.focusedNode.cursorPos = math.min(currentValue.length, cursorPos + 1); this.focusedNode.cursorPos = math.min(
currentValue.length,
cursorPos + 1,
);
this.needsRender = true; this.needsRender = true;
} else if (key === keys.backspace) { } else if (key === keys.backspace) {
// Delete character before cursor // Delete character before cursor
@@ -301,11 +304,15 @@ export class Application {
* Handle character input events * Handle character input events
*/ */
private handleCharEvent(char: string): void { private handleCharEvent(char: string): void {
if (this.focusedNode !== undefined && this.focusedNode.type === "input") { if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input"
) {
const type = (this.focusedNode.props as InputProps).type; const type = (this.focusedNode.props as InputProps).type;
if (type !== "checkbox") { if (type !== "checkbox") {
// Insert character at cursor position // Insert character at cursor position
const onInputProp = (this.focusedNode.props as InputProps).onInput; const onInputProp = (this.focusedNode.props as InputProps)
.onInput;
const valueProp = (this.focusedNode.props as InputProps).value; const valueProp = (this.focusedNode.props as InputProps).value;
if ( if (
@@ -338,7 +345,10 @@ export class Application {
if (clicked !== undefined) { if (clicked !== undefined) {
this.logger.debug( this.logger.debug(
string.format("handleMouseClick: Found node of type %s.", clicked.type), string.format(
"handleMouseClick: Found node of type %s.",
clicked.type,
),
); );
// Set focus // Set focus
if ( if (
@@ -351,7 +361,8 @@ export class Application {
} }
this.focusedNode = clicked; this.focusedNode = clicked;
if (typeof clicked.props.onFocusChanged === "function") { if (typeof clicked.props.onFocusChanged === "function") {
const onFocusChanged = clicked.props.onFocusChanged as Setter<boolean>; const onFocusChanged = clicked.props
.onFocusChanged as Setter<boolean>;
onFocusChanged(true); onFocusChanged(true);
} }
@@ -375,11 +386,15 @@ export class Application {
"handleMouseClick: onClick handler found, executing.", "handleMouseClick: onClick handler found, executing.",
); );
(onClick as () => void)(); (onClick as () => void)();
this.logger.debug("handleMouseClick: onClick handler finished."); this.logger.debug(
"handleMouseClick: onClick handler finished.",
);
this.needsRender = true; this.needsRender = true;
} }
} else if (clicked.type === "input") { } else if (clicked.type === "input") {
const type = (clicked.props as InputProps).type as string | undefined; const type = (clicked.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") { if (type === "checkbox") {
const onChangeProp = (clicked.props as InputProps).onChange; const onChangeProp = (clicked.props as InputProps).onChange;
const checkedProp = (clicked.props as InputProps).checked; const checkedProp = (clicked.props as InputProps).checked;
@@ -397,7 +412,9 @@ export class Application {
this.needsRender = true; this.needsRender = true;
} else { } else {
this.logger.debug("handleMouseClick: No node found at click position."); this.logger.debug(
"handleMouseClick: No node found at click position.",
);
} }
} }
@@ -432,7 +449,9 @@ export class Application {
); );
// Only return interactive elements // Only return interactive elements
if (node.type === "button" || node.type === "input") { if (node.type === "button" || node.type === "input") {
this.logger.debug("findNodeAt: Node is interactive, returning."); this.logger.debug(
"findNodeAt: Node is interactive, returning.",
);
return node; return node;
} }
} }
@@ -502,7 +521,9 @@ export class Application {
); );
// Only return scrollable elements // Only return scrollable elements
if (node.type === "scroll-container") { if (node.type === "scroll-container") {
this.logger.debug("findNodeAt: Node is scrollable, returning."); this.logger.debug(
"findNodeAt: Node is scrollable, returning.",
);
return node; return node;
} }
} }

View File

@@ -167,12 +167,15 @@ export function label(
const sentences = createMemo(() => { const sentences = createMemo(() => {
const words = splitByWhitespace(text()); const words = splitByWhitespace(text());
const ret = concatSentence(words, 40); const ret = concatSentence(words, 40);
context.logger?.debug(`label words changed : [ ${ret.join(",")} ]`); context.logger?.debug(
`label words changed : [ ${ret.join(",")} ]`,
);
return ret; return ret;
}); });
const forNode = For({ class: `flex flex-col`, each: sentences }, (word) => const forNode = For(
label({ class: p.class }, word), { class: `flex flex-col`, each: sentences },
(word) => label({ class: p.class }, word),
); );
return forNode; return forNode;

View File

@@ -4,13 +4,13 @@
* to all components without prop drilling. * to all components without prop drilling.
*/ */
import type { CCLog } from "../ccLog"; import { Logger } from "@/lib/ccStructLog";
/** /**
* The global context object for the TUI application. * The global context object for the TUI application.
* This will be set by the Application instance on creation. * This will be set by the Application instance on creation.
*/ */
export const context: { logger: CCLog | undefined } = { export const context: { logger: Logger | undefined } = {
logger: undefined, logger: undefined,
}; };
@@ -18,6 +18,6 @@ export const context: { logger: CCLog | undefined } = {
* Sets the global logger instance. * Sets the global logger instance.
* @param l The logger instance. * @param l The logger instance.
*/ */
export function setLogger(l: CCLog): void { export function setLogger(l: Logger): void {
context.logger = l; context.logger = l;
} }

View File

@@ -62,7 +62,10 @@ function measureNode(
if (node.styleProps.width === "screen") { if (node.styleProps.width === "screen") {
const termSize = getTerminalSize(); const termSize = getTerminalSize();
measuredWidth = termSize.width; measuredWidth = termSize.width;
} else if (node.styleProps.width === "full" && parentWidth !== undefined) { } else if (
node.styleProps.width === "full" &&
parentWidth !== undefined
) {
measuredWidth = parentWidth; measuredWidth = parentWidth;
} else if (typeof node.styleProps.width === "number") { } else if (typeof node.styleProps.width === "number") {
measuredWidth = node.styleProps.width; measuredWidth = node.styleProps.width;
@@ -297,7 +300,13 @@ export function calculateLayout(
const childY = startY + scrollOffsetY; const childY = startY + scrollOffsetY;
// Recursively calculate layout for child with its natural size // Recursively calculate layout for child with its natural size
calculateLayout(child, childSize.width, childSize.height, childX, childY); calculateLayout(
child,
childSize.width,
childSize.height,
childX,
childY,
);
} }
return; return;
} }
@@ -368,7 +377,8 @@ export function calculateLayout(
// Cross axis (vertical) alignment // Cross axis (vertical) alignment
if (align === "center") { if (align === "center") {
childY = startY + math.floor((availableHeight - measure.height) / 2); childY =
startY + math.floor((availableHeight - measure.height) / 2);
} else if (align === "end") { } else if (align === "end") {
childY = startY + (availableHeight - measure.height); childY = startY + (availableHeight - measure.height);
} else { } else {
@@ -385,7 +395,8 @@ export function calculateLayout(
// Cross axis (horizontal) alignment // Cross axis (horizontal) alignment
if (align === "center") { if (align === "center") {
childX = startX + math.floor((availableWidth - measure.width) / 2); childX =
startX + math.floor((availableWidth - measure.width) / 2);
} else if (align === "end") { } else if (align === "end") {
childX = startX + (availableWidth - measure.width); childX = startX + (availableWidth - measure.width);
} else { } else {

View File

@@ -75,10 +75,10 @@ export function createSignal<T>(initialValue: T): Signal<T> {
// Notify all subscribed listeners // Notify all subscribed listeners
if (batchDepth > 0) { if (batchDepth > 0) {
// In batch mode, collect effects to run later // In batch mode, collect effects to run later
listeners.forEach(listener => pendingEffects.add(listener)); listeners.forEach((listener) => pendingEffects.add(listener));
} else { } else {
// Run effects immediately // Run effects immediately
listeners.forEach(listener => { listeners.forEach((listener) => {
try { try {
listener(); listener();
} catch (e) { } catch (e) {
@@ -152,7 +152,7 @@ export function batch(fn: () => void): void {
const effects = Array.from(pendingEffects); const effects = Array.from(pendingEffects);
pendingEffects.clear(); pendingEffects.clear();
effects.forEach(effect => { effects.forEach((effect) => {
try { try {
effect(); effect();
} catch (e) { } catch (e) {

View File

@@ -18,7 +18,10 @@ function getTextContent(node: UIObject): string {
} }
// For nodes with text children, get their content // For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) { if (
node.children.length > 0 &&
node.children[0].textContent !== undefined
) {
const child = node.children[0]; const child = node.children[0];
if (typeof child.textContent === "function") { if (typeof child.textContent === "function") {
return child.textContent(); return child.textContent();
@@ -39,7 +42,11 @@ function isPositionVisible(
): boolean { ): boolean {
let current = node.parent; let current = node.parent;
while (current) { while (current) {
if (isScrollContainer(current) && current.layout && current.scrollProps) { if (
isScrollContainer(current) &&
current.layout &&
current.scrollProps
) {
const { x: containerX, y: containerY } = current.layout; const { x: containerX, y: containerY } = current.layout;
const { viewportWidth, viewportHeight } = current.scrollProps; const { viewportWidth, viewportHeight } = current.scrollProps;
@@ -189,7 +196,9 @@ function drawNode(
} }
case "input": { case "input": {
const type = (node.props as InputProps).type as string | undefined; const type = (node.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") { if (type === "checkbox") {
// Draw checkbox // Draw checkbox
@@ -224,7 +233,11 @@ function drawNode(
const focusedBgColor = bgColor ?? colors.white; const focusedBgColor = bgColor ?? colors.white;
const unfocusedBgColor = bgColor ?? colors.black; const unfocusedBgColor = bgColor ?? colors.black;
if (displayText === "" && placeholder !== undefined && !focused) { if (
displayText === "" &&
placeholder !== undefined &&
!focused
) {
displayText = placeholder; displayText = placeholder;
showPlaceholder = true; showPlaceholder = true;
currentTextColor = currentTextColor ?? colors.gray; currentTextColor = currentTextColor ?? colors.gray;
@@ -235,7 +248,9 @@ function drawNode(
} }
// Set background and clear the input area, creating a 1-character padding on the left // Set background and clear the input area, creating a 1-character padding on the left
term.setBackgroundColor(focused ? focusedBgColor : unfocusedBgColor); term.setBackgroundColor(
focused ? focusedBgColor : unfocusedBgColor,
);
term.setCursorPos(x, y); term.setCursorPos(x, y);
term.write(" ".repeat(width)); term.write(" ".repeat(width));
@@ -247,7 +262,9 @@ function drawNode(
// Move text if it's too long for the padded area // Move text if it's too long for the padded area
const startDisPos = const startDisPos =
cursorPos >= renderWidth ? cursorPos - renderWidth + 1 : 0; cursorPos >= renderWidth
? cursorPos - renderWidth + 1
: 0;
const stopDisPos = startDisPos + renderWidth; const stopDisPos = startDisPos + renderWidth;
if (focused && !showPlaceholder && cursorBlinkState) { if (focused && !showPlaceholder && cursorBlinkState) {
@@ -271,7 +288,10 @@ function drawNode(
} }
} }
// Draw cursor at the end of the text if applicable // Draw cursor at the end of the text if applicable
if (cursorPos === textToRender.length && cursorPos < renderWidth) { if (
cursorPos === textToRender.length &&
cursorPos < renderWidth
) {
term.setBackgroundColor(currentTextColor); term.setBackgroundColor(currentTextColor);
term.setTextColor(focusedBgColor); term.setTextColor(focusedBgColor);
term.write(" "); term.write(" ");
@@ -281,7 +301,9 @@ function drawNode(
} }
} else { } else {
// Not focused or no cursor, just write the text // Not focused or no cursor, just write the text
term.write(textToRender.substring(startDisPos, stopDisPos)); term.write(
textToRender.substring(startDisPos, stopDisPos),
);
} }
} }
break; break;

View File

@@ -45,7 +45,9 @@ export interface SetStoreFunction<T> {
* setTodos([{ title: "First", done: false }]); * setTodos([{ title: "First", done: false }]);
* ``` * ```
*/ */
export function createStore<T extends object>(initialValue: T): [Accessor<T>, SetStoreFunction<T>] { export function createStore<T extends object>(
initialValue: T,
): [Accessor<T>, SetStoreFunction<T>] {
// Use a signal to track the entire state // Use a signal to track the entire state
const [get, set] = createSignal(initialValue); const [get, set] = createSignal(initialValue);
@@ -88,8 +90,11 @@ export function createStore<T extends object>(initialValue: T): [Accessor<T>, Se
if (Array.isArray(current)) { if (Array.isArray(current)) {
const newArray = [...current] as unknown[]; const newArray = [...current] as unknown[];
if (typeof newArray[index] === "object" && newArray[index] !== undefined) { if (
newArray[index] = { ...(newArray[index]!), [key]: value }; typeof newArray[index] === "object" &&
newArray[index] !== undefined
) {
newArray[index] = { ...newArray[index]!, [key]: value };
} }
set(newArray as T); set(newArray as T);
} }

View File

View File

@@ -36,7 +36,13 @@ const customLogger = new Logger({
}), }),
], ],
renderer: jsonRenderer, renderer: jsonRenderer,
streams: [new ConsoleStream(), new FileStream("custom.log", HOUR)], streams: [
new ConsoleStream(),
new FileStream({
filePath: "custom.log",
rotationInterval: HOUR,
}),
],
}); });
customLogger.info("Custom logger example", { customLogger.info("Custom logger example", {
@@ -136,7 +142,10 @@ const multiFormatLogger = new Logger({
{ {
write: (_, event) => { write: (_, event) => {
const formatted = jsonRenderer(event); const formatted = jsonRenderer(event);
new FileStream("structured.log").write(formatted, event); new FileStream({ filePath: "structured.log" }).write(
formatted,
event,
);
}, },
}, },
], ],
@@ -193,7 +202,7 @@ print("\n=== Cleanup Examples ===");
const fileLogger = new Logger({ const fileLogger = new Logger({
processors: [processor.addTimestamp()], processors: [processor.addTimestamp()],
renderer: jsonRenderer, renderer: jsonRenderer,
streams: [new FileStream("temp.log")], streams: [new FileStream({ filePath: "structured.log" })],
}); });
fileLogger.info("Temporary log entry"); fileLogger.info("Temporary log entry");