mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 21:29:06 +00:00
Initial commit
This commit is contained in:
121
lib/stores/audit-events.ts
Normal file
121
lib/stores/audit-events.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
username: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
entity_name: string | null;
|
||||
environment_id: number | null;
|
||||
description: string | null;
|
||||
details: any | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type AuditEventCallback = (event: AuditLogEntry) => void;
|
||||
|
||||
// Connection state
|
||||
export const auditSseConnected = writable<boolean>(false);
|
||||
export const auditSseError = writable<string | null>(null);
|
||||
export const lastAuditEvent = writable<AuditLogEntry | null>(null);
|
||||
|
||||
// Event listeners
|
||||
const listeners: Set<AuditEventCallback> = new Set();
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const RECONNECT_DELAY = 3000;
|
||||
|
||||
// Subscribe to audit events
|
||||
export function onAuditEvent(callback: AuditEventCallback): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
// Notify all listeners
|
||||
function notifyListeners(event: AuditLogEntry) {
|
||||
lastAuditEvent.set(event);
|
||||
listeners.forEach(callback => {
|
||||
try {
|
||||
callback(event);
|
||||
} catch (e) {
|
||||
console.error('Audit event listener error:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Connect to SSE endpoint
|
||||
export function connectAuditSSE() {
|
||||
// Close existing connection
|
||||
disconnectAuditSSE();
|
||||
|
||||
try {
|
||||
eventSource = new EventSource('/api/audit/events');
|
||||
|
||||
eventSource.addEventListener('connected', (e) => {
|
||||
console.log('Audit SSE connected');
|
||||
auditSseConnected.set(true);
|
||||
auditSseError.set(null);
|
||||
reconnectAttempts = 0;
|
||||
});
|
||||
|
||||
eventSource.addEventListener('audit', (e) => {
|
||||
try {
|
||||
const event: AuditLogEntry = JSON.parse(e.data);
|
||||
notifyListeners(event);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse audit event:', err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Connection is alive
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (e) => {
|
||||
console.error('Audit SSE error:', e);
|
||||
auditSseConnected.set(false);
|
||||
|
||||
// Attempt reconnection
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
auditSseError.set(`Connection lost. Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
connectAuditSSE();
|
||||
}, RECONNECT_DELAY);
|
||||
} else {
|
||||
auditSseError.set('Connection failed. Refresh the page to retry.');
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// Handled by error event listener
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create Audit EventSource:', error);
|
||||
auditSseError.set(error.message || 'Failed to connect');
|
||||
auditSseConnected.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from SSE
|
||||
export function disconnectAuditSSE() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
auditSseConnected.set(false);
|
||||
auditSseError.set(null);
|
||||
reconnectAttempts = 0;
|
||||
}
|
||||
209
lib/stores/auth.ts
Normal file
209
lib/stores/auth.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
export interface Permissions {
|
||||
containers: string[];
|
||||
images: string[];
|
||||
volumes: string[];
|
||||
networks: string[];
|
||||
stacks: string[];
|
||||
environments: string[];
|
||||
registries: string[];
|
||||
notifications: string[];
|
||||
configsets: string[];
|
||||
settings: string[];
|
||||
users: string[];
|
||||
git: string[];
|
||||
license: string[];
|
||||
audit_logs: string[];
|
||||
activity: string[];
|
||||
schedules: string[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
isAdmin: boolean;
|
||||
provider: 'local' | 'ldap' | 'oidc';
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
authEnabled: boolean;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
authEnabled: false,
|
||||
authenticated: false
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Check current session status
|
||||
* Should be called on app init
|
||||
*/
|
||||
async check() {
|
||||
update(state => ({ ...state, loading: true }));
|
||||
try {
|
||||
const response = await fetch('/api/auth/session');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
set({
|
||||
user: null,
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
authenticated: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
user: data.user || null,
|
||||
loading: false,
|
||||
authEnabled: data.authEnabled,
|
||||
authenticated: data.authenticated
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
user: null,
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
authenticated: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
async login(username: string, password: string, mfaToken?: string, provider: string = 'local'): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
requiresMfa?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, mfaToken, provider })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: data.error || 'Login failed' };
|
||||
}
|
||||
|
||||
if (data.requiresMfa) {
|
||||
return { success: true, requiresMfa: true };
|
||||
}
|
||||
|
||||
if (data.success && data.user) {
|
||||
// Refresh session to get full user with permissions
|
||||
await this.check();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Login failed' };
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout and clear session
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
set({
|
||||
user: null,
|
||||
loading: false,
|
||||
authEnabled: true, // Keep authEnabled as we know it was on
|
||||
authenticated: false
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
* When auth is disabled, returns true (full access)
|
||||
*/
|
||||
hasPermission(user: AuthUser | null, authEnabled: boolean, resource: keyof Permissions, action: string): boolean {
|
||||
// If auth is disabled, everything is allowed
|
||||
if (!authEnabled) return true;
|
||||
|
||||
// If no user and auth is enabled, deny
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can do anything
|
||||
if (user.isAdmin) return true;
|
||||
|
||||
// Check specific permission
|
||||
const permissions = user.permissions[resource];
|
||||
return permissions?.includes(action) ?? false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const authStore = createAuthStore();
|
||||
|
||||
// Derived store for easy permission checking
|
||||
export const canAccess = derived(authStore, ($auth) => {
|
||||
return (resource: keyof Permissions, action: string): boolean => {
|
||||
// If auth is disabled, everything is allowed
|
||||
if (!$auth.authEnabled) return true;
|
||||
|
||||
// If not authenticated and auth is enabled, deny
|
||||
if (!$auth.authenticated || !$auth.user) return false;
|
||||
|
||||
// Admins can do anything
|
||||
if ($auth.user.isAdmin) return true;
|
||||
|
||||
// Check specific permission
|
||||
const permissions = $auth.user.permissions?.[resource];
|
||||
return permissions?.includes(action) ?? false;
|
||||
};
|
||||
});
|
||||
|
||||
// Derived store to check if user has ANY permission for a resource
|
||||
// Used for menu visibility - show menu if user has any access to that resource
|
||||
export const hasAnyAccess = derived(authStore, ($auth) => {
|
||||
return (resource: keyof Permissions): boolean => {
|
||||
// If auth is disabled, everything is allowed
|
||||
if (!$auth.authEnabled) return true;
|
||||
|
||||
// If not authenticated and auth is enabled, deny
|
||||
if (!$auth.authenticated || !$auth.user) return false;
|
||||
|
||||
// Admins can do anything
|
||||
if ($auth.user.isAdmin) return true;
|
||||
|
||||
// Check if user has ANY permission for this resource
|
||||
const permissions = $auth.user.permissions?.[resource];
|
||||
return permissions && permissions.length > 0;
|
||||
};
|
||||
});
|
||||
|
||||
// Derived store for whether auth is required for the current session
|
||||
export const requiresAuth = derived(authStore, ($auth) => {
|
||||
return $auth.authEnabled && !$auth.authenticated;
|
||||
});
|
||||
|
||||
// Derived store for admin check - true if auth disabled OR user is admin
|
||||
export const isAdmin = derived(authStore, ($auth) => {
|
||||
if (!$auth.authEnabled) return true;
|
||||
return $auth.user?.isAdmin ?? false;
|
||||
});
|
||||
209
lib/stores/dashboard.ts
Normal file
209
lib/stores/dashboard.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import type { EnvironmentStats } from '../../routes/api/dashboard/stats/+server';
|
||||
|
||||
// Grid item layout format for svelte-grid
|
||||
export interface GridItem {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
[key: string]: unknown; // Allow svelte-grid internal properties
|
||||
}
|
||||
|
||||
export interface DashboardPreferences {
|
||||
gridLayout: GridItem[];
|
||||
}
|
||||
|
||||
const defaultPreferences: DashboardPreferences = {
|
||||
gridLayout: []
|
||||
};
|
||||
|
||||
// Environment info from API
|
||||
interface EnvironmentInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
host?: string;
|
||||
icon: string;
|
||||
socketPath?: string;
|
||||
connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
||||
}
|
||||
|
||||
// Metrics history point for charts
|
||||
export interface MetricsHistoryPoint {
|
||||
cpu_percent: number;
|
||||
memory_percent: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Tile item combining environment info and stats
|
||||
export interface TileItem {
|
||||
id: number;
|
||||
stats: EnvironmentStats | null;
|
||||
info: EnvironmentInfo | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
// Dashboard data store for caching between navigations
|
||||
export interface DashboardData {
|
||||
tiles: TileItem[];
|
||||
gridItems: GridItem[];
|
||||
lastFetchTime: number | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
const defaultDashboardData: DashboardData = {
|
||||
tiles: [],
|
||||
gridItems: [],
|
||||
lastFetchTime: null,
|
||||
initialized: false
|
||||
};
|
||||
|
||||
function createDashboardDataStore() {
|
||||
const { subscribe, set, update } = writable<DashboardData>(defaultDashboardData);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setTiles: (tiles: TileItem[]) => {
|
||||
update(data => ({ ...data, tiles, lastFetchTime: Date.now() }));
|
||||
},
|
||||
updateTile: (id: number, updates: Partial<TileItem>) => {
|
||||
update(data => ({
|
||||
...data,
|
||||
tiles: data.tiles.map(t => t.id === id ? { ...t, ...updates } : t),
|
||||
lastFetchTime: Date.now()
|
||||
}));
|
||||
},
|
||||
// Partial update for progressive loading - merges into existing stats
|
||||
updateTilePartial: (id: number, partialStats: Partial<EnvironmentStats>) => {
|
||||
update(data => ({
|
||||
...data,
|
||||
tiles: data.tiles.map(t => {
|
||||
if (t.id === id && t.stats) {
|
||||
return {
|
||||
...t,
|
||||
stats: {
|
||||
...t.stats,
|
||||
...partialStats
|
||||
}
|
||||
};
|
||||
}
|
||||
return t;
|
||||
}),
|
||||
lastFetchTime: Date.now()
|
||||
}));
|
||||
},
|
||||
setGridItems: (gridItems: GridItem[]) => {
|
||||
update(data => ({ ...data, gridItems }));
|
||||
},
|
||||
setInitialized: (initialized: boolean) => {
|
||||
update(data => ({ ...data, initialized }));
|
||||
},
|
||||
markAllLoading: () => {
|
||||
update(data => ({
|
||||
...data,
|
||||
tiles: data.tiles.map(t => ({ ...t, loading: true }))
|
||||
}));
|
||||
},
|
||||
// Invalidate cache to force a fresh fetch on next dashboard visit
|
||||
// Clear tiles so dashboard starts fresh with new data
|
||||
invalidate: () => {
|
||||
update(data => ({
|
||||
...data,
|
||||
tiles: [],
|
||||
lastFetchTime: null,
|
||||
initialized: false
|
||||
}));
|
||||
},
|
||||
reset: () => set(defaultDashboardData),
|
||||
getData: () => get({ subscribe })
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardData = createDashboardDataStore();
|
||||
|
||||
// Number of columns in the grid
|
||||
export const GRID_COLS = 4;
|
||||
// Row height for tiles - compact tiles (h=1) show basic info, larger tiles show more
|
||||
// At height=2 (default), should fit: header, container counts, health, CPU/mem, resources, events
|
||||
export const GRID_ROW_HEIGHT = 175;
|
||||
|
||||
function createDashboardStore() {
|
||||
const { subscribe, set, update } = writable<DashboardPreferences>(defaultPreferences);
|
||||
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let initialized = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/preferences');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Handle migration from old format
|
||||
if (data.gridLayout && Array.isArray(data.gridLayout)) {
|
||||
set({ gridLayout: data.gridLayout });
|
||||
} else {
|
||||
set({ gridLayout: [] });
|
||||
}
|
||||
} else {
|
||||
set({ gridLayout: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard preferences:', error);
|
||||
set({ gridLayout: [] });
|
||||
} finally {
|
||||
// Always mark as initialized so saves can proceed
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(prefs: DashboardPreferences) {
|
||||
try {
|
||||
await fetch('/api/dashboard/preferences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(prefs)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save dashboard preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced save - auto-saves 500ms after last change
|
||||
function scheduleSave(prefs: DashboardPreferences) {
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
}
|
||||
saveTimeout = setTimeout(() => {
|
||||
save(prefs);
|
||||
saveTimeout = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
load,
|
||||
setGridLayout: (layout: GridItem[]) => {
|
||||
update(prefs => {
|
||||
// Only keep essential properties to avoid storing internal svelte-grid state
|
||||
const cleanLayout = layout.map(item => ({
|
||||
id: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h
|
||||
}));
|
||||
const newPrefs = { ...prefs, gridLayout: cleanLayout };
|
||||
if (initialized) {
|
||||
scheduleSave(newPrefs);
|
||||
}
|
||||
return newPrefs;
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
initialized = false;
|
||||
set(defaultPreferences);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardPreferences = createDashboardStore();
|
||||
163
lib/stores/environment.ts
Normal file
163
lib/stores/environment.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface CurrentEnvironment {
|
||||
id: number;
|
||||
name: string;
|
||||
highlightChanges?: boolean;
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
id: number;
|
||||
name: string;
|
||||
icon?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: string;
|
||||
socketPath?: string;
|
||||
connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
||||
publicIp?: string | null;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dockhand:environment';
|
||||
|
||||
// Load initial state from localStorage
|
||||
function getInitialEnvironment(): CurrentEnvironment | null {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a writable store for the current environment
|
||||
function createEnvironmentStore() {
|
||||
const { subscribe, set, update } = writable<CurrentEnvironment | null>(getInitialEnvironment());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (value: CurrentEnvironment | null) => {
|
||||
if (browser) {
|
||||
if (value) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
set(value);
|
||||
},
|
||||
update
|
||||
};
|
||||
}
|
||||
|
||||
export const currentEnvironment = createEnvironmentStore();
|
||||
|
||||
/**
|
||||
* Call this when an API returns 404 for the current environment.
|
||||
* Clears the stale environment from localStorage and store.
|
||||
*/
|
||||
export function clearStaleEnvironment(envId: number) {
|
||||
if (browser) {
|
||||
const current = get(currentEnvironment);
|
||||
// Use Number() for type-safe comparison
|
||||
if (current && Number(current.id) === Number(envId)) {
|
||||
console.warn(`Environment ${envId} no longer exists, clearing from localStorage`);
|
||||
currentEnvironment.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get the environment ID for API calls
|
||||
export function getEnvParam(envId: number | null | undefined): string {
|
||||
return envId ? `?env=${envId}` : '';
|
||||
}
|
||||
|
||||
// Helper to append env param to existing URL
|
||||
export function appendEnvParam(url: string, envId: number | null | undefined): string {
|
||||
if (!envId) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}env=${envId}`;
|
||||
}
|
||||
|
||||
// Store for environments list with auto-refresh capability
|
||||
function createEnvironmentsStore() {
|
||||
const { subscribe, set, update } = writable<Environment[]>([]);
|
||||
let loading = false;
|
||||
|
||||
async function fetchEnvironments() {
|
||||
if (!browser || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/environments');
|
||||
if (response.ok) {
|
||||
const data: Environment[] = await response.json();
|
||||
set(data);
|
||||
|
||||
// Auto-select environment if none selected or current one no longer exists
|
||||
const current = get(currentEnvironment);
|
||||
// Use Number() to handle any potential type mismatches from localStorage
|
||||
const currentId = current ? Number(current.id) : null;
|
||||
const currentExists = currentId !== null && data.some((e) => Number(e.id) === currentId);
|
||||
|
||||
console.log(`[EnvStore] refresh: current=${currentId}, exists=${currentExists}, envCount=${data.length}`);
|
||||
|
||||
if (data.length === 0) {
|
||||
// No environments left - clear selection
|
||||
console.log('[EnvStore] No environments, clearing selection');
|
||||
currentEnvironment.set(null);
|
||||
} else if (!current) {
|
||||
// No selection - select first
|
||||
console.log(`[EnvStore] No current env, selecting first: ${data[0].name}`);
|
||||
const firstEnv = data[0];
|
||||
currentEnvironment.set({
|
||||
id: firstEnv.id,
|
||||
name: firstEnv.name
|
||||
});
|
||||
} else if (!currentExists) {
|
||||
// Current env was deleted - select first
|
||||
console.warn(`[EnvStore] Environment ${currentId} no longer exists in list, selecting first: ${data[0].name}`);
|
||||
const firstEnv = data[0];
|
||||
currentEnvironment.set({
|
||||
id: firstEnv.id,
|
||||
name: firstEnv.name
|
||||
});
|
||||
} else {
|
||||
console.log(`[EnvStore] Current env ${currentId} still exists, keeping selection`);
|
||||
}
|
||||
} else {
|
||||
// Clear environments on permission denied or other errors
|
||||
set([]);
|
||||
// Also clear the current environment from localStorage
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
currentEnvironment.set(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch environments:', error);
|
||||
set([]);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
currentEnvironment.set(null);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fetch on browser load
|
||||
if (browser) {
|
||||
fetchEnvironments();
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
refresh: fetchEnvironments,
|
||||
set,
|
||||
update
|
||||
};
|
||||
}
|
||||
|
||||
export const environments = createEnvironmentsStore();
|
||||
221
lib/stores/events.ts
Normal file
221
lib/stores/events.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { currentEnvironment, environments } from './environment';
|
||||
|
||||
export interface DockerEvent {
|
||||
type: 'container' | 'image' | 'volume' | 'network';
|
||||
action: string;
|
||||
actor: {
|
||||
id: string;
|
||||
name: string;
|
||||
attributes: Record<string, string>;
|
||||
};
|
||||
time: number;
|
||||
timeNano: string;
|
||||
}
|
||||
|
||||
export type EventCallback = (event: DockerEvent) => void;
|
||||
|
||||
// Connection state
|
||||
export const sseConnected = writable<boolean>(false);
|
||||
export const sseError = writable<string | null>(null);
|
||||
export const lastEvent = writable<DockerEvent | null>(null);
|
||||
|
||||
// Event listeners
|
||||
const listeners: Set<EventCallback> = new Set();
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
let wantsConnection = false; // Track intent to be connected (even for edge envs without eventSource)
|
||||
let isEdgeMode = false; // Track if current env is edge (no SSE needed)
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const RECONNECT_DELAY = 3000;
|
||||
|
||||
// Check if environment is edge type (events come via Hawser WebSocket, not SSE)
|
||||
function isEdgeEnvironment(envId: number | null | undefined): boolean {
|
||||
if (!envId) return false;
|
||||
const envList = get(environments);
|
||||
const env = envList.find(e => e.id === envId);
|
||||
return env?.connectionType === 'hawser-edge';
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
export function onDockerEvent(callback: EventCallback): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
// Notify all listeners
|
||||
function notifyListeners(event: DockerEvent) {
|
||||
lastEvent.set(event);
|
||||
listeners.forEach(callback => {
|
||||
try {
|
||||
callback(event);
|
||||
} catch (e) {
|
||||
console.error('Event listener error:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Connect to SSE endpoint
|
||||
export function connectSSE(envId?: number | null) {
|
||||
// Close existing connection
|
||||
disconnectSSE();
|
||||
|
||||
// Mark that we want to be connected
|
||||
wantsConnection = true;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
// Don't connect if no environment is selected
|
||||
if (!envId) {
|
||||
sseConnected.set(false);
|
||||
sseError.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Edge environments receive events via Hawser agent WebSocket, not SSE
|
||||
if (isEdgeEnvironment(envId)) {
|
||||
isEdgeMode = true;
|
||||
// For edge environments, we're "connected" but via a different mechanism
|
||||
sseConnected.set(true);
|
||||
sseError.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
isEdgeMode = false;
|
||||
const url = `/api/events?env=${envId}`;
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.addEventListener('connected', (e) => {
|
||||
console.log('SSE connected:', JSON.parse(e.data));
|
||||
sseConnected.set(true);
|
||||
sseError.set(null);
|
||||
reconnectAttempts = 0;
|
||||
});
|
||||
|
||||
eventSource.addEventListener('docker', (e) => {
|
||||
try {
|
||||
const event: DockerEvent = JSON.parse(e.data);
|
||||
notifyListeners(event);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse docker event:', err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('heartbeat', () => {
|
||||
// Connection is alive
|
||||
});
|
||||
|
||||
// Handle SSE error events (both server-sent and connection errors)
|
||||
eventSource.addEventListener('error', (e: Event) => {
|
||||
// Check if this is a server-sent error message (MessageEvent with data)
|
||||
const messageEvent = e as MessageEvent;
|
||||
if (messageEvent.data) {
|
||||
try {
|
||||
const data = JSON.parse(messageEvent.data);
|
||||
// Check if this is the edge environment message (fallback if env list wasn't loaded)
|
||||
if (data.message?.includes('Edge environments')) {
|
||||
isEdgeMode = true;
|
||||
sseConnected.set(true);
|
||||
sseError.set(null);
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, fall through to generic error handling
|
||||
}
|
||||
}
|
||||
|
||||
// Skip reconnection if we're in edge mode
|
||||
if (isEdgeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('SSE error:', e);
|
||||
sseConnected.set(false);
|
||||
|
||||
// Attempt reconnection
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
sseError.set(`Connection lost. Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
const env = get(currentEnvironment);
|
||||
connectSSE(env?.id);
|
||||
}, RECONNECT_DELAY);
|
||||
} else {
|
||||
sseError.set('Connection failed. Refresh the page to retry.');
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// Handled by error event listener
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create EventSource:', error);
|
||||
sseError.set(error.message || 'Failed to connect');
|
||||
sseConnected.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from SSE
|
||||
export function disconnectSSE() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// Don't reset wantsConnection here - it's reset by explicit calls
|
||||
sseConnected.set(false);
|
||||
isEdgeMode = false;
|
||||
}
|
||||
|
||||
// Subscribe to environment changes and reconnect
|
||||
let currentEnvId: number | null = null;
|
||||
currentEnvironment.subscribe((env) => {
|
||||
const newEnvId = env?.id ?? null;
|
||||
if (newEnvId !== currentEnvId) {
|
||||
currentEnvId = newEnvId;
|
||||
// If no environment, disconnect
|
||||
if (!newEnvId) {
|
||||
disconnectSSE();
|
||||
wantsConnection = false;
|
||||
} else if (wantsConnection) {
|
||||
// Reconnect with new environment if we want to be connected
|
||||
// (using wantsConnection because eventSource is null for edge envs)
|
||||
connectSSE(newEnvId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to check if action affects container list
|
||||
export function isContainerListChange(event: DockerEvent): boolean {
|
||||
if (event.type !== 'container') return false;
|
||||
return ['create', 'destroy', 'start', 'stop', 'pause', 'unpause', 'die', 'kill', 'rename'].includes(event.action);
|
||||
}
|
||||
|
||||
// Helper to check if action affects image list
|
||||
export function isImageListChange(event: DockerEvent): boolean {
|
||||
if (event.type !== 'image') return false;
|
||||
return ['pull', 'push', 'delete', 'tag', 'untag', 'import'].includes(event.action);
|
||||
}
|
||||
|
||||
// Helper to check if action affects volume list
|
||||
export function isVolumeListChange(event: DockerEvent): boolean {
|
||||
if (event.type !== 'volume') return false;
|
||||
return ['create', 'destroy'].includes(event.action);
|
||||
}
|
||||
|
||||
// Helper to check if action affects network list
|
||||
export function isNetworkListChange(event: DockerEvent): boolean {
|
||||
if (event.type !== 'network') return false;
|
||||
return ['create', 'destroy', 'connect', 'disconnect'].includes(event.action);
|
||||
}
|
||||
226
lib/stores/grid-preferences.ts
Normal file
226
lib/stores/grid-preferences.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Grid Preferences Store for Dockhand
|
||||
*
|
||||
* Manages column visibility and ordering preferences with:
|
||||
* - localStorage sync for flash-free loading
|
||||
* - Database persistence via API
|
||||
* - Per-grid configuration
|
||||
*/
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import type { AllGridPreferences, GridId, ColumnPreference, GridColumnPreferences } from '$lib/types';
|
||||
import { getDefaultColumnPreferences, getConfigurableColumns } from '$lib/config/grid-columns';
|
||||
|
||||
const STORAGE_KEY = 'dockhand-grid-preferences';
|
||||
|
||||
// Load initial state from localStorage
|
||||
function loadFromStorage(): AllGridPreferences {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
function saveToStorage(prefs: AllGridPreferences) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Create the store
|
||||
function createGridPreferencesStore() {
|
||||
const { subscribe, set, update } = writable<AllGridPreferences>(loadFromStorage());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize from API (called on mount)
|
||||
async init() {
|
||||
try {
|
||||
const res = await fetch('/api/preferences/grid');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const prefs = data.preferences || {};
|
||||
set(prefs);
|
||||
saveToStorage(prefs);
|
||||
}
|
||||
} catch {
|
||||
// Use localStorage fallback
|
||||
}
|
||||
},
|
||||
|
||||
// Get visible columns for a grid (in order)
|
||||
getVisibleColumns(gridId: GridId): ColumnPreference[] {
|
||||
const prefs = get({ subscribe });
|
||||
const gridPrefs = prefs[gridId];
|
||||
|
||||
if (!gridPrefs?.columns?.length) {
|
||||
// Return defaults (all visible)
|
||||
return getDefaultColumnPreferences(gridId);
|
||||
}
|
||||
|
||||
// Return columns in saved order, filtering to visible ones
|
||||
return gridPrefs.columns.filter((col) => col.visible);
|
||||
},
|
||||
|
||||
// Get all columns for a grid (visible and hidden, in order)
|
||||
getAllColumns(gridId: GridId): ColumnPreference[] {
|
||||
const prefs = get({ subscribe });
|
||||
const gridPrefs = prefs[gridId];
|
||||
|
||||
if (!gridPrefs?.columns?.length) {
|
||||
// Return defaults (all visible)
|
||||
return getDefaultColumnPreferences(gridId);
|
||||
}
|
||||
|
||||
// Merge with defaults to ensure new columns are included
|
||||
const defaults = getDefaultColumnPreferences(gridId);
|
||||
const savedIds = new Set(gridPrefs.columns.map((c) => c.id));
|
||||
|
||||
// Start with saved columns, then add any new defaults
|
||||
const result = [...gridPrefs.columns];
|
||||
for (const def of defaults) {
|
||||
if (!savedIds.has(def.id)) {
|
||||
result.push(def);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
// Check if a specific column is visible
|
||||
isColumnVisible(gridId: GridId, columnId: string): boolean {
|
||||
const prefs = get({ subscribe });
|
||||
const gridPrefs = prefs[gridId];
|
||||
|
||||
if (!gridPrefs?.columns?.length) {
|
||||
// Defaults to visible
|
||||
return true;
|
||||
}
|
||||
|
||||
const col = gridPrefs.columns.find((c) => c.id === columnId);
|
||||
return col ? col.visible : true;
|
||||
},
|
||||
|
||||
// Update column visibility/order for a grid
|
||||
async setColumns(gridId: GridId, columns: ColumnPreference[]) {
|
||||
update((prefs) => {
|
||||
const newPrefs = {
|
||||
...prefs,
|
||||
[gridId]: { columns }
|
||||
};
|
||||
saveToStorage(newPrefs);
|
||||
return newPrefs;
|
||||
});
|
||||
|
||||
// Save to database (async, non-blocking)
|
||||
try {
|
||||
await fetch('/api/preferences/grid', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gridId, columns })
|
||||
});
|
||||
} catch {
|
||||
// Silently fail - localStorage has the value
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle a column's visibility
|
||||
async toggleColumn(gridId: GridId, columnId: string) {
|
||||
const allCols = this.getAllColumns(gridId);
|
||||
const newColumns = allCols.map((col) =>
|
||||
col.id === columnId ? { ...col, visible: !col.visible } : col
|
||||
);
|
||||
await this.setColumns(gridId, newColumns);
|
||||
},
|
||||
|
||||
// Reset a grid to default columns
|
||||
async resetGrid(gridId: GridId) {
|
||||
const defaults = getDefaultColumnPreferences(gridId);
|
||||
|
||||
update((prefs) => {
|
||||
const newPrefs = { ...prefs };
|
||||
delete newPrefs[gridId];
|
||||
saveToStorage(newPrefs);
|
||||
return newPrefs;
|
||||
});
|
||||
|
||||
// Delete from database
|
||||
try {
|
||||
await fetch(`/api/preferences/grid?gridId=${gridId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
// Get ordered column IDs for rendering
|
||||
getColumnOrder(gridId: GridId): string[] {
|
||||
const allCols = this.getAllColumns(gridId);
|
||||
return allCols.filter((c) => c.visible).map((c) => c.id);
|
||||
},
|
||||
|
||||
// Get saved width for a specific column
|
||||
getColumnWidth(gridId: GridId, columnId: string): number | undefined {
|
||||
const prefs = get({ subscribe });
|
||||
const gridPrefs = prefs[gridId];
|
||||
if (!gridPrefs?.columns?.length) return undefined;
|
||||
const col = gridPrefs.columns.find((c) => c.id === columnId);
|
||||
return col?.width;
|
||||
},
|
||||
|
||||
// Get all saved widths as a Map
|
||||
getColumnWidths(gridId: GridId): Map<string, number> {
|
||||
const prefs = get({ subscribe });
|
||||
const gridPrefs = prefs[gridId];
|
||||
const widths = new Map<string, number>();
|
||||
if (gridPrefs?.columns) {
|
||||
for (const col of gridPrefs.columns) {
|
||||
if (col.width !== undefined) {
|
||||
widths.set(col.id, col.width);
|
||||
}
|
||||
}
|
||||
}
|
||||
return widths;
|
||||
},
|
||||
|
||||
// Set width for a specific column (works for both configurable and fixed columns)
|
||||
async setColumnWidth(gridId: GridId, columnId: string, width: number) {
|
||||
const allCols = this.getAllColumns(gridId);
|
||||
let found = false;
|
||||
const newColumns = allCols.map((col) => {
|
||||
if (col.id === columnId) {
|
||||
found = true;
|
||||
return { ...col, width };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
// If column wasn't found (e.g., fixed column), add it
|
||||
if (!found) {
|
||||
newColumns.push({ id: columnId, visible: true, width });
|
||||
}
|
||||
|
||||
await this.setColumns(gridId, newColumns);
|
||||
},
|
||||
|
||||
// Get current preferences
|
||||
get(): AllGridPreferences {
|
||||
return get({ subscribe });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const gridPreferencesStore = createGridPreferencesStore();
|
||||
75
lib/stores/license.ts
Normal file
75
lib/stores/license.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
export type LicenseType = 'enterprise' | 'smb';
|
||||
|
||||
export interface LicenseState {
|
||||
isEnterprise: boolean;
|
||||
isLicensed: boolean;
|
||||
licenseType: LicenseType | null;
|
||||
loading: boolean;
|
||||
licensedTo: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
function createLicenseStore() {
|
||||
const { subscribe, set, update } = writable<LicenseState>({
|
||||
isEnterprise: false,
|
||||
isLicensed: false,
|
||||
licenseType: null,
|
||||
loading: true,
|
||||
licensedTo: null,
|
||||
expiresAt: null
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
async check() {
|
||||
update(state => ({ ...state, loading: true }));
|
||||
try {
|
||||
const response = await fetch('/api/license');
|
||||
const data = await response.json();
|
||||
const isValid = data.valid && data.active;
|
||||
const licenseType = data.payload?.type as LicenseType | undefined;
|
||||
set({
|
||||
isEnterprise: isValid && licenseType === 'enterprise',
|
||||
isLicensed: isValid,
|
||||
licenseType: isValid ? (licenseType || null) : null,
|
||||
loading: false,
|
||||
licensedTo: data.stored?.name || null,
|
||||
expiresAt: data.payload?.expires || null
|
||||
});
|
||||
} catch {
|
||||
set({ isEnterprise: false, isLicensed: false, licenseType: null, loading: false, licensedTo: null, expiresAt: null });
|
||||
}
|
||||
},
|
||||
setEnterprise(value: boolean) {
|
||||
update(state => ({ ...state, isEnterprise: value }));
|
||||
},
|
||||
/** Wait for the store to finish loading */
|
||||
waitUntilLoaded(): Promise<LicenseState> {
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = subscribe((state) => {
|
||||
if (!state.loading) {
|
||||
// Use setTimeout to avoid unsubscribing during callback
|
||||
setTimeout(() => unsubscribe(), 0);
|
||||
resolve(state);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const licenseStore = createLicenseStore();
|
||||
|
||||
// Derived store for days until expiration
|
||||
export const daysUntilExpiry = derived(licenseStore, ($license) => {
|
||||
if (!$license.isLicensed || !$license.expiresAt) return null;
|
||||
|
||||
const now = new Date();
|
||||
const expires = new Date($license.expiresAt);
|
||||
const diffTime = expires.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return diffDays;
|
||||
});
|
||||
365
lib/stores/settings.ts
Normal file
365
lib/stores/settings.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
||||
|
||||
export interface AppSettings {
|
||||
confirmDestructive: boolean;
|
||||
showStoppedContainers: boolean;
|
||||
highlightUpdates: boolean;
|
||||
timeFormat: TimeFormat;
|
||||
dateFormat: DateFormat;
|
||||
downloadFormat: DownloadFormat;
|
||||
defaultGrypeArgs: string;
|
||||
defaultTrivyArgs: string;
|
||||
scheduleRetentionDays: number;
|
||||
eventRetentionDays: number;
|
||||
scheduleCleanupCron: string;
|
||||
eventCleanupCron: string;
|
||||
scheduleCleanupEnabled: boolean;
|
||||
eventCleanupEnabled: boolean;
|
||||
logBufferSizeKb: number;
|
||||
defaultTimezone: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
confirmDestructive: true,
|
||||
showStoppedContainers: true,
|
||||
highlightUpdates: true,
|
||||
timeFormat: '24h',
|
||||
dateFormat: 'DD.MM.YYYY',
|
||||
downloadFormat: 'tar',
|
||||
defaultGrypeArgs: '-o json -v {image}',
|
||||
defaultTrivyArgs: 'image --format json {image}',
|
||||
scheduleRetentionDays: 30,
|
||||
eventRetentionDays: 30,
|
||||
scheduleCleanupCron: '0 3 * * *',
|
||||
eventCleanupCron: '30 3 * * *',
|
||||
scheduleCleanupEnabled: true,
|
||||
eventCleanupEnabled: true,
|
||||
logBufferSizeKb: 500,
|
||||
defaultTimezone: 'UTC'
|
||||
};
|
||||
|
||||
// Create a writable store for app settings
|
||||
function createSettingsStore() {
|
||||
const { subscribe, set, update } = writable<AppSettings>(DEFAULT_SETTINGS);
|
||||
let initialized = false;
|
||||
|
||||
// Load settings from database on initialization
|
||||
async function loadSettings() {
|
||||
if (!browser || initialized) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/general');
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
set({
|
||||
confirmDestructive: settings.confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive,
|
||||
showStoppedContainers: settings.showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers,
|
||||
highlightUpdates: settings.highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates,
|
||||
timeFormat: settings.timeFormat ?? DEFAULT_SETTINGS.timeFormat,
|
||||
dateFormat: settings.dateFormat ?? DEFAULT_SETTINGS.dateFormat,
|
||||
downloadFormat: settings.downloadFormat ?? DEFAULT_SETTINGS.downloadFormat,
|
||||
defaultGrypeArgs: settings.defaultGrypeArgs ?? DEFAULT_SETTINGS.defaultGrypeArgs,
|
||||
defaultTrivyArgs: settings.defaultTrivyArgs ?? DEFAULT_SETTINGS.defaultTrivyArgs,
|
||||
scheduleRetentionDays: settings.scheduleRetentionDays ?? DEFAULT_SETTINGS.scheduleRetentionDays,
|
||||
eventRetentionDays: settings.eventRetentionDays ?? DEFAULT_SETTINGS.eventRetentionDays,
|
||||
scheduleCleanupCron: settings.scheduleCleanupCron ?? DEFAULT_SETTINGS.scheduleCleanupCron,
|
||||
eventCleanupCron: settings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
|
||||
scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
|
||||
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently use defaults if settings can't be loaded
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings to database
|
||||
async function saveSettings(settings: Partial<AppSettings>) {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/general', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
if (response.ok) {
|
||||
const updatedSettings = await response.json();
|
||||
set({
|
||||
confirmDestructive: updatedSettings.confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive,
|
||||
showStoppedContainers: updatedSettings.showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers,
|
||||
highlightUpdates: updatedSettings.highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates,
|
||||
timeFormat: updatedSettings.timeFormat ?? DEFAULT_SETTINGS.timeFormat,
|
||||
dateFormat: updatedSettings.dateFormat ?? DEFAULT_SETTINGS.dateFormat,
|
||||
downloadFormat: updatedSettings.downloadFormat ?? DEFAULT_SETTINGS.downloadFormat,
|
||||
defaultGrypeArgs: updatedSettings.defaultGrypeArgs ?? DEFAULT_SETTINGS.defaultGrypeArgs,
|
||||
defaultTrivyArgs: updatedSettings.defaultTrivyArgs ?? DEFAULT_SETTINGS.defaultTrivyArgs,
|
||||
scheduleRetentionDays: updatedSettings.scheduleRetentionDays ?? DEFAULT_SETTINGS.scheduleRetentionDays,
|
||||
eventRetentionDays: updatedSettings.eventRetentionDays ?? DEFAULT_SETTINGS.eventRetentionDays,
|
||||
scheduleCleanupCron: updatedSettings.scheduleCleanupCron ?? DEFAULT_SETTINGS.scheduleCleanupCron,
|
||||
eventCleanupCron: updatedSettings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
|
||||
scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
|
||||
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load settings on store creation
|
||||
if (browser) {
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (value: AppSettings) => {
|
||||
set(value);
|
||||
saveSettings(value);
|
||||
},
|
||||
update: (fn: (settings: AppSettings) => AppSettings) => {
|
||||
update((current) => {
|
||||
const newSettings = fn(current);
|
||||
saveSettings(newSettings);
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
// Convenience methods for individual settings
|
||||
setConfirmDestructive: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, confirmDestructive: value };
|
||||
saveSettings({ confirmDestructive: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setShowStoppedContainers: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, showStoppedContainers: value };
|
||||
saveSettings({ showStoppedContainers: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setHighlightUpdates: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, highlightUpdates: value };
|
||||
saveSettings({ highlightUpdates: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setTimeFormat: (value: TimeFormat) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, timeFormat: value };
|
||||
saveSettings({ timeFormat: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDateFormat: (value: DateFormat) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, dateFormat: value };
|
||||
saveSettings({ dateFormat: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDownloadFormat: (value: DownloadFormat) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, downloadFormat: value };
|
||||
saveSettings({ downloadFormat: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultGrypeArgs: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultGrypeArgs: value };
|
||||
saveSettings({ defaultGrypeArgs: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultTrivyArgs: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultTrivyArgs: value };
|
||||
saveSettings({ defaultTrivyArgs: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setScheduleRetentionDays: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, scheduleRetentionDays: value };
|
||||
saveSettings({ scheduleRetentionDays: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setEventRetentionDays: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, eventRetentionDays: value };
|
||||
saveSettings({ eventRetentionDays: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setScheduleCleanupCron: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, scheduleCleanupCron: value };
|
||||
saveSettings({ scheduleCleanupCron: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setEventCleanupCron: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, eventCleanupCron: value };
|
||||
saveSettings({ eventCleanupCron: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setScheduleCleanupEnabled: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, scheduleCleanupEnabled: value };
|
||||
saveSettings({ scheduleCleanupEnabled: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setEventCleanupEnabled: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, eventCleanupEnabled: value };
|
||||
saveSettings({ eventCleanupEnabled: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setLogBufferSizeKb: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, logBufferSizeKb: value };
|
||||
saveSettings({ logBufferSizeKb: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultTimezone: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultTimezone: value };
|
||||
saveSettings({ defaultTimezone: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
// Manual refresh from database
|
||||
refresh: loadSettings
|
||||
};
|
||||
}
|
||||
|
||||
export const appSettings = createSettingsStore();
|
||||
|
||||
// Cache current settings for synchronous access (updated reactively)
|
||||
let cachedTimeFormat: TimeFormat = DEFAULT_SETTINGS.timeFormat;
|
||||
let cachedDateFormat: DateFormat = DEFAULT_SETTINGS.dateFormat;
|
||||
|
||||
// Subscribe once to keep cache updated
|
||||
if (browser) {
|
||||
appSettings.subscribe((s) => {
|
||||
cachedTimeFormat = s.timeFormat;
|
||||
cachedDateFormat = s.dateFormat;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date part according to user's date format preference.
|
||||
* This is a low-level helper - prefer formatDateTime for most uses.
|
||||
*/
|
||||
function formatDatePart(d: Date): string {
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
|
||||
switch (cachedDateFormat) {
|
||||
case 'MM/DD/YYYY':
|
||||
return `${month}/${day}/${year}`;
|
||||
case 'DD/MM/YYYY':
|
||||
return `${day}/${month}/${year}`;
|
||||
case 'YYYY-MM-DD':
|
||||
return `${year}-${month}-${day}`;
|
||||
case 'DD.MM.YYYY':
|
||||
default:
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time part according to user's time format preference.
|
||||
* This is a low-level helper - prefer formatDateTime for most uses.
|
||||
*/
|
||||
function formatTimePart(d: Date, includeSeconds = false): string {
|
||||
const hours = d.getHours();
|
||||
const minutes = d.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = d.getSeconds().toString().padStart(2, '0');
|
||||
|
||||
if (cachedTimeFormat === '12h') {
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
return includeSeconds
|
||||
? `${hour12}:${minutes}:${seconds} ${ampm}`
|
||||
: `${hour12}:${minutes} ${ampm}`;
|
||||
} else {
|
||||
const hour24 = hours.toString().padStart(2, '0');
|
||||
return includeSeconds
|
||||
? `${hour24}:${minutes}:${seconds}`
|
||||
: `${hour24}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp according to user's time and date format preferences.
|
||||
* Performant: uses cached settings, no store subscription per call.
|
||||
*
|
||||
* @param date - Date object, ISO string, or timestamp
|
||||
* @param options - Formatting options
|
||||
* @returns Formatted string
|
||||
*/
|
||||
export function formatTime(
|
||||
date: Date | string | number,
|
||||
options: { includeDate?: boolean; includeSeconds?: boolean } = {}
|
||||
): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const { includeDate = false, includeSeconds = false } = options;
|
||||
|
||||
if (includeDate) {
|
||||
return `${formatDatePart(d)} ${formatTimePart(d, includeSeconds)}`;
|
||||
}
|
||||
|
||||
return formatTimePart(d, includeSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp with date according to user's preferences.
|
||||
* Convenience wrapper around formatTime.
|
||||
*/
|
||||
export function formatDateTime(date: Date | string | number, includeSeconds = false): string {
|
||||
return formatTime(date, { includeDate: true, includeSeconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format just the date part according to user's preferences.
|
||||
*/
|
||||
export function formatDate(date: Date | string | number): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
return formatDatePart(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current time format setting (for components that need it).
|
||||
*/
|
||||
export function getTimeFormat(): TimeFormat {
|
||||
return cachedTimeFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current date format setting (for components that need it).
|
||||
*/
|
||||
export function getDateFormat(): DateFormat {
|
||||
return cachedDateFormat;
|
||||
}
|
||||
134
lib/stores/stats.ts
Normal file
134
lib/stores/stats.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { currentEnvironment, appendEnvParam } from './environment';
|
||||
|
||||
export interface ContainerStats {
|
||||
id: string;
|
||||
name: string;
|
||||
cpuPercent: number;
|
||||
memoryUsage: number;
|
||||
memoryLimit: number;
|
||||
memoryPercent: number;
|
||||
}
|
||||
|
||||
export interface HostInfo {
|
||||
hostname: string;
|
||||
ipAddress: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
cpus: number;
|
||||
totalMemory: number;
|
||||
freeMemory: number;
|
||||
uptime: number;
|
||||
dockerVersion: string;
|
||||
dockerContainers: number;
|
||||
dockerContainersRunning: number;
|
||||
dockerImages: number;
|
||||
}
|
||||
|
||||
export interface HostMetric {
|
||||
cpu_percent: number;
|
||||
memory_percent: number;
|
||||
memory_used: number;
|
||||
memory_total: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Historical data settings
|
||||
const MAX_HISTORY = 60; // 10 minutes at 10s intervals (server collects every 10s)
|
||||
const POLL_INTERVAL = 5000; // 5 seconds
|
||||
|
||||
// Stores
|
||||
export const cpuHistory = writable<number[]>([]);
|
||||
export const memoryHistory = writable<number[]>([]);
|
||||
export const containerStats = writable<ContainerStats[]>([]);
|
||||
export const hostInfo = writable<HostInfo | null>(null);
|
||||
export const lastUpdated = writable<Date>(new Date());
|
||||
export const isCollecting = writable<boolean>(false);
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let envId: number | null = null;
|
||||
let initialFetchDone = false;
|
||||
|
||||
// Subscribe to environment changes
|
||||
currentEnvironment.subscribe((env) => {
|
||||
envId = env?.id ?? null;
|
||||
// Reset history when environment changes
|
||||
if (initialFetchDone) {
|
||||
cpuHistory.set([]);
|
||||
memoryHistory.set([]);
|
||||
initialFetchDone = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper for fetch with timeout
|
||||
async function fetchWithTimeout(url: string, timeout = 5000): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
return response.json();
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
// Don't fetch if no environment is selected
|
||||
if (!envId) return;
|
||||
|
||||
// Fire all fetches independently - don't block on slow ones
|
||||
fetchWithTimeout(appendEnvParam('/api/containers/stats?limit=5', envId), 5000).then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
containerStats.set(data);
|
||||
}
|
||||
});
|
||||
|
||||
fetchWithTimeout(appendEnvParam('/api/host', envId), 5000).then(data => {
|
||||
if (data && !data.error) {
|
||||
hostInfo.set(data);
|
||||
}
|
||||
});
|
||||
|
||||
fetchWithTimeout(appendEnvParam('/api/metrics?limit=60', envId), 5000).then(data => {
|
||||
if (data?.metrics && data.metrics.length > 0) {
|
||||
const metrics: HostMetric[] = data.metrics;
|
||||
const cpuValues = metrics.map(m => m.cpu_percent);
|
||||
const memValues = metrics.map(m => m.memory_percent);
|
||||
|
||||
cpuHistory.set(cpuValues.slice(-MAX_HISTORY));
|
||||
memoryHistory.set(memValues.slice(-MAX_HISTORY));
|
||||
initialFetchDone = true;
|
||||
}
|
||||
});
|
||||
|
||||
lastUpdated.set(new Date());
|
||||
}
|
||||
|
||||
export function startStatsCollection() {
|
||||
if (pollInterval) return; // Already running
|
||||
|
||||
isCollecting.set(true);
|
||||
fetchStats(); // Initial fetch
|
||||
pollInterval = setInterval(fetchStats, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
export function stopStatsCollection() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
isCollecting.set(false);
|
||||
}
|
||||
|
||||
// Get current values
|
||||
export function getCurrentCpu(): number {
|
||||
const history = get(cpuHistory);
|
||||
return history.length > 0 ? history[history.length - 1] : 0;
|
||||
}
|
||||
|
||||
export function getCurrentMemory(): number {
|
||||
const history = get(memoryHistory);
|
||||
return history.length > 0 ? history[history.length - 1] : 0;
|
||||
}
|
||||
260
lib/stores/theme.ts
Normal file
260
lib/stores/theme.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Theme Store for Dockhand
|
||||
*
|
||||
* Manages theme and font preferences with:
|
||||
* - Immediate application (no page reload)
|
||||
* - localStorage sync for flash-free loading
|
||||
* - Database persistence via API
|
||||
*/
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes';
|
||||
|
||||
export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge';
|
||||
|
||||
export interface ThemePreferences {
|
||||
lightTheme: string;
|
||||
darkTheme: string;
|
||||
font: string;
|
||||
fontSize: FontSize;
|
||||
gridFontSize: FontSize;
|
||||
terminalFont: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dockhand-theme';
|
||||
|
||||
const defaultPrefs: ThemePreferences = {
|
||||
lightTheme: 'default',
|
||||
darkTheme: 'default',
|
||||
font: 'system',
|
||||
fontSize: 'normal',
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono'
|
||||
};
|
||||
|
||||
// Font size scale mapping
|
||||
const fontSizeScales: Record<FontSize, number> = {
|
||||
xsmall: 0.75,
|
||||
small: 0.875,
|
||||
normal: 1.0,
|
||||
medium: 1.0625,
|
||||
large: 1.125,
|
||||
xlarge: 1.25
|
||||
};
|
||||
|
||||
// Grid font size scale - independent scaling for data grids
|
||||
const gridFontSizeScales: Record<FontSize, number> = {
|
||||
xsmall: 0.7,
|
||||
small: 0.85,
|
||||
normal: 1.0,
|
||||
medium: 1.15,
|
||||
large: 1.35,
|
||||
xlarge: 1.7
|
||||
};
|
||||
|
||||
// Load initial state from localStorage (for flash-free loading)
|
||||
function loadFromStorage(): ThemePreferences {
|
||||
if (typeof window === 'undefined') return defaultPrefs;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultPrefs, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return defaultPrefs;
|
||||
}
|
||||
|
||||
// Create the store
|
||||
function createThemeStore() {
|
||||
const initialPrefs = loadFromStorage();
|
||||
const { subscribe, set, update } = writable<ThemePreferences>(initialPrefs);
|
||||
|
||||
// Apply theme immediately on store creation (for flash-free loading)
|
||||
if (typeof document !== 'undefined') {
|
||||
applyTheme(initialPrefs);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize from API (called on mount)
|
||||
async init(userId?: number) {
|
||||
try {
|
||||
const url = userId
|
||||
? `/api/profile/preferences`
|
||||
: `/api/settings/general`;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const prefs: ThemePreferences = {
|
||||
lightTheme: data.lightTheme || data.theme_light || 'default',
|
||||
darkTheme: data.darkTheme || data.theme_dark || 'default',
|
||||
font: data.font || data.theme_font || 'system',
|
||||
fontSize: data.fontSize || data.font_size || 'normal',
|
||||
gridFontSize: data.gridFontSize || data.grid_font_size || 'normal',
|
||||
terminalFont: data.terminalFont || data.terminal_font || 'system-mono'
|
||||
};
|
||||
set(prefs);
|
||||
saveToStorage(prefs);
|
||||
applyTheme(prefs);
|
||||
}
|
||||
} catch {
|
||||
// Use localStorage fallback
|
||||
const prefs = loadFromStorage();
|
||||
applyTheme(prefs);
|
||||
}
|
||||
},
|
||||
|
||||
// Update a preference and apply immediately
|
||||
async setPreference<K extends keyof ThemePreferences>(
|
||||
key: K,
|
||||
value: ThemePreferences[K],
|
||||
userId?: number
|
||||
) {
|
||||
update((prefs) => {
|
||||
const newPrefs = { ...prefs, [key]: value };
|
||||
saveToStorage(newPrefs);
|
||||
applyTheme(newPrefs);
|
||||
return newPrefs;
|
||||
});
|
||||
|
||||
// Save to database (async, non-blocking)
|
||||
try {
|
||||
const url = userId
|
||||
? `/api/profile/preferences`
|
||||
: `/api/settings/general`;
|
||||
|
||||
await fetch(url, {
|
||||
method: userId ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [key]: value })
|
||||
});
|
||||
} catch {
|
||||
// Silently fail - localStorage has the value
|
||||
}
|
||||
},
|
||||
|
||||
// Get current preferences
|
||||
get(): ThemePreferences {
|
||||
return get({ subscribe });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
function saveToStorage(prefs: ThemePreferences) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme to document
|
||||
export function applyTheme(prefs: ThemePreferences) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const isDark = root.classList.contains('dark');
|
||||
|
||||
// Remove all theme classes
|
||||
root.classList.forEach((cls) => {
|
||||
if (cls.startsWith('theme-light-') || cls.startsWith('theme-dark-')) {
|
||||
root.classList.remove(cls);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply the appropriate theme class
|
||||
if (isDark && prefs.darkTheme !== 'default') {
|
||||
root.classList.add(`theme-dark-${prefs.darkTheme}`);
|
||||
} else if (!isDark && prefs.lightTheme !== 'default') {
|
||||
root.classList.add(`theme-light-${prefs.lightTheme}`);
|
||||
}
|
||||
|
||||
// Apply font
|
||||
applyFont(prefs.font);
|
||||
|
||||
// Apply font size
|
||||
applyFontSize(prefs.fontSize);
|
||||
|
||||
// Apply grid font size
|
||||
applyGridFontSize(prefs.gridFontSize);
|
||||
|
||||
// Apply terminal font
|
||||
applyTerminalFont(prefs.terminalFont);
|
||||
}
|
||||
|
||||
// Apply font to document
|
||||
function applyFont(fontId: string) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const fontMeta = getFont(fontId);
|
||||
if (!fontMeta) return;
|
||||
|
||||
// Load Google Font if needed
|
||||
if (fontMeta.googleFont) {
|
||||
loadGoogleFont(fontMeta);
|
||||
}
|
||||
|
||||
// Set CSS variable
|
||||
document.documentElement.style.setProperty('--font-sans', fontMeta.family);
|
||||
}
|
||||
|
||||
// Apply font size to document
|
||||
function applyFontSize(fontSize: FontSize) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const scale = fontSizeScales[fontSize] || 1.0;
|
||||
document.documentElement.style.setProperty('--font-size-scale', scale.toString());
|
||||
}
|
||||
|
||||
// Apply grid font size to document
|
||||
function applyGridFontSize(gridFontSize: FontSize) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const gridScale = gridFontSizeScales[gridFontSize] || 1.0;
|
||||
document.documentElement.style.setProperty('--grid-font-size-scale', gridScale.toString());
|
||||
}
|
||||
|
||||
// Apply terminal font to document
|
||||
function applyTerminalFont(fontId: string) {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const fontMeta = getMonospaceFont(fontId);
|
||||
if (!fontMeta) return;
|
||||
|
||||
// Load Google Font if needed
|
||||
if (fontMeta.googleFont) {
|
||||
loadGoogleFont(fontMeta);
|
||||
}
|
||||
|
||||
// Set CSS variable
|
||||
document.documentElement.style.setProperty('--font-mono', fontMeta.family);
|
||||
}
|
||||
|
||||
// Load Google Font dynamically
|
||||
function loadGoogleFont(font: FontMeta) {
|
||||
if (!font.googleFont) return;
|
||||
|
||||
const linkId = `google-font-${font.id}`;
|
||||
if (document.getElementById(linkId)) return; // Already loaded
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.id = linkId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${font.googleFont}&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Re-apply theme when dark mode toggles
|
||||
export function onDarkModeChange() {
|
||||
const prefs = themeStore.get();
|
||||
applyTheme(prefs);
|
||||
}
|
||||
|
||||
export const themeStore = createThemeStore();
|
||||
Reference in New Issue
Block a user