refactor(logging): simplify to daily-only rotation

- Remove rotation_strategy and max_file_size settings
  - Always use YYYY-MM-DD.log format for log files
  - Update cleanup job to parse dates from filenames
  - Simplify settings UI to retention days only
This commit is contained in:
Sam Chau
2025-10-21 07:44:37 +10:30
parent 303e81507f
commit 0a9b287825
22 changed files with 249 additions and 246 deletions

View File

@@ -55,11 +55,11 @@
{step}
{required}
{disabled}
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<!-- Custom increment/decrement buttons -->
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 flex-col">
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 flex-col">
<button
type="button"
on:click={increment}

View File

@@ -51,7 +51,9 @@ class DatabaseManager {
await Deno.mkdir(config.paths.data, { recursive: true });
// Check if database exists before opening
const dbExists = await Deno.stat(config.paths.database).then(() => true).catch(() => false);
const dbExists = await Deno.stat(config.paths.database)
.then(() => true)
.catch(() => false);
if (!dbExists) {
await logger.warn('Database file does not exist, creating new database', {

View File

@@ -7,6 +7,7 @@ import { migration as migration002 } from './migrations/002_remove_sync_profile.
import { migration as migration003 } from './migrations/003_create_log_settings.ts';
import { migration as migration004 } from './migrations/004_create_jobs_tables.ts';
import { migration as migration005 } from './migrations/005_create_backup_settings.ts';
import { migration as migration006 } from './migrations/006_simplify_log_settings.ts';
export interface Migration {
version: number;
@@ -223,13 +224,14 @@ export const migrationRunner = new MigrationRunner();
* Helper function to load migrations
* Returns all statically imported migrations
*/
export async function loadMigrations(): Promise<Migration[]> {
export function loadMigrations(): Migration[] {
const migrations: Migration[] = [
migration001,
migration002,
migration003,
migration004,
migration005,
migration006
];
// Sort by version number
@@ -240,6 +242,6 @@ export async function loadMigrations(): Promise<Migration[]> {
* Run migrations
*/
export async function runMigrations(migrations?: Migration[]): Promise<void> {
const migrationsToRun = migrations ?? (await loadMigrations());
const migrationsToRun = migrations ?? loadMigrations();
await migrationRunner.up(migrationsToRun);
}

View File

@@ -0,0 +1,121 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 006: Simplify log settings to daily-only rotation
*
* Removes rotation_strategy and max_file_size columns.
* Logs will now always use daily rotation (YYYY-MM-DD.log format).
*/
export const migration: Migration = {
version: 6,
name: 'Simplify log settings to daily-only rotation',
up: `
-- Remove rotation strategy and max file size columns
-- SQLite doesn't support DROP COLUMN directly in all versions,
-- so we need to recreate the table
-- Create new table with updated schema
CREATE TABLE log_settings_new (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- Retention
retention_days INTEGER NOT NULL DEFAULT 30,
-- Log Level
min_level TEXT NOT NULL DEFAULT 'INFO' CHECK (min_level IN ('DEBUG', 'INFO', 'WARN', 'ERROR')),
-- Enable/Disable
enabled INTEGER NOT NULL DEFAULT 1,
file_logging INTEGER NOT NULL DEFAULT 1,
console_logging INTEGER NOT NULL DEFAULT 1,
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Copy data from old table (excluding removed columns)
INSERT INTO log_settings_new (
id,
retention_days,
min_level,
enabled,
file_logging,
console_logging,
created_at,
updated_at
)
SELECT
id,
retention_days,
min_level,
enabled,
file_logging,
console_logging,
created_at,
updated_at
FROM log_settings;
-- Drop old table
DROP TABLE log_settings;
-- Rename new table to original name
ALTER TABLE log_settings_new RENAME TO log_settings;
`,
down: `
-- Recreate table with rotation_strategy and max_file_size columns
CREATE TABLE log_settings_new (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- Rotation & Retention
rotation_strategy TEXT NOT NULL DEFAULT 'daily' CHECK (rotation_strategy IN ('daily', 'size', 'both')),
retention_days INTEGER NOT NULL DEFAULT 30,
max_file_size INTEGER NOT NULL DEFAULT 100,
-- Log Level
min_level TEXT NOT NULL DEFAULT 'INFO' CHECK (min_level IN ('DEBUG', 'INFO', 'WARN', 'ERROR')),
-- Enable/Disable
enabled INTEGER NOT NULL DEFAULT 1,
file_logging INTEGER NOT NULL DEFAULT 1,
console_logging INTEGER NOT NULL DEFAULT 1,
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Copy data back, adding default values for removed columns
INSERT INTO log_settings_new (
id,
rotation_strategy,
retention_days,
max_file_size,
min_level,
enabled,
file_logging,
console_logging,
created_at,
updated_at
)
SELECT
id,
'daily',
retention_days,
100,
min_level,
enabled,
file_logging,
console_logging,
created_at,
updated_at
FROM log_settings;
DROP TABLE log_settings;
ALTER TABLE log_settings_new RENAME TO log_settings;
`
};

View File

@@ -5,9 +5,7 @@ import { db } from '../db.ts';
*/
export interface LogSettings {
id: number;
rotation_strategy: 'daily' | 'size' | 'both';
retention_days: number;
max_file_size: number;
min_level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
enabled: number;
file_logging: number;
@@ -17,9 +15,7 @@ export interface LogSettings {
}
export interface UpdateLogSettingsInput {
rotationStrategy?: 'daily' | 'size' | 'both';
retentionDays?: number;
maxFileSize?: number;
minLevel?: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
enabled?: boolean;
fileLogging?: boolean;
@@ -45,18 +41,10 @@ export const logSettingsQueries = {
const updates: string[] = [];
const params: (string | number)[] = [];
if (input.rotationStrategy !== undefined) {
updates.push('rotation_strategy = ?');
params.push(input.rotationStrategy);
}
if (input.retentionDays !== undefined) {
updates.push('retention_days = ?');
params.push(input.retentionDays);
}
if (input.maxFileSize !== undefined) {
updates.push('max_file_size = ?');
params.push(input.maxFileSize);
}
if (input.minLevel !== undefined) {
updates.push('min_level = ?');
params.push(input.minLevel);
@@ -96,9 +84,7 @@ export const logSettingsQueries = {
reset(): boolean {
const affected = db.execute(`
UPDATE log_settings SET
rotation_strategy = 'daily',
retention_days = 30,
max_file_size = 100,
min_level = 'INFO',
enabled = 1,
file_logging = 1,

View File

@@ -50,10 +50,8 @@ CREATE TABLE arr_instances (
CREATE TABLE log_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- Rotation & Retention
rotation_strategy TEXT NOT NULL DEFAULT 'daily' CHECK (rotation_strategy IN ('daily', 'size', 'both')),
-- Retention
retention_days INTEGER NOT NULL DEFAULT 30,
max_file_size INTEGER NOT NULL DEFAULT 100,
-- Log Level
min_level TEXT NOT NULL DEFAULT 'INFO' CHECK (min_level IN ('DEBUG', 'INFO', 'WARN', 'ERROR')),

View File

@@ -99,7 +99,9 @@ export const actions: Actions = {
try {
const backupsDir = config.paths.backups;
const filename = file.name.startsWith('backup-') ? file.name : `backup-uploaded-${Date.now()}.tar.gz`;
const filename = file.name.startsWith('backup-')
? file.name
: `backup-uploaded-${Date.now()}.tar.gz`;
const backupPath = `${backupsDir}/${filename}`;
// Check if file already exists
@@ -179,12 +181,7 @@ export const actions: Actions = {
// Extract backup to base directory (will overwrite data directory)
const command = new Deno.Command('tar', {
args: [
'-xzf',
backupPath,
'-C',
config.paths.base
],
args: ['-xzf', backupPath, '-C', config.paths.base],
stdout: 'piped',
stderr: 'piped'
});
@@ -205,7 +202,10 @@ export const actions: Actions = {
meta: { filename }
});
return { success: true, message: 'Backup restored successfully. Please restart the application.' };
return {
success: true,
message: 'Backup restored successfully. Please restart the application.'
};
} catch (err) {
await logger.error(`Failed to restore backup: ${filename}`, {
source: 'settings/backups',

View File

@@ -311,10 +311,7 @@
</tr>
{:else}
<tr>
<td
colspan="4"
class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400"
>
<td colspan="4" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No backups found. Create your first backup to get started.
</td>
</tr>

View File

@@ -19,9 +19,7 @@ export const load = () => {
return {
logSettings: {
rotation_strategy: logSetting.rotation_strategy,
retention_days: logSetting.retention_days,
max_file_size: logSetting.max_file_size,
min_level: logSetting.min_level,
enabled: logSetting.enabled === 1,
file_logging: logSetting.file_logging === 1,
@@ -42,36 +40,24 @@ export const actions: Actions = {
const formData = await request.formData();
// Parse form data
const rotationStrategy = formData.get('rotation_strategy') as 'daily' | 'size' | 'both';
const retentionDays = parseInt(formData.get('retention_days') as string);
const maxFileSize = parseInt(formData.get('max_file_size') as string);
const minLevel = formData.get('min_level') as 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
const enabled = formData.get('enabled') === 'on';
const fileLogging = formData.get('file_logging') === 'on';
const consoleLogging = formData.get('console_logging') === 'on';
// Validate
if (!rotationStrategy || !['daily', 'size', 'both'].includes(rotationStrategy)) {
return fail(400, { error: 'Invalid rotation strategy' });
}
if (isNaN(retentionDays) || retentionDays < 1 || retentionDays > 365) {
return fail(400, { error: 'Retention days must be between 1 and 365' });
}
if (isNaN(maxFileSize) || maxFileSize < 1 || maxFileSize > 1000) {
return fail(400, { error: 'Max file size must be between 1 and 1000 MB' });
}
if (!minLevel || !['DEBUG', 'INFO', 'WARN', 'ERROR'].includes(minLevel)) {
return fail(400, { error: 'Invalid minimum log level' });
}
// Update settings
const updated = logSettingsQueries.update({
rotationStrategy,
retentionDays,
maxFileSize,
minLevel,
enabled,
fileLogging,
@@ -91,9 +77,7 @@ export const actions: Actions = {
await logger.info('Log settings updated', {
source: 'settings/general',
meta: {
rotationStrategy,
retentionDays,
maxFileSize,
minLevel,
enabled,
fileLogging,

View File

@@ -40,11 +40,9 @@
<div class="space-y-6">
<!-- Enable Backups -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">
Enable Features
</h3>
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">Enable Features</h3>
<div class="space-y-2">
<label class="flex items-center gap-3 cursor-pointer">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
name="enabled"
@@ -61,7 +59,7 @@
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
name="compression_enabled"
@@ -82,9 +80,7 @@
<!-- Schedule Configuration -->
<div class="space-y-3">
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">
Backup Schedule
</h3>
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">Backup Schedule</h3>
<div class="space-y-2">
<label class="block">
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-50">
@@ -93,7 +89,7 @@
<select
name="schedule"
bind:value={settings.schedule}
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>

View File

@@ -9,9 +9,7 @@
// Default values
const DEFAULTS = {
rotation_strategy: 'daily',
retention_days: 30,
max_file_size: 100,
min_level: 'INFO',
enabled: true,
file_logging: true,
@@ -20,9 +18,7 @@
// Reset to defaults (client-side only)
function resetToDefaults() {
settings.rotation_strategy = DEFAULTS.rotation_strategy as 'daily' | 'size' | 'both';
settings.retention_days = DEFAULTS.retention_days;
settings.max_file_size = DEFAULTS.max_file_size;
settings.min_level = DEFAULTS.min_level as 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
settings.enabled = DEFAULTS.enabled;
settings.file_logging = DEFAULTS.file_logging;
@@ -65,7 +61,7 @@
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">Enable Features</h3>
<div class="space-y-2">
<!-- Enable Logging -->
<label class="flex items-center gap-3 cursor-pointer">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
name="enabled"
@@ -83,7 +79,7 @@
</label>
<!-- File Logging -->
<label class="flex items-center gap-3 cursor-pointer">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
name="file_logging"
@@ -99,7 +95,7 @@
</label>
<!-- Console Logging -->
<label class="flex items-center gap-3 cursor-pointer">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
name="console_logging"
@@ -110,9 +106,7 @@
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-50">
Console Logging
</span>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Output logs to terminal
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400">Output logs to terminal</p>
</div>
</label>
</div>
@@ -125,7 +119,7 @@
<div>
<label
for="min_level"
class="block text-sm font-semibold text-neutral-900 dark:text-neutral-50 mb-2"
class="mb-2 block text-sm font-semibold text-neutral-900 dark:text-neutral-50"
>
Minimum Log Level
</label>
@@ -149,77 +143,30 @@
<!-- Divider -->
<div class="border-t border-neutral-200 dark:border-neutral-800"></div>
<!-- Rotation & Retention -->
<!-- Retention -->
<div>
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50 mb-3">
Rotation & Retention
<h3 class="mb-3 text-sm font-semibold text-neutral-900 dark:text-neutral-50">
Retention Policy
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Rotation Strategy -->
<div>
<label
for="rotation_strategy"
class="block text-sm font-medium text-neutral-900 dark:text-neutral-50 mb-1"
>
Rotation Strategy
</label>
<select
id="rotation_strategy"
name="rotation_strategy"
bind:value={settings.rotation_strategy}
required
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
>
<option value="daily">Daily</option>
<option value="size">Size</option>
<option value="both">Both</option>
</select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
How to rotate log files
</p>
</div>
<!-- Retention Days -->
<div>
<label
for="retention_days"
class="block text-sm font-medium text-neutral-900 dark:text-neutral-50 mb-1"
>
Retention (days)
</label>
<NumberInput
name="retention_days"
id="retention_days"
bind:value={settings.retention_days}
min={1}
max={365}
required
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Keep logs for 1-365 days
</p>
</div>
<!-- Max File Size -->
<div class="md:col-span-2">
<label
for="max_file_size"
class="block text-sm font-medium text-neutral-900 dark:text-neutral-50 mb-1"
>
Max File Size (MB)
</label>
<NumberInput
name="max_file_size"
id="max_file_size"
bind:value={settings.max_file_size}
min={1}
max={1000}
required
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Maximum size before rotation (1-1000 MB)
</p>
</div>
<div>
<label
for="retention_days"
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
>
Retention (days)
</label>
<NumberInput
name="retention_days"
id="retention_days"
bind:value={settings.retention_days}
min={1}
max={365}
required
/>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Keep daily log files for 1-365 days. Logs are automatically rotated daily
(YYYY-MM-DD.log format).
</p>
</div>
</div>
</div>
@@ -231,7 +178,7 @@
<button
type="button"
on:click={resetToDefaults}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<RotateCcw size={16} />
Reset to Defaults
@@ -239,7 +186,7 @@
<button
type="submit"
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600"
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Save size={16} />
Save Settings

View File

@@ -3,9 +3,7 @@
*/
export interface LogSettings {
rotation_strategy: 'daily' | 'size' | 'both';
retention_days: number;
max_file_size: number;
min_level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
enabled: boolean;
file_logging: boolean;

View File

@@ -7,10 +7,10 @@ import { logger } from '$logger';
// Helper to format schedule for display
function formatSchedule(schedule: string): string {
const scheduleMap: Record<string, string> = {
'daily': 'Daily',
'hourly': 'Hourly',
'weekly': 'Weekly',
'monthly': 'Monthly'
daily: 'Daily',
hourly: 'Hourly',
weekly: 'Weekly',
monthly: 'Monthly'
};
// Check for cron-like patterns

View File

@@ -77,7 +77,10 @@
<!-- Schedule -->
<div>
<label for="schedule" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
<label
for="schedule"
class="block text-sm font-medium text-neutral-900 dark:text-neutral-50"
>
Schedule <span class="text-red-500">*</span>
</label>
<input
@@ -91,11 +94,25 @@
/>
<div class="mt-2 space-y-1">
<p class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Examples:</p>
<ul class="list-inside list-disc space-y-1 text-sm text-neutral-600 dark:text-neutral-400">
<li><code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">daily</code> - Runs once per day</li>
<li><code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">hourly</code> - Runs every hour</li>
<li><code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">*/5 minutes</code> - Runs every 5 minutes</li>
<li><code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">weekly</code> - Runs once per week</li>
<ul
class="list-inside list-disc space-y-1 text-sm text-neutral-600 dark:text-neutral-400"
>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">daily</code> - Runs
once per day
</li>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">hourly</code> - Runs
every hour
</li>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">*/5 minutes</code
> - Runs every 5 minutes
</li>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">weekly</code> - Runs
once per week
</li>
</ul>
</div>
</div>
@@ -104,14 +121,14 @@
<div class="flex gap-3 border-t border-neutral-200 pt-6 dark:border-neutral-800">
<button
type="submit"
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600"
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Save size={16} />
Save Changes
</button>
<a
href="/settings/jobs"
class="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
class="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
Cancel
</a>

View File

@@ -201,14 +201,14 @@
</code>
{#if job.last_run_status === 'success'}
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-100"
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/40 dark:text-green-100"
>
<CheckCircle size={10} />
Success
</span>
{:else if job.last_run_status === 'failure'}
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-100"
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-100"
>
<XCircle size={10} />
Failed

View File

@@ -43,7 +43,9 @@
}
</script>
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-center gap-2">
@@ -98,14 +100,14 @@
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
{#if run.status === 'success'}
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-100"
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/40 dark:text-green-100"
>
<CheckCircle size={10} />
Success
</span>
{:else}
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-100"
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-100"
>
<XCircle size={10} />
Failed
@@ -157,10 +159,7 @@
</tr>
{:else}
<tr>
<td
colspan="5"
class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400"
>
<td colspan="5" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No job runs yet
</td>
</tr>

View File

@@ -8,9 +8,7 @@ export const load = async ({ url }: { url: URL }) => {
const selectedFile = url.searchParams.get('file') || logFiles[0]?.filename || '';
// Load logs from selected file or all files if no file selected
const logs = selectedFile
? await readLogsFromFile(selectedFile, 1000)
: await readLastLogs(1000);
const logs = selectedFile ? await readLogsFromFile(selectedFile, 1000) : await readLastLogs(1000);
return {
logs,

View File

@@ -118,14 +118,17 @@
<div class="mb-6 flex flex-wrap items-center gap-4">
<!-- Log File Selector -->
<div class="flex items-center gap-2">
<label for="log-file-select" class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
<label
for="log-file-select"
class="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Log File:
</label>
<select
id="log-file-select"
value={data.selectedFile}
on:change={(e) => changeLogFile(e.currentTarget.value)}
class="min-w-64 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
class="min-w-64 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
>
{#each data.logFiles as file (file.filename)}
<option value={file.filename}>
@@ -153,16 +156,13 @@
<!-- Search Input -->
<div class="flex flex-1 items-center gap-2">
<div class="relative flex-1 max-w-md">
<Search
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
/>
<div class="relative max-w-md flex-1">
<Search size={18} class="absolute top-1/2 left-3 -translate-y-1/2 text-neutral-400" />
<input
type="text"
bind:value={searchQuery}
placeholder="Search logs..."
class="w-full rounded-lg border border-neutral-300 bg-white py-1.5 pl-10 pr-3 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
class="w-full rounded-lg border border-neutral-300 bg-white py-1.5 pr-3 pl-10 text-sm text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
</div>
</div>
@@ -256,9 +256,7 @@
>
{log.message}
</td>
<td
class="border-b border-neutral-200 px-4 py-2 text-center dark:border-neutral-800"
>
<td class="border-b border-neutral-200 px-4 py-2 text-center dark:border-neutral-800">
<div class="flex items-center justify-center gap-1">
<button
type="button"
@@ -305,5 +303,9 @@
>
<pre
slot="body"
class="max-h-[400px] overflow-auto whitespace-pre-wrap rounded-lg bg-neutral-50 p-4 font-mono text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50">{JSON.stringify(selectedMeta, null, 2)}</pre>
class="max-h-[400px] overflow-auto rounded-lg bg-neutral-50 p-4 font-mono text-xs whitespace-pre-wrap text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50">{JSON.stringify(
selectedMeta,
null,
2
)}</pre>
</Modal>

View File

@@ -5,7 +5,7 @@ import type { JobDefinition, JobResult } from '../types.ts';
/**
* Cleanup old log files job
* Deletes log files older than the configured retention period
* Deletes daily log files (YYYY-MM-DD.log) older than the configured retention period
*/
export const cleanupLogsJob: JobDefinition = {
name: 'cleanup_logs',
@@ -26,40 +26,43 @@ export const cleanupLogsJob: JobDefinition = {
const retentionDays = settings.retention_days;
const logsDir = config.paths.logs;
// Calculate cutoff date
// Calculate cutoff date (YYYY-MM-DD format)
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0]; // YYYY-MM-DD
await logger.info(`Cleaning up logs older than ${retentionDays} days`, {
source: 'CleanupLogsJob',
meta: { cutoffDate: cutoffDate.toISOString() }
meta: { cutoffDate: cutoffDateStr }
});
// Read all files in logs directory
let deletedCount = 0;
let errorCount = 0;
// Regex to match daily log files: YYYY-MM-DD.log
const dateLogPattern = /^(\d{4}-\d{2}-\d{2})\.log$/;
try {
for await (const entry of Deno.readDir(logsDir)) {
if (!entry.isFile) continue;
// Only process log files (*.log, app-*.log, etc.)
if (!entry.name.endsWith('.log')) continue;
// Only process log files matching YYYY-MM-DD.log pattern
const match = entry.name.match(dateLogPattern);
if (!match) continue;
const logDate = match[1]; // Extract YYYY-MM-DD from filename
const filePath = `${logsDir}/${entry.name}`;
try {
// Get file stats
const stat = await Deno.stat(filePath);
// Check if file is older than cutoff
if (stat.mtime && stat.mtime < cutoffDate) {
// Compare date strings directly (YYYY-MM-DD format sorts correctly)
if (logDate < cutoffDateStr) {
await Deno.remove(filePath);
deletedCount++;
await logger.info(`Deleted old log file: ${entry.name}`, {
source: 'CleanupLogsJob',
meta: { file: entry.name, modifiedAt: stat.mtime.toISOString() }
meta: { file: entry.name, logDate }
});
}
} catch (error) {
@@ -73,7 +76,9 @@ export const cleanupLogsJob: JobDefinition = {
} catch (error) {
return {
success: false,
error: `Failed to read logs directory: ${error instanceof Error ? error.message : String(error)}`
error: `Failed to read logs directory: ${
error instanceof Error ? error.message : String(error)
}`
};
}

View File

@@ -39,13 +39,7 @@ export const createBackupJob: JobDefinition = {
// Create tar.gz archive of data directory
const command = new Deno.Command('tar', {
args: [
'-czf',
backupPath,
'-C',
config.paths.base,
'data'
],
args: ['-czf', backupPath, '-C', config.paths.base, 'data'],
stdout: 'piped',
stderr: 'piped'
});

View File

@@ -1,12 +1,12 @@
/**
* Logger singleton with console and file output
* Supports configurable settings, daily rotation, and size-based rotation
* Supports configurable settings and daily rotation
*/
import { config } from '$config';
import { colors } from './colors.ts';
import { logSettings } from './settings.ts';
import type { LogOptions, LogEntry } from './types.ts';
import type { LogEntry, LogOptions } from './types.ts';
class Logger {
private formatTimestamp(): string {
@@ -29,49 +29,11 @@ class Logger {
}
/**
* Get log file path based on rotation strategy
* Get log file path with daily rotation (YYYY-MM-DD.log)
*/
private getLogFilePath(): string {
const settings = logSettings.get();
const strategy = settings.rotation_strategy;
if (strategy === 'daily' || strategy === 'both') {
// Use date-based filename: app-2025-01-20.log
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
return `${config.paths.logs}/app-${date}.log`;
}
// Default: use app.log
return config.paths.logFile;
}
/**
* Check if log file needs rotation due to size
*/
private async checkSizeRotation(filePath: string): Promise<void> {
const settings = logSettings.get();
const strategy = settings.rotation_strategy;
if (strategy !== 'size' && strategy !== 'both') {
return;
}
try {
const stat = await Deno.stat(filePath);
const maxSizeBytes = settings.max_file_size * 1024 * 1024; // Convert MB to bytes
if (stat.size >= maxSizeBytes) {
// Rotate: rename current file with timestamp
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
const rotatedPath = filePath.replace('.log', `-${timestamp}.log`);
await Deno.rename(filePath, rotatedPath);
}
} catch (error) {
// File might not exist yet, that's okay
if (!(error instanceof Deno.errors.NotFound)) {
console.error('Error checking file size for rotation:', error);
}
}
const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
return `${config.paths.logs}/${date}.log`;
}
private async log(
@@ -113,9 +75,6 @@ class Logger {
try {
const filePath = this.getLogFilePath();
// Check if file needs rotation due to size
await this.checkSizeRotation(filePath);
// Write to log file
await Deno.writeTextFile(filePath, JSON.stringify(logEntry) + '\n', {
append: true

View File

@@ -41,9 +41,7 @@ class LogSettingsManager {
// Return defaults if not initialized
return {
id: 1,
rotation_strategy: 'daily',
retention_days: 30,
max_file_size: 100,
min_level: 'INFO',
enabled: 1,
file_logging: 1,