feat: Add operations view components for logs, overview, security, and stats
- Implemented `ops-view-logs` for displaying and filtering logs with streaming capabilities. - Created `ops-view-overview` to show server, email, DNS statistics, and charts. - Developed `ops-view-security` for monitoring security metrics, blocked IPs, and authentication attempts. - Added `ops-view-stats` to present comprehensive statistics on server, email, DNS, and security metrics. - Introduced shared styles and components including `ops-sectionheading` for consistent UI.
This commit is contained in:
@ -164,13 +164,13 @@ export class StatsHandler {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Phase 3: Frontend State Management
|
### Phase 3: Frontend State Management ✓
|
||||||
|
|
||||||
#### 3.1 Set up Smartstate (`ts_web/appstate.ts`)
|
#### 3.1 Set up Smartstate (`ts_web/appstate.ts`) ✓
|
||||||
|
|
||||||
- [ ] Initialize Smartstate instance
|
- [x] Initialize Smartstate instance
|
||||||
- [ ] Create state parts with appropriate persistence
|
- [x] Create state parts with appropriate persistence
|
||||||
- [ ] Define initial state structures
|
- [x] Define initial state structures
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// State structure example
|
// State structure example
|
||||||
@ -184,48 +184,53 @@ interface IStatsState {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3.2 State Parts to Create
|
#### 3.2 State Parts to Create ✓
|
||||||
|
|
||||||
- [ ] `statsState` - Runtime statistics (soft persistence)
|
- [x] `statsState` - Runtime statistics (soft persistence)
|
||||||
- [ ] `configState` - Configuration data (soft persistence)
|
- [x] `configState` - Configuration data (soft persistence)
|
||||||
- [ ] `uiState` - UI preferences (persistent)
|
- [x] `uiState` - UI preferences (persistent)
|
||||||
- [ ] `loginState` - Authentication state (persistent) *if needed*
|
- [x] `loginState` - Authentication state (persistent)
|
||||||
|
|
||||||
### Phase 4: Frontend Integration
|
### Phase 4: Frontend Integration ✓
|
||||||
|
|
||||||
#### 4.1 API Client Setup (`ts_web/api/clients.ts`)
|
#### 4.1 API Client Setup ✓
|
||||||
|
|
||||||
- [ ] Create TypedRequest instances for each endpoint
|
- [x] TypedRequest instances created inline within actions
|
||||||
- [ ] Configure base URL handling
|
- [x] Base URL handled through relative paths
|
||||||
- [ ] Add error interceptors
|
- [x] Error handling integrated in actions
|
||||||
- [ ] Implement retry logic
|
- [x] Following cloudly pattern of creating requests within actions
|
||||||
|
|
||||||
#### 4.2 Create Actions (`ts_web/state/actions.ts`)
|
#### 4.2 Create Actions (`ts_web/appstate.ts`) ✓
|
||||||
|
|
||||||
- [ ] `fetchAllStatsAction` - Batch fetch all statistics
|
- [x] `loginAction` - Authentication with JWT
|
||||||
- [ ] `refreshServerStatsAction` - Update server stats only
|
- [x] `logoutAction` - Clear authentication state
|
||||||
- [ ] `refreshEmailStatsAction` - Update email stats only
|
- [x] `fetchAllStatsAction` - Batch fetch all statistics
|
||||||
- [ ] `setAutoRefreshAction` - Toggle auto-refresh
|
- [x] `fetchConfigurationAction` - Get configuration
|
||||||
- [ ] Error handling actions
|
- [x] `updateConfigurationAction` - Update configuration
|
||||||
|
- [x] `fetchRecentLogsAction` - Get recent logs
|
||||||
|
- [x] `toggleAutoRefreshAction` - Toggle auto-refresh
|
||||||
|
- [x] `setActiveViewAction` - Change active view
|
||||||
|
- [x] Error handling in all actions
|
||||||
|
|
||||||
#### 4.3 Update Dashboard Component (`ts_web/elements/ops-dashboard.ts`)
|
#### 4.3 Update Dashboard Component (`ts_web/elements/ops-dashboard.ts`) ✓
|
||||||
|
|
||||||
- [ ] Subscribe to state changes
|
- [x] Subscribe to state changes (login and UI state)
|
||||||
- [ ] Implement reactive UI updates
|
- [x] Implement reactive UI updates
|
||||||
- [ ] Add refresh controls
|
- [x] Use dees-simple-login and dees-simple-appdash components
|
||||||
- [ ] Create sub-components for different stat types
|
- [x] Create view components for different sections
|
||||||
- [ ] Implement auto-refresh timer
|
- [x] Implement auto-refresh timer functionality
|
||||||
|
|
||||||
### Phase 5: Component Structure
|
### Phase 5: Component Structure ✓
|
||||||
|
|
||||||
Create modular components in `ts_web/elements/components/`:
|
Created modular view components in `ts_web/elements/`:
|
||||||
|
|
||||||
- [ ] `server-stats.ts` - Server statistics display
|
- [x] `ops-view-overview.ts` - Overview with server, email, and DNS statistics
|
||||||
- [ ] `email-stats.ts` - Email metrics visualization
|
- [x] `ops-view-stats.ts` - Detailed statistics with tables and metrics
|
||||||
- [ ] `dns-stats.ts` - DNS statistics
|
- [x] `ops-view-logs.ts` - Log viewer with filtering and search
|
||||||
- [ ] `rate-limit-display.ts` - Rate limiting status
|
- [x] `ops-view-config.ts` - Configuration editor with JSON editing
|
||||||
- [ ] `security-metrics.ts` - Security dashboard
|
- [x] `ops-view-security.ts` - Security metrics and threat monitoring
|
||||||
- [ ] `log-viewer.ts` - Real-time log display
|
- [x] `shared/ops-sectionheading.ts` - Reusable section heading component
|
||||||
|
- [x] `shared/css.ts` - Shared CSS styles
|
||||||
|
|
||||||
### Phase 6: Optional Enhancements
|
### Phase 6: Optional Enhancements
|
||||||
|
|
||||||
@ -314,10 +319,32 @@ Create modular components in `ts_web/elements/components/`:
|
|||||||
- Added guard helpers for protecting endpoints
|
- Added guard helpers for protecting endpoints
|
||||||
- Full test coverage for JWT authentication flows
|
- Full test coverage for JWT authentication flows
|
||||||
|
|
||||||
|
- **Phase 3: Frontend State Management** - Smartstate implementation
|
||||||
|
- Initialized Smartstate with proper state parts
|
||||||
|
- Created state interfaces for all data types
|
||||||
|
- Implemented persistent vs soft state persistence
|
||||||
|
- Set up reactive subscriptions
|
||||||
|
|
||||||
|
- **Phase 4: Frontend Integration** - Complete dashboard implementation
|
||||||
|
- Created all state management actions with TypedRequest
|
||||||
|
- Implemented JWT authentication flow in frontend
|
||||||
|
- Built reactive dashboard with dees-simple-login and dees-simple-appdash
|
||||||
|
- Added auto-refresh functionality
|
||||||
|
- Fixed all interface import issues (using dist_ts_interfaces)
|
||||||
|
|
||||||
|
- **Phase 5: Component Structure** - View components
|
||||||
|
- Created all view components following cloudly patterns
|
||||||
|
- Implemented reactive data binding with state subscriptions
|
||||||
|
- Added interactive features (filtering, editing, refresh controls)
|
||||||
|
- Used @design.estate/dees-catalog components throughout
|
||||||
|
- Created shared components and styles
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- Phase 3: Frontend State Management - Set up Smartstate
|
- Write comprehensive tests for handlers and frontend components
|
||||||
- Phase 4: Frontend Integration - Create API clients and update dashboard
|
- Implement real data sources (replace mock data)
|
||||||
- Phase 5: Create modular UI components
|
- Add WebSocket support for real-time updates
|
||||||
|
- Enhance error handling and user feedback
|
||||||
|
- Add more detailed charts and visualizations
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -1,9 +1,363 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
import * as interfaces from '../dist_ts_interfaces/index.js';
|
||||||
|
|
||||||
const appState = new plugins.deesElement.domtools.plugins.smartstate.Smartstate();
|
// Create main app state instance
|
||||||
|
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
|
||||||
|
|
||||||
export interface IDcRouterState {}
|
// Define state interfaces
|
||||||
export const loginStatePart: plugins.deesElement.domtools.plugins.smartstate.StatePart<
|
export interface ILoginState {
|
||||||
unknown,
|
identity: interfaces.data.IIdentity | null;
|
||||||
IDcRouterState
|
isLoggedIn: boolean;
|
||||||
> = await appState.getStatePart<IDcRouterState>('login', { identity: null }, 'persistent');
|
}
|
||||||
|
|
||||||
|
export interface IStatsState {
|
||||||
|
serverStats: interfaces.data.IServerStats | null;
|
||||||
|
emailStats: interfaces.data.IEmailStats | null;
|
||||||
|
dnsStats: interfaces.data.IDnsStats | null;
|
||||||
|
securityMetrics: interfaces.data.ISecurityMetrics | null;
|
||||||
|
lastUpdated: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConfigState {
|
||||||
|
config: any | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUiState {
|
||||||
|
activeView: string;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
refreshInterval: number; // milliseconds
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILogState {
|
||||||
|
recentLogs: interfaces.data.ILogEntry[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
filters: {
|
||||||
|
level?: string[];
|
||||||
|
category?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create state parts with appropriate persistence
|
||||||
|
export const loginStatePart = await appState.getStatePart<ILoginState>(
|
||||||
|
'login',
|
||||||
|
{
|
||||||
|
identity: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
},
|
||||||
|
'persistent' // Login state persists across sessions
|
||||||
|
);
|
||||||
|
|
||||||
|
export const statsStatePart = await appState.getStatePart<IStatsState>(
|
||||||
|
'stats',
|
||||||
|
{
|
||||||
|
serverStats: null,
|
||||||
|
emailStats: null,
|
||||||
|
dnsStats: null,
|
||||||
|
securityMetrics: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
'soft' // Stats are cached but not persisted
|
||||||
|
);
|
||||||
|
|
||||||
|
export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||||
|
'config',
|
||||||
|
{
|
||||||
|
config: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
'soft'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||||
|
'ui',
|
||||||
|
{
|
||||||
|
activeView: 'dashboard',
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 30000, // 30 seconds
|
||||||
|
theme: 'light',
|
||||||
|
},
|
||||||
|
'persistent' // UI preferences persist
|
||||||
|
);
|
||||||
|
|
||||||
|
export const logStatePart = await appState.getStatePart<ILogState>(
|
||||||
|
'logs',
|
||||||
|
{
|
||||||
|
recentLogs: [],
|
||||||
|
isStreaming: false,
|
||||||
|
filters: {},
|
||||||
|
},
|
||||||
|
'soft'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions for state management
|
||||||
|
interface IActionContext {
|
||||||
|
identity: interfaces.data.IIdentity | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionContext = (): IActionContext => {
|
||||||
|
return {
|
||||||
|
identity: loginStatePart.getState().identity,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Login Action
|
||||||
|
export const loginAction = loginStatePart.createAction<{
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
|
||||||
|
>('/typedrequest', 'adminLoginWithUsernameAndPassword');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
username: dataArg.username,
|
||||||
|
password: dataArg.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.identity) {
|
||||||
|
return {
|
||||||
|
identity: response.identity,
|
||||||
|
isLoggedIn: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return statePartArg.getState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout Action
|
||||||
|
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
if (!context.identity) return statePartArg.getState();
|
||||||
|
|
||||||
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AdminLogout
|
||||||
|
>('/typedrequest', 'adminLogout');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await typedRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear login state regardless
|
||||||
|
return {
|
||||||
|
identity: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch All Stats Action
|
||||||
|
export const fetchAllStatsAction = statsStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch server stats
|
||||||
|
const serverStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetServerStatistics
|
||||||
|
>('/typedrequest', 'getServerStatistics');
|
||||||
|
|
||||||
|
const serverStatsResponse = await serverStatsRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
includeHistory: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch email stats
|
||||||
|
const emailStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetEmailStatistics
|
||||||
|
>('/typedrequest', 'getEmailStatistics');
|
||||||
|
|
||||||
|
const emailStatsResponse = await emailStatsRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch DNS stats
|
||||||
|
const dnsStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetDnsStatistics
|
||||||
|
>('/typedrequest', 'getDnsStatistics');
|
||||||
|
|
||||||
|
const dnsStatsResponse = await dnsStatsRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch security metrics
|
||||||
|
const securityRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetSecurityMetrics
|
||||||
|
>('/typedrequest', 'getSecurityMetrics');
|
||||||
|
|
||||||
|
const securityResponse = await securityRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update state with all stats
|
||||||
|
return {
|
||||||
|
serverStats: serverStatsResponse.stats,
|
||||||
|
emailStats: emailStatsResponse.stats,
|
||||||
|
dnsStats: dnsStatsResponse.stats,
|
||||||
|
securityMetrics: securityResponse.metrics,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error.message || 'Failed to fetch statistics',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch Configuration Action
|
||||||
|
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetConfiguration
|
||||||
|
>('/typedrequest', 'getConfiguration');
|
||||||
|
|
||||||
|
const response = await configRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: response.config,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error.message || 'Failed to fetch configuration',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Configuration Action
|
||||||
|
export const updateConfigurationAction = configStatePart.createAction<{
|
||||||
|
section: string;
|
||||||
|
config: any;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
if (!context.identity) {
|
||||||
|
throw new Error('Must be logged in to update configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateConfiguration
|
||||||
|
>('/typedrequest', 'updateConfiguration');
|
||||||
|
|
||||||
|
const response = await updateRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
section: dataArg.section,
|
||||||
|
config: dataArg.config,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.updated) {
|
||||||
|
// Refresh configuration
|
||||||
|
await configStatePart.dispatchAction(fetchConfigurationAction, null);
|
||||||
|
return statePartArg.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return statePartArg.getState();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch Recent Logs Action
|
||||||
|
export const fetchRecentLogsAction = logStatePart.createAction<{
|
||||||
|
limit?: number;
|
||||||
|
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
|
||||||
|
const logsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetRecentLogs
|
||||||
|
>('/typedrequest', 'getRecentLogs');
|
||||||
|
|
||||||
|
const response = await logsRequest.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
limit: dataArg.limit || 100,
|
||||||
|
level: dataArg.level,
|
||||||
|
category: dataArg.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...statePartArg.getState(),
|
||||||
|
recentLogs: response.logs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle Auto Refresh Action
|
||||||
|
export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePartArg) => {
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
autoRefresh: !currentState.autoRefresh,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set Active View Action
|
||||||
|
export const setActiveViewAction = uiStatePart.createAction<string>(async (statePartArg, viewName) => {
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
activeView: viewName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize auto-refresh
|
||||||
|
let refreshInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// Initialize auto-refresh when UI state is ready
|
||||||
|
(() => {
|
||||||
|
const startAutoRefresh = () => {
|
||||||
|
const uiState = uiStatePart.getState();
|
||||||
|
if (uiState.autoRefresh && loginStatePart.getState().isLoggedIn) {
|
||||||
|
refreshInterval = setInterval(() => {
|
||||||
|
statsStatePart.dispatchAction(fetchAllStatsAction, null);
|
||||||
|
}, uiState.refreshInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
refreshInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes
|
||||||
|
uiStatePart.state.subscribe(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
startAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
loginStatePart.state.subscribe(() => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
startAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial start
|
||||||
|
startAutoRefresh();
|
||||||
|
})();
|
@ -1 +1,7 @@
|
|||||||
export * from './ops-dashboard.js';
|
export * from './ops-dashboard.js';
|
||||||
|
export * from './ops-view-overview.js';
|
||||||
|
export * from './ops-view-stats.js';
|
||||||
|
export * from './ops-view-logs.js';
|
||||||
|
export * from './ops-view-config.js';
|
||||||
|
export * from './ops-view-security.js';
|
||||||
|
export * from './shared/index.js';
|
@ -1,3 +1,6 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
css,
|
css,
|
||||||
@ -8,11 +11,114 @@ import {
|
|||||||
type TemplateResult
|
type TemplateResult
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
// Import view components
|
||||||
|
import { OpsViewOverview } from './ops-view-overview.js';
|
||||||
|
import { OpsViewStats } from './ops-view-stats.js';
|
||||||
|
import { OpsViewLogs } from './ops-view-logs.js';
|
||||||
|
import { OpsViewConfig } from './ops-view-config.js';
|
||||||
|
import { OpsViewSecurity } from './ops-view-security.js';
|
||||||
|
|
||||||
@customElement('ops-dashboard')
|
@customElement('ops-dashboard')
|
||||||
export class OpsDashboard extends DeesElement {
|
export class OpsDashboard extends DeesElement {
|
||||||
|
@state() private loginState: appstate.ILoginState = {
|
||||||
|
identity: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
@state() private uiState: appstate.IUiState = {
|
||||||
|
activeView: 'dashboard',
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
theme: 'light',
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
document.title = 'DCRouter OpsServer';
|
||||||
|
|
||||||
|
// Subscribe to login state
|
||||||
|
const loginSubscription = appstate.loginStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((loginState) => {
|
||||||
|
this.loginState = loginState;
|
||||||
|
// Trigger data fetch when logged in
|
||||||
|
if (loginState.isLoggedIn) {
|
||||||
|
appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||||
|
appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(loginSubscription);
|
||||||
|
|
||||||
|
// Subscribe to UI state
|
||||||
|
const uiSubscription = appstate.uiStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((uiState) => {
|
||||||
|
this.uiState = uiState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(uiSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
.maincontainer {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
hello
|
<div class="maincontainer">
|
||||||
|
<dees-simple-login
|
||||||
|
name="DCRouter OpsServer"
|
||||||
|
.loginAction=${async (username: string, password: string) => {
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return this.loginState.isLoggedIn;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<dees-simple-appdash
|
||||||
|
name="DCRouter OpsServer"
|
||||||
|
.viewTabs=${[
|
||||||
|
{
|
||||||
|
name: 'Overview',
|
||||||
|
element: OpsViewOverview,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Statistics',
|
||||||
|
element: OpsViewStats,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logs',
|
||||||
|
element: OpsViewLogs,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Configuration',
|
||||||
|
element: OpsViewConfig,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Security',
|
||||||
|
element: OpsViewSecurity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
.userMenuItems=${[
|
||||||
|
{
|
||||||
|
name: 'Logout',
|
||||||
|
action: async () => {
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
</dees-simple-appdash>
|
||||||
|
</dees-simple-login>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
268
ts_web/elements/ops-view-config.ts
Normal file
268
ts_web/elements/ops-view-config.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-view-config')
|
||||||
|
export class OpsViewConfig extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private configState: appstate.IConfigState = {
|
||||||
|
config: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private editingSection: string | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private editedConfig: any = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const subscription = appstate.configStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((configState) => {
|
||||||
|
this.configState = configState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
css`
|
||||||
|
.configSection {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionContent {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configField {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldValue {
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configEditor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #856404;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
background: #fee;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
color: #c00;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingMessage {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>Configuration</ops-sectionheading>
|
||||||
|
|
||||||
|
${this.configState.isLoading ? html`
|
||||||
|
<div class="loadingMessage">
|
||||||
|
<dees-spinner></dees-spinner>
|
||||||
|
<p>Loading configuration...</p>
|
||||||
|
</div>
|
||||||
|
` : this.configState.error ? html`
|
||||||
|
<div class="errorMessage">
|
||||||
|
Error loading configuration: ${this.configState.error}
|
||||||
|
</div>
|
||||||
|
` : this.configState.config ? html`
|
||||||
|
<div class="warning">
|
||||||
|
<dees-icon name="warning"></dees-icon>
|
||||||
|
<span>Changes to configuration will take effect immediately. Please be careful when editing production settings.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.renderConfigSection('server', 'Server Configuration', this.configState.config.server)}
|
||||||
|
${this.renderConfigSection('email', 'Email Configuration', this.configState.config.email)}
|
||||||
|
${this.renderConfigSection('dns', 'DNS Configuration', this.configState.config.dns)}
|
||||||
|
${this.renderConfigSection('security', 'Security Configuration', this.configState.config.security)}
|
||||||
|
${this.renderConfigSection('storage', 'Storage Configuration', this.configState.config.storage)}
|
||||||
|
` : html`
|
||||||
|
<div class="errorMessage">No configuration loaded</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderConfigSection(key: string, title: string, config: any) {
|
||||||
|
const isEditing = this.editingSection === key;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="configSection">
|
||||||
|
<div class="sectionHeader">
|
||||||
|
<h3 class="sectionTitle">${title}</h3>
|
||||||
|
<div>
|
||||||
|
${isEditing ? html`
|
||||||
|
<dees-button
|
||||||
|
@click=${() => this.saveConfig(key)}
|
||||||
|
type="highlighted"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</dees-button>
|
||||||
|
<dees-button
|
||||||
|
@click=${() => this.cancelEdit()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</dees-button>
|
||||||
|
` : html`
|
||||||
|
<dees-button
|
||||||
|
@click=${() => this.startEdit(key, config)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</dees-button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sectionContent">
|
||||||
|
${isEditing ? html`
|
||||||
|
<textarea
|
||||||
|
class="configEditor"
|
||||||
|
@input=${(e) => this.editedConfig = e.target.value}
|
||||||
|
.value=${JSON.stringify(config, null, 2)}
|
||||||
|
></textarea>
|
||||||
|
` : html`
|
||||||
|
${this.renderConfigFields(config)}
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderConfigFields(config: any, prefix = '') {
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
return html`<div class="fieldValue">${config}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(config).map(([key, value]) => {
|
||||||
|
const fieldName = prefix ? `${prefix}.${key}` : key;
|
||||||
|
|
||||||
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return html`
|
||||||
|
<div class="configField">
|
||||||
|
<label class="fieldLabel">${fieldName}</label>
|
||||||
|
${this.renderConfigFields(value, fieldName)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="configField">
|
||||||
|
<label class="fieldLabel">${fieldName}</label>
|
||||||
|
<div class="fieldValue">
|
||||||
|
${Array.isArray(value) ? value.join(', ') : value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startEdit(section: string, config: any) {
|
||||||
|
this.editingSection = section;
|
||||||
|
this.editedConfig = JSON.stringify(config, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelEdit() {
|
||||||
|
this.editingSection = null;
|
||||||
|
this.editedConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveConfig(section: string) {
|
||||||
|
try {
|
||||||
|
const parsedConfig = JSON.parse(this.editedConfig);
|
||||||
|
|
||||||
|
await appstate.configStatePart.dispatchAction(appstate.updateConfigurationAction, {
|
||||||
|
section,
|
||||||
|
config: parsedConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.editingSection = null;
|
||||||
|
this.editedConfig = null;
|
||||||
|
|
||||||
|
// Configuration updated successfully
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating configuration:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
207
ts_web/elements/ops-view-logs.ts
Normal file
207
ts_web/elements/ops-view-logs.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-view-logs')
|
||||||
|
export class OpsViewLogs extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private logState: appstate.ILogState = {
|
||||||
|
recentLogs: [],
|
||||||
|
isStreaming: false,
|
||||||
|
filters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const subscription = appstate.logStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((logState) => {
|
||||||
|
this.logState = logState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
css`
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logContainer {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logEntry {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logTimestamp {
|
||||||
|
color: #7a7a7a;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logLevel {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logLevel.debug {
|
||||||
|
color: #6a9955;
|
||||||
|
background: rgba(106, 153, 85, 0.1);
|
||||||
|
}
|
||||||
|
.logLevel.info {
|
||||||
|
color: #569cd6;
|
||||||
|
background: rgba(86, 156, 214, 0.1);
|
||||||
|
}
|
||||||
|
.logLevel.warn {
|
||||||
|
color: #ce9178;
|
||||||
|
background: rgba(206, 145, 120, 0.1);
|
||||||
|
}
|
||||||
|
.logLevel.error {
|
||||||
|
color: #f44747;
|
||||||
|
background: rgba(244, 71, 71, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCategory {
|
||||||
|
color: #c586c0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logMessage {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noLogs {
|
||||||
|
color: #7a7a7a;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>Logs</ops-sectionheading>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="filterGroup">
|
||||||
|
<dees-button
|
||||||
|
@click=${() => this.fetchLogs()}
|
||||||
|
>
|
||||||
|
Refresh Logs
|
||||||
|
</dees-button>
|
||||||
|
|
||||||
|
<dees-button
|
||||||
|
@click=${() => this.toggleStreaming()}
|
||||||
|
.type=${this.logState.isStreaming ? 'highlighted' : 'normal'}
|
||||||
|
>
|
||||||
|
${this.logState.isStreaming ? 'Stop Streaming' : 'Start Streaming'}
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filterGroup">
|
||||||
|
<label>Level:</label>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.options=${['all', 'debug', 'info', 'warn', 'error']}
|
||||||
|
.selectedOption=${'all'}
|
||||||
|
@selectedOption=${(e) => this.updateFilter('level', e.detail)}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filterGroup">
|
||||||
|
<label>Category:</label>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.options=${['all', 'smtp', 'dns', 'security', 'system', 'email']}
|
||||||
|
.selectedOption=${'all'}
|
||||||
|
@selectedOption=${(e) => this.updateFilter('category', e.detail)}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filterGroup">
|
||||||
|
<label>Limit:</label>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.options=${['50', '100', '200', '500']}
|
||||||
|
.selectedOption=${'100'}
|
||||||
|
@selectedOption=${(e) => this.updateFilter('limit', e.detail)}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logContainer">
|
||||||
|
${this.logState.recentLogs.length > 0 ?
|
||||||
|
this.logState.recentLogs.map(log => html`
|
||||||
|
<div class="logEntry">
|
||||||
|
<span class="logTimestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||||
|
<span class="logLevel ${log.level}">${log.level.toUpperCase()}</span>
|
||||||
|
<span class="logCategory">[${log.category}]</span>
|
||||||
|
<span class="logMessage">${log.message}</span>
|
||||||
|
</div>
|
||||||
|
`) : html`
|
||||||
|
<div class="noLogs">No logs to display</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchLogs() {
|
||||||
|
const filters = this.getActiveFilters();
|
||||||
|
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, {
|
||||||
|
limit: filters.limit || 100,
|
||||||
|
level: filters.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
||||||
|
category: filters.category as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFilter(type: string, value: string) {
|
||||||
|
if (value === 'all') {
|
||||||
|
value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filters then fetch logs
|
||||||
|
this.fetchLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getActiveFilters() {
|
||||||
|
return {
|
||||||
|
level: this.logState.filters.level?.[0],
|
||||||
|
category: this.logState.filters.category?.[0],
|
||||||
|
limit: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleStreaming() {
|
||||||
|
// TODO: Implement log streaming with VirtualStream
|
||||||
|
console.log('Streaming toggle not yet implemented');
|
||||||
|
}
|
||||||
|
}
|
222
ts_web/elements/ops-view-overview.ts
Normal file
222
ts_web/elements/ops-view-overview.ts
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-view-overview')
|
||||||
|
export class OpsViewOverview extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private statsState: appstate.IStatsState = {
|
||||||
|
serverStats: null,
|
||||||
|
emailStats: null,
|
||||||
|
dnsStats: null,
|
||||||
|
securityMetrics: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const subscription = appstate.statsStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((statsState) => {
|
||||||
|
this.statsState = statsState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
css`
|
||||||
|
.statsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
grid-gap: 16px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2196F3;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-gap: 16px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingMessage {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
background-color: #fee;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
color: #c00;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>Overview</ops-sectionheading>
|
||||||
|
|
||||||
|
${this.statsState.isLoading ? html`
|
||||||
|
<div class="loadingMessage">
|
||||||
|
<dees-spinner></dees-spinner>
|
||||||
|
<p>Loading statistics...</p>
|
||||||
|
</div>
|
||||||
|
` : this.statsState.error ? html`
|
||||||
|
<div class="errorMessage">
|
||||||
|
Error loading statistics: ${this.statsState.error}
|
||||||
|
</div>
|
||||||
|
` : html`
|
||||||
|
<div class="statsGrid">
|
||||||
|
${this.statsState.serverStats ? html`
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Server Status</h3>
|
||||||
|
<div class="statValue">${this.statsState.serverStats.uptime ? 'Online' : 'Offline'}</div>
|
||||||
|
<div class="statLabel">Uptime: ${this.formatUptime(this.statsState.serverStats.uptime)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Connections</h3>
|
||||||
|
<div class="statValue">${this.statsState.serverStats.activeConnections}</div>
|
||||||
|
<div class="statLabel">Active connections</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Memory Usage</h3>
|
||||||
|
<div class="statValue">${this.formatBytes(this.statsState.serverStats.memoryUsage.heapUsed)}</div>
|
||||||
|
<div class="statLabel">of ${this.formatBytes(this.statsState.serverStats.memoryUsage.heapTotal)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>CPU Usage</h3>
|
||||||
|
<div class="statValue">${Math.round((this.statsState.serverStats.cpuUsage.user + this.statsState.serverStats.cpuUsage.system) / 2)}%</div>
|
||||||
|
<div class="statLabel">Average load</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.statsState.emailStats ? html`
|
||||||
|
<h2>Email Statistics</h2>
|
||||||
|
<div class="statsGrid">
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Emails Sent</h3>
|
||||||
|
<div class="statValue">${this.statsState.emailStats.sent}</div>
|
||||||
|
<div class="statLabel">Total sent</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Emails Received</h3>
|
||||||
|
<div class="statValue">${this.statsState.emailStats.received}</div>
|
||||||
|
<div class="statLabel">Total received</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Failed Deliveries</h3>
|
||||||
|
<div class="statValue">${this.statsState.emailStats.failed}</div>
|
||||||
|
<div class="statLabel">Delivery failures</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Queued</h3>
|
||||||
|
<div class="statValue">${this.statsState.emailStats.queued}</div>
|
||||||
|
<div class="statLabel">In queue</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.statsState.dnsStats ? html`
|
||||||
|
<h2>DNS Statistics</h2>
|
||||||
|
<div class="statsGrid">
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>DNS Queries</h3>
|
||||||
|
<div class="statValue">${this.statsState.dnsStats.totalQueries}</div>
|
||||||
|
<div class="statLabel">Total queries handled</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statCard">
|
||||||
|
<h3>Cache Hit Rate</h3>
|
||||||
|
<div class="statValue">${Math.round(this.statsState.dnsStats.cacheHitRate * 100)}%</div>
|
||||||
|
<div class="statLabel">Cache efficiency</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="chartGrid">
|
||||||
|
<dees-chart-area .label=${'Email Traffic (24h)'} .data=${[]}></dees-chart-area>
|
||||||
|
<dees-chart-area .label=${'DNS Queries (24h)'} .data=${[]}></dees-chart-area>
|
||||||
|
<dees-chart-log .label=${'Recent Events'} .data=${[]}></dees-chart-log>
|
||||||
|
<dees-chart-log .label=${'Security Alerts'} .data=${[]}></dees-chart-log>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBytes(bytes: number): string {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
}
|
471
ts_web/elements/ops-view-security.ts
Normal file
471
ts_web/elements/ops-view-security.ts
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-view-security')
|
||||||
|
export class OpsViewSecurity extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private statsState: appstate.IStatsState = {
|
||||||
|
serverStats: null,
|
||||||
|
emailStats: null,
|
||||||
|
dnsStats: null,
|
||||||
|
securityMetrics: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const subscription = appstate.statsStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((statsState) => {
|
||||||
|
this.statsState = statsState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
css`
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: #2196F3;
|
||||||
|
border-bottom-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityCard {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityCard.alert {
|
||||||
|
border-color: #f44336;
|
||||||
|
background: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityCard.warning {
|
||||||
|
border-color: #ff9800;
|
||||||
|
background: #fff3e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.securityCard.success {
|
||||||
|
border-color: #4caf50;
|
||||||
|
background: #e8f5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardStatus {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-critical {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
background: #ff9800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-good {
|
||||||
|
background: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValue {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockedIpList {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockedIpItem {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockedIpItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ipAddress {
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockReason {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blockTime {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>Security</ops-sectionheading>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab ${this.selectedTab === 'overview' ? 'active' : ''}"
|
||||||
|
@click=${() => this.selectedTab = 'overview'}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab ${this.selectedTab === 'blocked' ? 'active' : ''}"
|
||||||
|
@click=${() => this.selectedTab = 'blocked'}
|
||||||
|
>
|
||||||
|
Blocked IPs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab ${this.selectedTab === 'authentication' ? 'active' : ''}"
|
||||||
|
@click=${() => this.selectedTab = 'authentication'}
|
||||||
|
>
|
||||||
|
Authentication
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab ${this.selectedTab === 'email-security' ? 'active' : ''}"
|
||||||
|
@click=${() => this.selectedTab = 'email-security'}
|
||||||
|
>
|
||||||
|
Email Security
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.renderTabContent()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTabContent() {
|
||||||
|
const metrics = this.statsState.securityMetrics;
|
||||||
|
|
||||||
|
if (!metrics) {
|
||||||
|
return html`
|
||||||
|
<div class="loadingMessage">
|
||||||
|
<p>Loading security metrics...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(this.selectedTab) {
|
||||||
|
case 'overview':
|
||||||
|
return this.renderOverview(metrics);
|
||||||
|
case 'blocked':
|
||||||
|
return this.renderBlockedIPs(metrics);
|
||||||
|
case 'authentication':
|
||||||
|
return this.renderAuthentication(metrics);
|
||||||
|
case 'email-security':
|
||||||
|
return this.renderEmailSecurity(metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderOverview(metrics: any) {
|
||||||
|
const threatLevel = this.calculateThreatLevel(metrics);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="securityGrid">
|
||||||
|
<div class="securityCard ${threatLevel}">
|
||||||
|
<div class="cardHeader">
|
||||||
|
<h3 class="cardTitle">Threat Level</h3>
|
||||||
|
<span class="cardStatus status-${threatLevel === 'alert' ? 'critical' : threatLevel === 'warning' ? 'warning' : 'good'}">
|
||||||
|
${threatLevel.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="metricValue">${this.getThreatScore(metrics)}/100</div>
|
||||||
|
<div class="metricLabel">Overall security score</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="securityCard">
|
||||||
|
<div class="cardHeader">
|
||||||
|
<h3 class="cardTitle">Blocked Threats</h3>
|
||||||
|
</div>
|
||||||
|
<div class="metricValue">${metrics.blockedIPs.length + metrics.spamDetected}</div>
|
||||||
|
<div class="metricLabel">Total threats blocked today</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="securityCard">
|
||||||
|
<div class="cardHeader">
|
||||||
|
<h3 class="cardTitle">Active Sessions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="metricValue">${0}</div>
|
||||||
|
<div class="metricLabel">Current authenticated sessions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Recent Security Events</h2>
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Security Events'}
|
||||||
|
.heading2=${'Last 24 hours'}
|
||||||
|
.data=${this.getSecurityEvents(metrics)}
|
||||||
|
.displayFunction=${(item) => ({
|
||||||
|
'Time': new Date(item.timestamp).toLocaleTimeString(),
|
||||||
|
'Event': item.event,
|
||||||
|
'Severity': item.severity,
|
||||||
|
'Details': item.details,
|
||||||
|
})}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBlockedIPs(metrics: any) {
|
||||||
|
return html`
|
||||||
|
<div class="securityCard">
|
||||||
|
<div class="cardHeader">
|
||||||
|
<h3 class="cardTitle">Blocked IP Addresses</h3>
|
||||||
|
<dees-button @click=${() => this.clearBlockedIPs()}>
|
||||||
|
Clear All
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="blockedIpList">
|
||||||
|
${metrics.blockedIPs && metrics.blockedIPs.length > 0 ? metrics.blockedIPs.map((ipAddress, index) => html`
|
||||||
|
<div class="blockedIpItem">
|
||||||
|
<div>
|
||||||
|
<div class="ipAddress">${ipAddress}</div>
|
||||||
|
<div class="blockReason">Suspicious activity</div>
|
||||||
|
<div class="blockTime">Blocked</div>
|
||||||
|
</div>
|
||||||
|
<dees-button @click=${() => this.unblockIP(ipAddress)}>
|
||||||
|
Unblock
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
`) : html`
|
||||||
|
<p>No blocked IPs</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderAuthentication(metrics: any) {
|
||||||
|
return html`
|
||||||
|
<div class="securityGrid">
|
||||||
|
<div class="securityCard">
|
||||||
|
<h3 class="cardTitle">Authentication Statistics</h3>
|
||||||
|
<div class="metricValue">${metrics.authenticationFailures}</div>
|
||||||
|
<div class="metricLabel">Failed authentication attempts today</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="securityCard">
|
||||||
|
<h3 class="cardTitle">Successful Logins</h3>
|
||||||
|
<div class="metricValue">${0}</div>
|
||||||
|
<div class="metricLabel">Successful logins today</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Recent Login Attempts</h2>
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Login History'}
|
||||||
|
.heading2=${'Recent authentication attempts'}
|
||||||
|
.data=${[]}
|
||||||
|
.displayFunction=${(item) => ({
|
||||||
|
'Time': new Date(item.timestamp).toLocaleString(),
|
||||||
|
'Username': item.username,
|
||||||
|
'IP Address': item.ipAddress,
|
||||||
|
'Status': item.success ? 'Success' : 'Failed',
|
||||||
|
'Reason': item.reason || '-',
|
||||||
|
})}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEmailSecurity(metrics: any) {
|
||||||
|
return html`
|
||||||
|
<div class="securityGrid">
|
||||||
|
<div class="securityCard">
|
||||||
|
<h3 class="cardTitle">Malware Detection</h3>
|
||||||
|
<div class="metricValue">${metrics.malwareDetected}</div>
|
||||||
|
<div class="metricLabel">Malware detected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="securityCard">
|
||||||
|
<h3 class="cardTitle">Phishing Detection</h3>
|
||||||
|
<div class="metricValue">${metrics.phishingDetected}</div>
|
||||||
|
<div class="metricLabel">Phishing attempts detected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="securityCard">
|
||||||
|
<h3 class="cardTitle">Suspicious Activities</h3>
|
||||||
|
<div class="metricValue">${metrics.suspiciousActivities}</div>
|
||||||
|
<div class="metricLabel">Suspicious activities detected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="securityCard">
|
||||||
|
<h3 class="cardTitle">Spam Detection</h3>
|
||||||
|
<div class="metricValue">${metrics.spamDetected}</div>
|
||||||
|
<div class="metricLabel">Spam emails blocked</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Email Security Configuration</h2>
|
||||||
|
<div class="securityCard">
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'enableSPF'}
|
||||||
|
.label=${'Enable SPF checking'}
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'enableDKIM'}
|
||||||
|
.label=${'Enable DKIM validation'}
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'enableDMARC'}
|
||||||
|
.label=${'Enable DMARC policy enforcement'}
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'enableSpamFilter'}
|
||||||
|
.label=${'Enable spam filtering'}
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
<dees-button
|
||||||
|
class="actionButton"
|
||||||
|
type="highlighted"
|
||||||
|
@click=${() => this.saveEmailSecuritySettings()}
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateThreatLevel(metrics: any): string {
|
||||||
|
const score = this.getThreatScore(metrics);
|
||||||
|
if (score < 30) return 'alert';
|
||||||
|
if (score < 70) return 'warning';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getThreatScore(metrics: any): number {
|
||||||
|
// Simple scoring algorithm
|
||||||
|
let score = 100;
|
||||||
|
score -= metrics.blockedIPs.length * 2;
|
||||||
|
score -= metrics.authenticationFailures * 1;
|
||||||
|
score -= metrics.spamDetected * 0.5;
|
||||||
|
score -= metrics.malwareDetected * 3;
|
||||||
|
score -= metrics.phishingDetected * 3;
|
||||||
|
score -= metrics.suspiciousActivities * 2;
|
||||||
|
return Math.max(0, Math.min(100, Math.round(score)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSecurityEvents(metrics: any): any[] {
|
||||||
|
// Mock data - in real implementation, this would come from the server
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
timestamp: Date.now() - 1000 * 60 * 5,
|
||||||
|
event: 'Multiple failed login attempts',
|
||||||
|
severity: 'warning',
|
||||||
|
details: 'IP: 192.168.1.100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: Date.now() - 1000 * 60 * 15,
|
||||||
|
event: 'SPF check failed',
|
||||||
|
severity: 'medium',
|
||||||
|
details: 'Domain: example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: Date.now() - 1000 * 60 * 30,
|
||||||
|
event: 'IP blocked due to spam',
|
||||||
|
severity: 'high',
|
||||||
|
details: 'IP: 10.0.0.1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearBlockedIPs() {
|
||||||
|
console.log('Clear blocked IPs');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unblockIP(ip: string) {
|
||||||
|
console.log('Unblock IP:', ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveEmailSecuritySettings() {
|
||||||
|
console.log('Save email security settings');
|
||||||
|
}
|
||||||
|
}
|
299
ts_web/elements/ops-view-stats.ts
Normal file
299
ts_web/elements/ops-view-stats.ts
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as shared from './shared/index.js';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-view-stats')
|
||||||
|
export class OpsViewStats extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private statsState: appstate.IStatsState = {
|
||||||
|
serverStats: null,
|
||||||
|
emailStats: null,
|
||||||
|
dnsStats: null,
|
||||||
|
securityMetrics: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private uiState: appstate.IUiState = {
|
||||||
|
activeView: 'dashboard',
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
theme: 'light',
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const statsSubscription = appstate.statsStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((statsState) => {
|
||||||
|
this.statsState = statsState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(statsSubscription);
|
||||||
|
|
||||||
|
const uiSubscription = appstate.uiStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((uiState) => {
|
||||||
|
this.uiState = uiState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(uiSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
css`
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastUpdated {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsSection {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricCard {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricCard:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValue {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricUnit {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartContainer {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>Statistics</ops-sectionheading>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="refreshButton">
|
||||||
|
<dees-button
|
||||||
|
@click=${() => appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null)}
|
||||||
|
.disabled=${this.statsState.isLoading}
|
||||||
|
>
|
||||||
|
${this.statsState.isLoading ? html`<dees-spinner size="small"></dees-spinner>` : 'Refresh'}
|
||||||
|
</dees-button>
|
||||||
|
<dees-button
|
||||||
|
@click=${() => appstate.uiStatePart.dispatchAction(appstate.toggleAutoRefreshAction, null)}
|
||||||
|
.type=${this.uiState.autoRefresh ? 'highlighted' : 'normal'}
|
||||||
|
>
|
||||||
|
Auto-refresh: ${this.uiState.autoRefresh ? 'ON' : 'OFF'}
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
<div class="lastUpdated">
|
||||||
|
${this.statsState.lastUpdated ? html`
|
||||||
|
Last updated: ${new Date(this.statsState.lastUpdated).toLocaleTimeString()}
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.statsState.serverStats ? html`
|
||||||
|
<div class="statsSection">
|
||||||
|
<h2 class="sectionTitle">Server Metrics</h2>
|
||||||
|
<div class="metricsGrid">
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">Uptime</div>
|
||||||
|
<div class="metricValue">${this.formatUptime(this.statsState.serverStats.uptime)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">CPU Usage</div>
|
||||||
|
<div class="metricValue">${Math.round((this.statsState.serverStats.cpuUsage.user + this.statsState.serverStats.cpuUsage.system) / 2)}<span class="metricUnit">%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">Memory Used</div>
|
||||||
|
<div class="metricValue">${this.formatBytes(this.statsState.serverStats.memoryUsage.heapUsed)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">Active Connections</div>
|
||||||
|
<div class="metricValue">${this.statsState.serverStats.activeConnections}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chartContainer">
|
||||||
|
<dees-chart-area
|
||||||
|
.label=${'Server Performance (Last 24 Hours)'}
|
||||||
|
.data=${[]}
|
||||||
|
></dees-chart-area>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.statsState.emailStats ? html`
|
||||||
|
<div class="statsSection">
|
||||||
|
<h2 class="sectionTitle">Email Statistics</h2>
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Email Metrics'}
|
||||||
|
.heading2=${'Current statistics for email processing'}
|
||||||
|
.data=${[
|
||||||
|
{ metric: 'Total Sent', value: this.statsState.emailStats.sent, unit: 'emails' },
|
||||||
|
{ metric: 'Total Received', value: this.statsState.emailStats.received, unit: 'emails' },
|
||||||
|
{ metric: 'Failed Deliveries', value: this.statsState.emailStats.failed, unit: 'emails' },
|
||||||
|
{ metric: 'Currently Queued', value: this.statsState.emailStats.queued, unit: 'emails' },
|
||||||
|
{ metric: 'Average Delivery Time', value: this.statsState.emailStats.averageDeliveryTime, unit: 'ms' },
|
||||||
|
{ metric: 'Delivery Rate', value: `${Math.round(this.statsState.emailStats.deliveryRate * 100)}`, unit: '%' },
|
||||||
|
]}
|
||||||
|
.displayFunction=${(item) => ({
|
||||||
|
Metric: item.metric,
|
||||||
|
Value: `${item.value} ${item.unit}`,
|
||||||
|
})}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.statsState.dnsStats ? html`
|
||||||
|
<div class="statsSection">
|
||||||
|
<h2 class="sectionTitle">DNS Statistics</h2>
|
||||||
|
<div class="metricsGrid">
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">Total Queries</div>
|
||||||
|
<div class="metricValue">${this.formatNumber(this.statsState.dnsStats.totalQueries)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">Cache Hit Rate</div>
|
||||||
|
<div class="metricValue">${Math.round(this.statsState.dnsStats.cacheHitRate * 100)}<span class="metricUnit">%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">Average Response Time</div>
|
||||||
|
<div class="metricValue">${this.statsState.dnsStats.averageResponseTime}<span class="metricUnit">ms</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="metricCard">
|
||||||
|
<div class="metricLabel">Domains Configured</div>
|
||||||
|
<div class="metricValue">${this.statsState.dnsStats.activeDomains}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.statsState.securityMetrics ? html`
|
||||||
|
<div class="statsSection">
|
||||||
|
<h2 class="sectionTitle">Security Metrics</h2>
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Security Events'}
|
||||||
|
.heading2=${'Recent security-related activities'}
|
||||||
|
.data=${[
|
||||||
|
{ metric: 'Blocked IPs', value: this.statsState.securityMetrics.blockedIPs.length, severity: 'high' },
|
||||||
|
{ metric: 'Failed Authentications', value: this.statsState.securityMetrics.authenticationFailures, severity: 'medium' },
|
||||||
|
{ metric: 'Spam Detected', value: this.statsState.securityMetrics.spamDetected, severity: 'low' },
|
||||||
|
{ metric: 'Spam Detected', value: this.statsState.securityMetrics.spamDetected, severity: 'medium' },
|
||||||
|
{ metric: 'Malware Detected', value: this.statsState.securityMetrics.malwareDetected, severity: 'high' },
|
||||||
|
{ metric: 'Phishing Detected', value: this.statsState.securityMetrics.phishingDetected, severity: 'high' },
|
||||||
|
]}
|
||||||
|
.displayFunction=${(item) => ({
|
||||||
|
'Security Metric': item.metric,
|
||||||
|
'Count': item.value,
|
||||||
|
'Severity': item.severity,
|
||||||
|
})}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${hours}h`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBytes(bytes: number): string {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatNumber(num: number): string {
|
||||||
|
if (num >= 1000000) {
|
||||||
|
return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
} else if (num >= 1000) {
|
||||||
|
return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
}
|
10
ts_web/elements/shared/css.ts
Normal file
10
ts_web/elements/shared/css.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { css } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const viewHostCss = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
max-width: 1280px;
|
||||||
|
padding: 16px 16px;
|
||||||
|
}
|
||||||
|
`;
|
2
ts_web/elements/shared/index.ts
Normal file
2
ts_web/elements/shared/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './css.js';
|
||||||
|
export * from './ops-sectionheading.js';
|
42
ts_web/elements/shared/ops-sectionheading.ts
Normal file
42
ts_web/elements/shared/ops-sectionheading.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
type TemplateResult
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-sectionheading')
|
||||||
|
export class OpsSectionHeading extends DeesElement {
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-family: 'Cal Sans', 'Inter', sans-serif;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([theme="dark"]) .heading {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<h1 class="heading">
|
||||||
|
<slot></slot>
|
||||||
|
</h1>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
@ -6,3 +6,6 @@ export {
|
|||||||
deesElement,
|
deesElement,
|
||||||
deesCatalog
|
deesCatalog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// domtools gives us TypedRequest and other utilities
|
||||||
|
export const domtools = deesElement.domtools;
|
||||||
|
Reference in New Issue
Block a user