refactor: remove legacy DISABLE_AUTH flag and enhance authentication UX
Major authentication system improvements: - Remove deprecated DISABLE_AUTH environment variable support - Update all documentation to remove DISABLE_AUTH references - Add auth recovery instructions to docs (create .auth_recovery file) - Improve first-run setup and Quick Security wizard flows - Enhance login page with better error messaging and validation - Refactor Docker hosts view with new unified table and tree components - Add useDebouncedValue hook for better search performance - Improve Settings page with better security configuration UX - Update mock mode and development scripts for consistency - Add ScrollableTable persistence and improved responsive design Backend changes: - Remove DISABLE_AUTH flag detection and handling - Improve auth configuration validation and error messages - Enhance security status endpoint responses - Update router integration tests Frontend changes: - New Docker components: DockerUnifiedTable, DockerTree, DockerSummaryStats - Better connection status indicator positioning - Improved authentication state management - Enhanced CSRF and session handling - Better loading states and error recovery This completes the migration away from the insecure DISABLE_AUTH pattern toward proper authentication with recovery mechanisms.
This commit is contained in:
parent
68ce8e7520
commit
e07336dd9f
32 changed files with 2610 additions and 1715 deletions
|
|
@ -42,7 +42,6 @@ API_TOKEN=abc123... # Optional: seed a primary API token (auto-
|
|||
API_TOKENS=token-one,token-two # Optional: comma-separated list of API tokens
|
||||
|
||||
# Security settings
|
||||
DISABLE_AUTH=true # Disable authentication entirely
|
||||
PULSE_AUDIT_LOG=true # Enable security audit logging
|
||||
|
||||
# Proxy/SSO Authentication (see docs/PROXY_AUTH.md for full details)
|
||||
|
|
@ -63,6 +62,7 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
|
|||
- Changes to this file are applied immediately without restart (v4.3.9+)
|
||||
- **DO NOT** put port configuration here - use system.json or systemd overrides
|
||||
- Copy `.env.example` from the repository for a ready-to-edit template
|
||||
- Locked out? Create `<data-path>/.auth_recovery`, restart Pulse, and sign in from localhost to reset credentials. Remove the file afterwards.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -397,7 +397,8 @@ These should be set in the .env file for security:
|
|||
- `PULSE_AUTH_USER`, `PULSE_AUTH_PASS` - Basic authentication
|
||||
- `API_TOKEN` - Primary API token (auto-hashed if you supply the raw value)
|
||||
- `API_TOKENS` - Comma-separated list of additional API tokens (plain or SHA3-256 hashed)
|
||||
- `DISABLE_AUTH` - Set to `true` to disable authentication entirely
|
||||
|
||||
> Locked out? Create `<data-path>/.auth_recovery`, restart Pulse, and sign in from localhost. Delete the flag file and restart again to restore normal authentication.
|
||||
|
||||
#### OIDC Variables (optional overrides)
|
||||
Set these environment variables to manage single sign-on without using the UI. When present, the OIDC form is locked read-only.
|
||||
|
|
|
|||
|
|
@ -317,9 +317,10 @@ docker run -d --name pulse \
|
|||
| `PULSE_AUTH_PASS` | Admin password (plain text auto-hashed or bcrypt hash) | `super-secret-password` or `$2a$12$...` |
|
||||
| `API_TOKEN` | Legacy single API token (optional fallback) | `openssl rand -hex 24` |
|
||||
| `API_TOKENS` | Comma-separated list of API tokens (plain or SHA3-256 hashed) | `ansible-token,docker-agent-token` |
|
||||
| `DISABLE_AUTH` | Disable authentication entirely | `false` |
|
||||
| `PULSE_AUDIT_LOG` | Enable security audit logging | `false` |
|
||||
|
||||
> Locked out while testing a container? Create `/data/.auth_recovery`, restart the container, and connect from localhost to reset credentials. Remove the flag file and restart again to restore normal authentication.
|
||||
|
||||
### Network
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ OIDC is optional. Pulse continues to ship with the familiar username/password fl
|
|||
|
||||
- First-run setup still prompts you to create an admin credential or you can pre-seed it via `PULSE_AUTH_USER` / `PULSE_AUTH_PASS`.
|
||||
- If OIDC is **enabled**, the login page shows both the password form and the **Continue with Single Sign-On** button. Either path issues the same session cookie (`pulse_session`).
|
||||
- To run **password-only**, leave OIDC disabled (the default). To go **OIDC-only**, set `DISABLE_AUTH=true` after you confirm SSO works.
|
||||
- To run **password-only**, leave OIDC disabled (the default). For an **OIDC-first** experience, enable OIDC and rotate the local admin password to a randomly generated value; Pulse always keeps the password form available for emergency access.
|
||||
- The `allowedGroups`, `allowedDomains`, and `allowedEmails` settings only affect OIDC logins; password authentication continues to honour the account you created locally.
|
||||
|
||||
## Provider Cheat-Sheet
|
||||
|
|
|
|||
|
|
@ -13,20 +13,9 @@ Pulse uses WebSockets for real-time updates. Your reverse proxy **MUST** support
|
|||
|
||||
## Authentication with Reverse Proxy
|
||||
|
||||
If you're using authentication at the reverse proxy level (Authentik, Authelia, etc.), you can disable Pulse's built-in authentication to avoid double login prompts:
|
||||
Pulse always enforces its own authentication. If you want to delegate sign-in to your reverse proxy (Authentik, Authelia, etc.), configure Pulse's **Proxy Authentication** integration under *Settings → Security*. That lets Pulse trust the authenticated user provided by the proxy while keeping per-user roles, API tokens, and audit logging intact.
|
||||
|
||||
```bash
|
||||
# In your .env file or environment
|
||||
DISABLE_AUTH=true
|
||||
```
|
||||
|
||||
When `DISABLE_AUTH=true` is set:
|
||||
- Pulse's built-in authentication is completely bypassed
|
||||
- All endpoints become accessible without authentication
|
||||
- The reverse proxy handles all authentication and authorization
|
||||
- A warning is logged on startup to confirm auth is disabled
|
||||
|
||||
⚠️ **Warning**: Only use `DISABLE_AUTH=true` if your reverse proxy provides authentication. Never expose Pulse directly to the internet with authentication disabled.
|
||||
> **Note:** The legacy `DISABLE_AUTH` environment variable has been removed. If it still exists in your deployment, Pulse will log a warning at startup and ignore it. Remove the variable and restart Pulse to silence the warning.
|
||||
|
||||
## Nginx
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,31 @@ function App() {
|
|||
};
|
||||
const alertsActivation = useAlertsActivation();
|
||||
|
||||
let hasPreloadedRoutes = false;
|
||||
let hasFetchedVersionInfo = false;
|
||||
const preloadLazyRoutes = () => {
|
||||
if (hasPreloadedRoutes || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
hasPreloadedRoutes = true;
|
||||
const loaders: Array<() => Promise<unknown>> = [
|
||||
() => import('./components/Storage/Storage'),
|
||||
() => import('./components/Backups/Backups'),
|
||||
() => import('./components/Replication/Replication'),
|
||||
() => import('./components/PMG/MailGateway'),
|
||||
() => import('./components/Hosts/HostsOverview'),
|
||||
() => import('./pages/Alerts'),
|
||||
() => import('./components/Settings/Settings'),
|
||||
() => import('./components/Docker/DockerHosts'),
|
||||
];
|
||||
|
||||
loaders.forEach((load) => {
|
||||
void load().catch((error) => {
|
||||
console.warn('[App] Failed to preload route module', error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fallbackState: State = {
|
||||
nodes: [],
|
||||
vms: [],
|
||||
|
|
@ -214,9 +239,46 @@ function App() {
|
|||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
void alertsActivation.refreshConfig();
|
||||
void alertsActivation.refreshActiveAlerts();
|
||||
createEffect(() => {
|
||||
if (!isLoading() && !needsAuth()) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!hasPreloadedRoutes) {
|
||||
// Defer to the next tick so we don't contend with initial render
|
||||
window.setTimeout(preloadLazyRoutes, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (isLoading() || needsAuth() || hasFetchedVersionInfo) {
|
||||
return;
|
||||
}
|
||||
hasFetchedVersionInfo = true;
|
||||
|
||||
UpdatesAPI.getVersion()
|
||||
.then((version) => {
|
||||
setVersionInfo(version);
|
||||
// Check for updates after loading version info (non-blocking)
|
||||
updateStore.checkForUpdates();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load version:', error);
|
||||
});
|
||||
});
|
||||
|
||||
let alertsInitialized = false;
|
||||
createEffect(() => {
|
||||
const ready = !isLoading() && !needsAuth();
|
||||
if (ready && !alertsInitialized) {
|
||||
alertsInitialized = true;
|
||||
void alertsActivation.refreshConfig();
|
||||
void alertsActivation.refreshActiveAlerts();
|
||||
}
|
||||
if (!ready) {
|
||||
alertsInitialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
// No longer need tab state management - using router now
|
||||
|
|
@ -316,49 +378,34 @@ function App() {
|
|||
// First check security status to see if auth is configured
|
||||
try {
|
||||
const securityRes = await apiFetch('/api/security/status');
|
||||
|
||||
if (securityRes.status === 401) {
|
||||
console.warn(
|
||||
'[App] Security status request returned 401. Clearing stored credentials and showing login.',
|
||||
);
|
||||
try {
|
||||
const { clearAuth } = await import('./utils/apiClient');
|
||||
clearAuth();
|
||||
} catch (clearError) {
|
||||
console.warn('[App] Failed to clear stored auth after 401:', clearError);
|
||||
}
|
||||
setHasAuth(false);
|
||||
setNeedsAuth(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!securityRes.ok) {
|
||||
throw new Error(`Security status request failed with status ${securityRes.status}`);
|
||||
}
|
||||
|
||||
const securityData = await securityRes.json();
|
||||
console.log('[App] Security status:', securityData);
|
||||
|
||||
// Check if auth is disabled via DISABLE_AUTH
|
||||
if (securityData.disabled === true) {
|
||||
console.log('[App] Auth is disabled via DISABLE_AUTH, skipping authentication');
|
||||
setHasAuth(false);
|
||||
setNeedsAuth(false);
|
||||
// Initialize WebSocket immediately since no auth needed
|
||||
setWsStore(acquireWsStore());
|
||||
|
||||
// Load theme preference from server for cross-device sync
|
||||
// Only use server preference if no local preference exists
|
||||
if (!hasLocalPreference) {
|
||||
try {
|
||||
const systemSettings = await SettingsAPI.getSystemSettings();
|
||||
if (systemSettings.theme && systemSettings.theme !== '') {
|
||||
const prefersDark = systemSettings.theme === 'dark';
|
||||
setDarkMode(prefersDark);
|
||||
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(prefersDark));
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
setHasLoadedServerTheme(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load theme from server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load version info even when auth is disabled
|
||||
UpdatesAPI.getVersion()
|
||||
.then((version) => {
|
||||
setVersionInfo(version);
|
||||
// Check for updates after loading version info (non-blocking)
|
||||
updateStore.checkForUpdates();
|
||||
})
|
||||
.catch((error) => console.error('Failed to load version:', error));
|
||||
|
||||
setIsLoading(false);
|
||||
return;
|
||||
// Detect legacy DISABLE_AUTH flag (now ignored) so we can surface a warning
|
||||
if (securityData.deprecatedDisableAuth === true) {
|
||||
console.warn(
|
||||
'[App] Legacy DISABLE_AUTH flag detected; authentication remains enabled. Remove the flag and restart Pulse to silence this warning.',
|
||||
);
|
||||
}
|
||||
|
||||
const authConfigured = securityData.hasAuthentication || false;
|
||||
|
|
@ -504,23 +551,17 @@ function App() {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
// On error, try to proceed without auth
|
||||
setNeedsAuth(false);
|
||||
setWsStore(acquireWsStore());
|
||||
|
||||
// Theme is already applied on initialization, no need to reapply
|
||||
try {
|
||||
const { clearAuth } = await import('./utils/apiClient');
|
||||
clearAuth();
|
||||
} catch (clearError) {
|
||||
console.warn('[App] Failed to clear stored auth after auth check error:', clearError);
|
||||
}
|
||||
setHasAuth(false);
|
||||
setNeedsAuth(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
// Load version info
|
||||
UpdatesAPI.getVersion()
|
||||
.then((version) => {
|
||||
setVersionInfo(version);
|
||||
// Check for updates after loading version info (non-blocking)
|
||||
updateStore.checkForUpdates();
|
||||
})
|
||||
.catch((error) => console.error('Failed to load version:', error));
|
||||
});
|
||||
|
||||
const handleLogin = () => {
|
||||
|
|
@ -655,6 +696,61 @@ function App() {
|
|||
);
|
||||
}
|
||||
|
||||
function ConnectionStatusBadge(props: {
|
||||
connected: () => boolean;
|
||||
reconnecting: () => boolean;
|
||||
class?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
|
||||
props.connected()
|
||||
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300 min-w-6 h-6 group-hover:px-3'
|
||||
: props.reconnecting()
|
||||
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300 py-1'
|
||||
: 'disconnected bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 min-w-6 h-6 group-hover:px-3'
|
||||
} ${props.class ?? ''}`}
|
||||
>
|
||||
<Show when={props.reconnecting()}>
|
||||
<svg class="animate-spin h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</Show>
|
||||
<Show when={props.connected()}>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400 flex-shrink-0"></span>
|
||||
</Show>
|
||||
<Show when={!props.connected() && !props.reconnecting()}>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-gray-600 dark:bg-gray-400 flex-shrink-0"></span>
|
||||
</Show>
|
||||
<span
|
||||
class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${
|
||||
props.connected() || (!props.connected() && !props.reconnecting())
|
||||
? 'max-w-0 group-hover:max-w-[100px] group-hover:ml-2 group-hover:mr-1 opacity-0 group-hover:opacity-100'
|
||||
: 'max-w-[100px] ml-1 opacity-100'
|
||||
}`}
|
||||
>
|
||||
{props.connected()
|
||||
? 'Connected'
|
||||
: props.reconnecting()
|
||||
? 'Reconnecting...'
|
||||
: 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppLayout(props: {
|
||||
connected: () => boolean;
|
||||
reconnecting: () => boolean;
|
||||
|
|
@ -844,85 +940,47 @@ function AppLayout(props: {
|
|||
return (
|
||||
<div class="pulse-shell">
|
||||
{/* Header */}
|
||||
<div class="header mb-3 flex flex-col gap-3 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0">
|
||||
<div class="flex items-center gap-2 sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={`pulse-logo ${props.connected() && props.dataUpdated() ? 'animate-pulse-logo' : ''}`}
|
||||
>
|
||||
<title>Pulse Logo</title>
|
||||
<circle
|
||||
class="pulse-bg fill-blue-600 dark:fill-blue-500"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="122"
|
||||
/>
|
||||
<circle
|
||||
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="84"
|
||||
/>
|
||||
<circle
|
||||
class="pulse-center fill-white dark:fill-[#dbeafe]"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="26"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
|
||||
<Show when={props.versionInfo()?.channel === 'rc'}>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
|
||||
RC
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="header-controls flex w-full flex-wrap items-center gap-3 justify-start sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end">
|
||||
<div class="flex w-full flex-wrap items-center gap-2 justify-start sm:w-auto sm:justify-end">
|
||||
<div
|
||||
class={`group status text-xs rounded-full flex items-center justify-center transition-all duration-500 ease-in-out px-1.5 ${
|
||||
props.connected()
|
||||
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300 min-w-6 h-6 group-hover:px-3'
|
||||
: props.reconnecting()
|
||||
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300 py-1'
|
||||
: 'disconnected bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 min-w-6 h-6 group-hover:px-3'
|
||||
}`}
|
||||
<div class="header mb-3 flex flex-wrap items-center gap-2 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0">
|
||||
<div class="flex flex-1 items-center gap-2 sm:flex-initial sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={`pulse-logo ${props.connected() && props.dataUpdated() ? 'animate-pulse-logo' : ''}`}
|
||||
>
|
||||
<Show when={props.reconnecting()}>
|
||||
<svg class="animate-spin h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</Show>
|
||||
<Show when={props.connected()}>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400 flex-shrink-0"></span>
|
||||
</Show>
|
||||
<Show when={!props.connected() && !props.reconnecting()}>
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-gray-600 dark:bg-gray-400 flex-shrink-0"></span>
|
||||
</Show>
|
||||
<span class={`whitespace-nowrap overflow-hidden transition-all duration-500 ${props.connected() || (!props.connected() && !props.reconnecting()) ? 'max-w-0 group-hover:max-w-[100px] group-hover:ml-2 group-hover:mr-1 opacity-0 group-hover:opacity-100' : 'max-w-[100px] ml-1 opacity-100'}`}>
|
||||
{props.connected()
|
||||
? 'Connected'
|
||||
: props.reconnecting()
|
||||
? 'Reconnecting...'
|
||||
: 'Disconnected'}
|
||||
<title>Pulse Logo</title>
|
||||
<circle
|
||||
class="pulse-bg fill-blue-600 dark:fill-blue-500"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="122"
|
||||
/>
|
||||
<circle
|
||||
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="84"
|
||||
/>
|
||||
<circle
|
||||
class="pulse-center fill-white dark:fill-[#dbeafe]"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="26"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
|
||||
<Show when={props.versionInfo()?.channel === 'rc'}>
|
||||
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
|
||||
RC
|
||||
</span>
|
||||
</div>
|
||||
<Show when={props.hasAuth() && !props.needsAuth()}>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-controls flex w-full flex-wrap items-center gap-2 justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end">
|
||||
<Show when={props.hasAuth() && !props.needsAuth()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={props.proxyAuthInfo()?.username}>
|
||||
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
|
||||
{props.proxyAuthInfo()?.username}
|
||||
|
|
@ -955,8 +1013,13 @@ function AppLayout(props: {
|
|||
Logout
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<ConnectionStatusBadge
|
||||
connected={props.connected}
|
||||
reconnecting={props.reconnecting}
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
|||
|
||||
return (
|
||||
<Card padding="none" class="mb-4 overflow-hidden">
|
||||
<ScrollableTable minWidth="720px">
|
||||
<ScrollableTable minWidth="720px" persistKey="docker-host-summary">
|
||||
<table class="w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
350
frontend-modern/src/components/Docker/DockerSummaryStats.tsx
Normal file
350
frontend-modern/src/components/Docker/DockerSummaryStats.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { Show, createMemo } from 'solid-js';
|
||||
import type { DockerHost } from '@/types/api';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
sublabel?: string;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const StatCard: Component<StatCardProps> = (props) => {
|
||||
const baseClass = 'flex flex-col gap-1 px-4 py-3 rounded-lg border transition-all duration-200';
|
||||
|
||||
const variantClass = () => {
|
||||
if (props.isActive) {
|
||||
return 'bg-blue-100 dark:bg-blue-900/40 border-blue-300 dark:border-blue-700 ring-2 ring-blue-500/20';
|
||||
}
|
||||
|
||||
switch (props.variant) {
|
||||
case 'success':
|
||||
return 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800';
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
|
||||
case 'error':
|
||||
return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800';
|
||||
case 'info':
|
||||
return 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800';
|
||||
default:
|
||||
return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const hoverClass = () => props.onClick ? 'cursor-pointer hover:shadow-md hover:scale-[1.02]' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`${baseClass} ${variantClass()} ${hoverClass()}`}
|
||||
onClick={props.onClick}
|
||||
disabled={!props.onClick}
|
||||
>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{props.value}
|
||||
</div>
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
|
||||
{props.label}
|
||||
</div>
|
||||
<Show when={props.sublabel}>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-500">
|
||||
{props.sublabel}
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export interface DockerSummaryStats {
|
||||
hosts: {
|
||||
total: number;
|
||||
online: number;
|
||||
degraded: number;
|
||||
offline: number;
|
||||
};
|
||||
containers: {
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
error: number;
|
||||
};
|
||||
services?: {
|
||||
total: number;
|
||||
healthy: number;
|
||||
degraded: number;
|
||||
};
|
||||
resources?: {
|
||||
avgCpu: number | null;
|
||||
avgMemory: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
type SummaryFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null;
|
||||
|
||||
interface DockerSummaryStatsProps {
|
||||
hosts: DockerHost[];
|
||||
onFilterChange?: (filter: SummaryFilter) => void;
|
||||
activeFilter?: { type: string; value: string } | null;
|
||||
}
|
||||
|
||||
export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props) => {
|
||||
const stats = (): DockerSummaryStats => {
|
||||
const hosts = props.hosts || [];
|
||||
|
||||
// Host stats
|
||||
let onlineHosts = 0;
|
||||
let degradedHosts = 0;
|
||||
let offlineHosts = 0;
|
||||
|
||||
hosts.forEach((host) => {
|
||||
const status = host.status?.toLowerCase() ?? 'unknown';
|
||||
switch (status) {
|
||||
case 'online':
|
||||
onlineHosts++;
|
||||
break;
|
||||
case 'degraded':
|
||||
case 'warning':
|
||||
case 'maintenance':
|
||||
degradedHosts++;
|
||||
break;
|
||||
case 'offline':
|
||||
case 'error':
|
||||
case 'unreachable':
|
||||
offlineHosts++;
|
||||
break;
|
||||
default:
|
||||
degradedHosts++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Container stats
|
||||
let totalContainers = 0;
|
||||
let runningContainers = 0;
|
||||
let stoppedContainers = 0;
|
||||
let errorContainers = 0;
|
||||
|
||||
// Service stats
|
||||
let totalServices = 0;
|
||||
let healthyServices = 0;
|
||||
let degradedServices = 0;
|
||||
|
||||
// Resource stats
|
||||
let totalCpu = 0;
|
||||
let totalMemory = 0;
|
||||
let cpuSamples = 0;
|
||||
let memorySamples = 0;
|
||||
|
||||
hosts.forEach(host => {
|
||||
// Count containers
|
||||
const containers = host.containers || [];
|
||||
totalContainers += containers.length;
|
||||
|
||||
containers.forEach(container => {
|
||||
const state = container.state?.toLowerCase();
|
||||
if (state === 'running') {
|
||||
runningContainers++;
|
||||
// Sum CPU/Memory for running containers
|
||||
if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) {
|
||||
totalCpu += container.cpuPercent;
|
||||
cpuSamples++;
|
||||
}
|
||||
if (typeof container.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent)) {
|
||||
totalMemory += container.memoryPercent;
|
||||
memorySamples++;
|
||||
}
|
||||
} else if (state === 'exited' || state === 'stopped' || state === 'created' || state === 'paused') {
|
||||
stoppedContainers++;
|
||||
} else if (state === 'restarting' || state === 'dead' || state === 'removing' || state === 'error' || state === 'failed') {
|
||||
errorContainers++;
|
||||
}
|
||||
});
|
||||
|
||||
// Count services
|
||||
const services = host.services || [];
|
||||
totalServices += services.length;
|
||||
|
||||
services.forEach(service => {
|
||||
const desired = service.desiredTasks ?? 0;
|
||||
const running = service.runningTasks ?? 0;
|
||||
if (desired > 0 && running >= desired) {
|
||||
healthyServices++;
|
||||
} else if (desired > 0) {
|
||||
degradedServices++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
hosts: {
|
||||
total: hosts.length,
|
||||
online: onlineHosts,
|
||||
degraded: degradedHosts,
|
||||
offline: offlineHosts,
|
||||
},
|
||||
containers: {
|
||||
total: totalContainers,
|
||||
running: runningContainers,
|
||||
stopped: stoppedContainers,
|
||||
error: errorContainers,
|
||||
},
|
||||
services: totalServices > 0 ? {
|
||||
total: totalServices,
|
||||
healthy: healthyServices,
|
||||
degraded: degradedServices,
|
||||
} : undefined,
|
||||
resources: (cpuSamples > 0 || memorySamples > 0) ? {
|
||||
avgCpu: cpuSamples > 0 ? totalCpu / cpuSamples : null,
|
||||
avgMemory: memorySamples > 0 ? totalMemory / memorySamples : null,
|
||||
} : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const summary = createMemo(stats);
|
||||
|
||||
const hostSublabel = () => {
|
||||
const hosts = summary().hosts;
|
||||
const parts = [`${hosts.online} online`];
|
||||
if (hosts.degraded > 0) {
|
||||
parts.push(`${hosts.degraded} degraded`);
|
||||
}
|
||||
if (hosts.offline > 0) {
|
||||
parts.push(`${hosts.offline} offline`);
|
||||
}
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const servicesSublabel = () => {
|
||||
const services = summary().services;
|
||||
if (!services) return '';
|
||||
const parts = [`${services.healthy} healthy`];
|
||||
if (services.degraded > 0) {
|
||||
parts.push(`${services.degraded} degraded`);
|
||||
}
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const resourceValue = () => {
|
||||
const avgCpu = summary().resources?.avgCpu ?? null;
|
||||
return avgCpu !== null ? Math.round(avgCpu) : '—';
|
||||
};
|
||||
|
||||
const resourceSublabel = () => {
|
||||
const avgMemory = summary().resources?.avgMemory ?? null;
|
||||
if (avgMemory === null) return undefined;
|
||||
return `${Math.round(avgMemory)}% mem`;
|
||||
};
|
||||
|
||||
const resourceVariant = (): StatCardProps['variant'] => {
|
||||
const avgCpu = summary().resources?.avgCpu ?? null;
|
||||
if (avgCpu === null) return 'info';
|
||||
if (avgCpu > 80) return 'error';
|
||||
if (avgCpu > 60) return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const isActive = (type: string, value: string) => {
|
||||
return props.activeFilter?.type === type && props.activeFilter?.value === value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Overview
|
||||
</h2>
|
||||
<Show when={props.activeFilter}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onFilterChange?.(null)}
|
||||
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{/* Hosts */}
|
||||
<StatCard
|
||||
label="Total Hosts"
|
||||
value={summary().hosts.total}
|
||||
sublabel={hostSublabel()}
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
<Show when={summary().hosts.degraded > 0}>
|
||||
<StatCard
|
||||
label="Degraded Hosts"
|
||||
value={summary().hosts.degraded}
|
||||
variant="warning"
|
||||
onClick={() => props.onFilterChange?.({ type: 'host-status', value: 'degraded' })}
|
||||
isActive={isActive('host-status', 'degraded')}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={summary().hosts.offline > 0}>
|
||||
<StatCard
|
||||
label="Offline Hosts"
|
||||
value={summary().hosts.offline}
|
||||
variant="error"
|
||||
onClick={() => props.onFilterChange?.({ type: 'host-status', value: 'offline' })}
|
||||
isActive={isActive('host-status', 'offline')}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Containers */}
|
||||
<StatCard
|
||||
label="Running"
|
||||
value={summary().containers.running}
|
||||
sublabel={`of ${summary().containers.total} containers`}
|
||||
variant="success"
|
||||
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'running' })}
|
||||
isActive={isActive('container-state', 'running')}
|
||||
/>
|
||||
|
||||
<Show when={summary().containers.stopped > 0}>
|
||||
<StatCard
|
||||
label="Stopped"
|
||||
value={summary().containers.stopped}
|
||||
variant="warning"
|
||||
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'stopped' })}
|
||||
isActive={isActive('container-state', 'stopped')}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={summary().containers.error > 0}>
|
||||
<StatCard
|
||||
label="Error"
|
||||
value={summary().containers.error}
|
||||
variant="error"
|
||||
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'error' })}
|
||||
isActive={isActive('container-state', 'error')}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Services */}
|
||||
<Show when={summary().services}>
|
||||
<StatCard
|
||||
label="Services"
|
||||
value={summary().services!.total}
|
||||
sublabel={servicesSublabel()}
|
||||
variant={summary().services!.degraded > 0 ? 'warning' : 'success'}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Resources */}
|
||||
<Show when={summary().resources}>
|
||||
<StatCard
|
||||
label="Avg CPU"
|
||||
value={resourceValue()}
|
||||
sublabel={resourceSublabel()}
|
||||
variant={resourceVariant()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
331
frontend-modern/src/components/Docker/DockerTree.tsx
Normal file
331
frontend-modern/src/components/Docker/DockerTree.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { Component, For, Show } from 'solid-js';
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerHost,
|
||||
DockerService,
|
||||
DockerTask,
|
||||
} from '@/types/api';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
|
||||
export type DockerTreeSelection =
|
||||
| { type: 'host'; hostId: string; id: string }
|
||||
| { type: 'service'; hostId: string; id: string }
|
||||
| { type: 'task'; hostId: string; serviceKey: string; id: string }
|
||||
| { type: 'container'; hostId: string; id: string };
|
||||
|
||||
export interface DockerTreeTaskEntry {
|
||||
nodeId: string;
|
||||
task: DockerTask;
|
||||
}
|
||||
|
||||
export interface DockerTreeServiceEntry {
|
||||
key: string;
|
||||
service: DockerService;
|
||||
tasks: DockerTreeTaskEntry[];
|
||||
}
|
||||
|
||||
export interface DockerTreeContainerEntry {
|
||||
nodeId: string;
|
||||
container: DockerContainer;
|
||||
}
|
||||
|
||||
export interface DockerTreeHostEntry {
|
||||
host: DockerHost;
|
||||
hostId: string;
|
||||
containers: DockerContainer[];
|
||||
services: DockerTreeServiceEntry[];
|
||||
standaloneContainers: DockerTreeContainerEntry[];
|
||||
}
|
||||
|
||||
interface ExpandState {
|
||||
isExpanded: () => boolean;
|
||||
toggle: () => void;
|
||||
setExpanded: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface DockerTreeProps {
|
||||
hosts: DockerTreeHostEntry[];
|
||||
selected?: DockerTreeSelection | null;
|
||||
onSelect?: (selection: DockerTreeSelection) => void;
|
||||
getHostState: (hostId: string) => ExpandState;
|
||||
getServiceState: (serviceKey: string) => ExpandState;
|
||||
}
|
||||
|
||||
export const DockerTree: Component<DockerTreeProps> = (props) => {
|
||||
const isNodeSelected = (node: DockerTreeSelection) => {
|
||||
const current = props.selected;
|
||||
if (!current) return false;
|
||||
return (
|
||||
current.type === node.type &&
|
||||
current.hostId === node.hostId &&
|
||||
current.id === node.id
|
||||
);
|
||||
};
|
||||
|
||||
const hostDisplayName = (host: DockerHost) =>
|
||||
host.displayName || host.hostname || host.id || 'Unknown host';
|
||||
|
||||
const hostStatusVariant = (host: DockerHost) => {
|
||||
const status = host.status?.toLowerCase() ?? 'unknown';
|
||||
if (status === 'online') {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
if (
|
||||
status === 'offline' ||
|
||||
status === 'error' ||
|
||||
status === 'down' ||
|
||||
status === 'unreachable'
|
||||
) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
return 'bg-yellow-500';
|
||||
};
|
||||
|
||||
const taskStatusVariant = (task: DockerTask) => {
|
||||
const state = task.currentState?.toLowerCase() ?? '';
|
||||
if (state === 'running') return 'bg-green-500';
|
||||
if (state === 'failed' || state === 'error') return 'bg-red-500';
|
||||
return 'bg-yellow-500';
|
||||
};
|
||||
|
||||
const describeTaskLabel = (task: DockerTask) => {
|
||||
if (task.slot !== undefined && task.slot !== null) {
|
||||
return `${task.containerName || task.containerId || 'Task'}:${task.slot}`;
|
||||
}
|
||||
return task.containerName || task.containerId || task.id?.slice(0, 12) || 'Task';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="none"
|
||||
class="bg-white dark:bg-slate-900/60 p-2"
|
||||
>
|
||||
<nav class="space-y-1.5 text-xs">
|
||||
<For each={props.hosts}>
|
||||
{(entry) => {
|
||||
const hostState = props.getHostState(entry.hostId);
|
||||
|
||||
const selectHost = () => {
|
||||
hostState.setExpanded(true);
|
||||
props.onSelect?.({
|
||||
type: 'host',
|
||||
hostId: entry.hostId,
|
||||
id: entry.hostId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-start gap-1.5">
|
||||
{/*
|
||||
Small expand/collapse control for the host section. We avoid re-rendering
|
||||
the entire row by leaving the host summary text in a separate button.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hostState.toggle()}
|
||||
aria-label={
|
||||
hostState.isExpanded() ? 'Collapse host section' : 'Expand host section'
|
||||
}
|
||||
class="mt-0 h-4 w-4 flex items-center justify-center rounded border border-transparent text-slate-500 hover:text-sky-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500"
|
||||
>
|
||||
<svg
|
||||
class={`h-3 w-3 transition-transform duration-150 ${
|
||||
hostState.isExpanded() ? 'rotate-90' : ''
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 4l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectHost}
|
||||
class={`flex w-full items-center gap-2 truncate rounded border border-transparent px-1.5 py-0.5 text-left text-[12px] font-medium transition-colors ${
|
||||
isNodeSelected({ type: 'host', hostId: entry.hostId, id: entry.hostId })
|
||||
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`h-2 w-2 flex-shrink-0 rounded-full ${hostStatusVariant(entry.host)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate text-slate-700 dark:text-slate-100">{hostDisplayName(entry.host)}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={hostState.isExpanded()}>
|
||||
<div class="space-y-1 border-l border-slate-200 pl-2 dark:border-slate-700">
|
||||
<Show when={entry.services.length > 0}>
|
||||
<div class="space-y-0.5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-tight text-slate-400 dark:text-slate-500">
|
||||
Services
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<For each={entry.services}>
|
||||
{(serviceEntry) => {
|
||||
const serviceState = props.getServiceState(serviceEntry.key);
|
||||
|
||||
const selectService = () => {
|
||||
hostState.setExpanded(true);
|
||||
props.onSelect?.({
|
||||
type: 'service',
|
||||
hostId: entry.hostId,
|
||||
id: serviceEntry.key,
|
||||
});
|
||||
};
|
||||
|
||||
const selectTask = (taskNodeId: string) => {
|
||||
hostState.setExpanded(true);
|
||||
serviceState.setExpanded(true);
|
||||
props.onSelect?.({
|
||||
type: 'task',
|
||||
hostId: entry.hostId,
|
||||
serviceKey: serviceEntry.key,
|
||||
id: taskNodeId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-start gap-1.5">
|
||||
<Show when={serviceEntry.tasks.length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
serviceState.isExpanded()
|
||||
? 'Collapse task list'
|
||||
: 'Expand task list'
|
||||
}
|
||||
class="mt-0.5 h-3.5 w-3.5 flex items-center justify-center rounded border border-transparent text-slate-400 hover:text-sky-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
serviceState.toggle();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class={`h-3 w-3 transition-transform duration-150 ${
|
||||
serviceState.isExpanded() ? 'rotate-90' : ''
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 4l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectService}
|
||||
class={`flex min-h-[24px] w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-[11px] transition-colors ${
|
||||
isNodeSelected({
|
||||
type: 'service',
|
||||
hostId: entry.hostId,
|
||||
id: serviceEntry.key,
|
||||
})
|
||||
? 'bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span class="truncate text-slate-600 dark:text-slate-200">
|
||||
{serviceEntry.service.name || serviceEntry.service.id}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={serviceEntry.tasks.length > 0 && serviceState.isExpanded()}>
|
||||
<div class="space-y-0.5 border-l border-slate-200 pl-2 dark:border-slate-700">
|
||||
<For each={serviceEntry.tasks}>
|
||||
{(taskEntry) => {
|
||||
const task = taskEntry.task;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTask(taskEntry.nodeId)}
|
||||
class={`flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-[11px] transition-colors ${
|
||||
isNodeSelected({
|
||||
type: 'task',
|
||||
hostId: entry.hostId,
|
||||
serviceKey: serviceEntry.key,
|
||||
id: taskEntry.nodeId,
|
||||
})
|
||||
? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
class={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${taskStatusVariant(task)}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate text-slate-500 dark:text-slate-300">
|
||||
{describeTaskLabel(task)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={entry.standaloneContainers.length > 0}>
|
||||
<div class="space-y-0.5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-tight text-slate-400 dark:text-slate-500">
|
||||
Standalone Containers
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<For each={entry.standaloneContainers}>
|
||||
{(containerEntry) => {
|
||||
const container = containerEntry.container;
|
||||
const selectContainer = () => {
|
||||
hostState.setExpanded(true);
|
||||
props.onSelect?.({
|
||||
type: 'container',
|
||||
hostId: entry.hostId,
|
||||
id: containerEntry.nodeId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectContainer}
|
||||
class={`flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-[11px] transition-colors ${
|
||||
isNodeSelected({
|
||||
type: 'container',
|
||||
hostId: entry.hostId,
|
||||
id: containerEntry.nodeId,
|
||||
})
|
||||
? 'bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-200'
|
||||
: 'hover:bg-slate-100 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<span class="truncate text-slate-600 dark:text-slate-200">
|
||||
{container.name || container.id || 'Unknown container'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
1240
frontend-modern/src/components/Docker/DockerUnifiedTable.tsx
Normal file
1240
frontend-modern/src/components/Docker/DockerUnifiedTable.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,9 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
|
|||
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
|
||||
export const FirstRunSetup: Component = () => {
|
||||
export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: boolean }> = (
|
||||
props,
|
||||
) => {
|
||||
const [username, setUsername] = createSignal('admin');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
||||
|
|
@ -110,6 +112,7 @@ export const FirstRunSetup: Component = () => {
|
|||
username: username(),
|
||||
password: finalPassword,
|
||||
apiToken: token,
|
||||
force: props.force ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -214,6 +217,20 @@ IMPORTANT: Keep these credentials secure!
|
|||
return (
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-2xl">
|
||||
<Show when={props.showLegacyBanner}>
|
||||
<div class="mb-6 rounded-xl border border-amber-300 bg-amber-50/80 dark:border-amber-700 dark:bg-amber-900/40 p-4 text-amber-900 dark:text-amber-100">
|
||||
<h2 class="text-lg font-semibold mb-2">Authentication forced off via environment</h2>
|
||||
<p class="text-sm mb-3">
|
||||
Pulse detected the legacy <code class="font-mono text-xs">DISABLE_AUTH</code> flag. Complete the
|
||||
setup below to rotate credentials, then remove the environment variable and restart Pulse.
|
||||
</p>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-200">
|
||||
If you still need a temporary bypass after rotating, create <code class="font-mono text-xs">.auth_recovery</code>
|
||||
in the Pulse data directory and restart. Remove the file and restart again once you regain access.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Logo/Header */}
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ interface SecurityStatus {
|
|||
oidcIssuer?: string;
|
||||
oidcClientId?: string;
|
||||
oidcEnvOverrides?: Record<string, boolean>;
|
||||
disabled?: boolean;
|
||||
deprecatedDisableAuth?: boolean;
|
||||
message?: string;
|
||||
apiTokenConfigured?: boolean;
|
||||
}
|
||||
|
||||
export const Login: Component<LoginProps> = (props) => {
|
||||
|
|
@ -261,6 +265,10 @@ export const Login: Component<LoginProps> = (props) => {
|
|||
// Debug logging
|
||||
console.log('[Login] Render - loadingAuth:', loadingAuth(), 'authStatus:', authStatus());
|
||||
|
||||
const legacyDisableAuth = () => authStatus()?.deprecatedDisableAuth === true;
|
||||
const showFirstRunSetup = () =>
|
||||
authStatus()?.hasAuthentication === false || legacyDisableAuth();
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!loadingAuth()}
|
||||
|
|
@ -274,7 +282,7 @@ export const Login: Component<LoginProps> = (props) => {
|
|||
}
|
||||
>
|
||||
<Show
|
||||
when={authStatus()?.hasAuthentication === false}
|
||||
when={showFirstRunSetup()}
|
||||
fallback={
|
||||
<LoginForm
|
||||
{...{
|
||||
|
|
@ -304,7 +312,10 @@ export const Login: Component<LoginProps> = (props) => {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<FirstRunSetup />
|
||||
<FirstRunSetup
|
||||
force={legacyDisableAuth()}
|
||||
showLegacyBanner={legacyDisableAuth()}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -43,20 +43,6 @@ export const DockerAgents: Component = () => {
|
|||
const [latestRecord, setLatestRecord] = createSignal<APITokenRecord | null>(null);
|
||||
const [tokenName, setTokenName] = createSignal('');
|
||||
|
||||
const [expandedHosts, setExpandedHosts] = createSignal<Set<string>>(new Set());
|
||||
const isHostExpanded = (id: string) => expandedHosts().has(id);
|
||||
const toggleHostExpanded = (id: string) => {
|
||||
setExpandedHosts((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const pulseUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const protocol = window.location.protocol;
|
||||
|
|
@ -818,19 +804,35 @@ WantedBy=multi-user.target`;
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Reporting Docker hosts ({dockerHosts().length})
|
||||
</h3>
|
||||
<Show when={hiddenCount() > 0}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden(!showHidden())}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Reporting Docker hosts ({dockerHosts().length})
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Use this list to enroll or retire agents. For live health and troubleshooting, open the Docker monitoring view.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<a
|
||||
href="/docker"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-600/60 dark:bg-blue-900/30 dark:text-blue-200 dark:hover:bg-blue-900/50"
|
||||
>
|
||||
{showHidden() ? 'Hide' : 'Show'} hidden ({hiddenCount()})
|
||||
</button>
|
||||
</Show>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Open Docker monitoring
|
||||
</a>
|
||||
<Show when={hiddenCount() > 0}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden(!showHidden())}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
{showHidden() ? 'Hide' : 'Show'} hidden ({hiddenCount()})
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
|
|
@ -862,110 +864,58 @@ WantedBy=multi-user.target`;
|
|||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Host</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Status</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Containers</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Docker Version</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Agent Version</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Agent & Docker</th>
|
||||
<th class="text-left py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Last Seen</th>
|
||||
<th class="py-3 px-4" />
|
||||
<th class="text-right py-3 px-4 font-medium text-gray-600 dark:text-gray-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={dockerHosts()}>
|
||||
{(host) => {
|
||||
const isOnline = host.status?.toLowerCase() === 'online';
|
||||
const runningContainers = host.containers?.filter(c => c.state?.toLowerCase() === 'running').length || 0;
|
||||
const displayName = getDisplayName(host);
|
||||
const commandStatus = host.command?.status ?? null;
|
||||
const commandInProgress = commandStatus === 'queued' || commandStatus === 'dispatched' || commandStatus === 'acknowledged';
|
||||
const commandInProgress =
|
||||
commandStatus === 'queued' ||
|
||||
commandStatus === 'dispatched' ||
|
||||
commandStatus === 'acknowledged';
|
||||
const commandFailed = commandStatus === 'failed';
|
||||
const commandCompleted = commandStatus === 'completed';
|
||||
const services = host.services ?? [];
|
||||
const serviceCount = services.length;
|
||||
const taskCount = host.tasks?.length ?? 0;
|
||||
const swarm = host.swarm;
|
||||
const expanded = isHostExpanded(host.id);
|
||||
const unhealthyServiceCount = services.filter(service => {
|
||||
const desired = service.desiredTasks ?? 0;
|
||||
const running = service.runningTasks ?? 0;
|
||||
return desired > 0 && running < desired;
|
||||
}).length;
|
||||
const missingReplicaCount = services.reduce((total, service) => {
|
||||
const desired = service.desiredTasks ?? 0;
|
||||
const running = service.runningTasks ?? 0;
|
||||
if (desired <= 0 || running >= desired) {
|
||||
return total;
|
||||
}
|
||||
return total + (desired - running);
|
||||
}, 0);
|
||||
const offlineActionLabel = commandFailed
|
||||
? 'Force remove host'
|
||||
: host.pendingUninstall
|
||||
? 'Clean up pending host'
|
||||
: 'Remove offline host';
|
||||
const tokenRevoked = typeof host.tokenRevokedAt === 'number';
|
||||
const tokenRevokedRelative = tokenRevoked ? formatRelativeTime(host.tokenRevokedAt!) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr class={`${isOnline ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800/50 opacity-60'}`}>
|
||||
<td class="py-3 px-4">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{host.displayName}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{host.hostname}</div>
|
||||
<Show when={host.os || host.architecture}>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{host.os}
|
||||
<Show when={host.os && host.architecture}>
|
||||
<span class="mx-1">•</span>
|
||||
</Show>
|
||||
{host.architecture}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={swarm || serviceCount > 0}>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Show when={swarm}>
|
||||
<span
|
||||
class={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide ${
|
||||
swarm?.nodeRole === 'manager'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
|
||||
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{swarm?.nodeRole ?? 'unknown'}
|
||||
</span>
|
||||
<span>scope: {swarm?.scope ?? 'node'}</span>
|
||||
</Show>
|
||||
<span>services: {serviceCount}</span>
|
||||
<span>tasks: {taskCount}</span>
|
||||
<Show when={serviceCount > 0}>
|
||||
<span
|
||||
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
|
||||
unhealthyServiceCount > 0
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||
}`}
|
||||
title={
|
||||
unhealthyServiceCount > 0
|
||||
? `${unhealthyServiceCount} service(s) missing ${missingReplicaCount} replica(s)`
|
||||
: 'All reported services are meeting their desired replica count'
|
||||
}
|
||||
>
|
||||
{unhealthyServiceCount > 0
|
||||
? `${unhealthyServiceCount} degraded`
|
||||
: 'All healthy'}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={swarm || serviceCount > 0 || taskCount > 0}>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-xs font-semibold text-blue-600 hover:text-blue-700 focus:outline-none disabled:opacity-50"
|
||||
onClick={() => toggleHostExpanded(host.id)}
|
||||
>
|
||||
{expanded ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<tr class={`${isOnline ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800/50 opacity-60'}`}>
|
||||
<td class="py-3 px-4 align-top">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{displayName}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{host.hostname}</div>
|
||||
<Show when={host.os || host.architecture}>
|
||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{host.os}
|
||||
<Show when={host.os && host.architecture}>
|
||||
<span class="mx-1">•</span>
|
||||
</Show>
|
||||
{host.architecture}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.hidden}>
|
||||
<span class="mt-2 inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||||
Hidden
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tokenRevoked}>
|
||||
<div class="mt-2 text-xs text-amber-600 dark:text-amber-300">
|
||||
Token revoked {tokenRevokedRelative}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-3 px-4 align-top">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
isOnline
|
||||
|
|
@ -977,7 +927,7 @@ WantedBy=multi-user.target`;
|
|||
</span>
|
||||
<Show when={commandInProgress}>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Stopping
|
||||
|
|
@ -985,7 +935,7 @@ WantedBy=multi-user.target`;
|
|||
</Show>
|
||||
<Show when={commandFailed}>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728" />
|
||||
</svg>
|
||||
Failed
|
||||
|
|
@ -993,38 +943,39 @@ WantedBy=multi-user.target`;
|
|||
</Show>
|
||||
<Show when={commandCompleted}>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Stopped
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={host.pendingUninstall}>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Stop command queued; waiting for acknowledgement.
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-gray-900 dark:text-gray-100">
|
||||
{runningContainers} / {host.containers?.length || 0}
|
||||
<td class="py-3 px-4 align-top">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{host.dockerVersion ? `Docker ${host.dockerVersion}` : 'Docker version unavailable'}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">running</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-gray-900 dark:text-gray-100">{host.dockerVersion || '—'}</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="text-gray-900 dark:text-gray-100">{host.agentVersion || '—'}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
every {host.intervalSeconds || 0}s
|
||||
Agent {host.agentVersion ? `v${host.agentVersion}` : 'not reporting'}
|
||||
<Show when={host.intervalSeconds}>
|
||||
<span> (every {host.intervalSeconds}s)</span>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="py-3 px-4 align-top">
|
||||
<div class="text-gray-900 dark:text-gray-100">
|
||||
{host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{host.lastSeen ? formatAbsoluteTime(host.lastSeen) : '—'}
|
||||
{host.lastSeen ? formatAbsoluteTime(host.lastSeen) : 'Awaiting first report'}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<td class="py-3 px-4 text-right align-top">
|
||||
<Show
|
||||
when={host.hidden}
|
||||
fallback={
|
||||
|
|
@ -1032,39 +983,42 @@ WantedBy=multi-user.target`;
|
|||
<Show when={commandInProgress || commandCompleted}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="text-xs font-semibold text-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled
|
||||
>
|
||||
{commandCompleted ? 'Cleaning up…' : 'Stopping…'}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={commandFailed} fallback={
|
||||
<Show
|
||||
when={!isOnline}
|
||||
fallback={
|
||||
<Show
|
||||
when={commandFailed}
|
||||
fallback={
|
||||
<Show
|
||||
when={!isOnline}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => openRemoveModal(host)}
|
||||
disabled={isRemovingHost(host.id)}
|
||||
>
|
||||
{isRemovingHost(host.id) ? 'Working…' : 'Remove'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-red-600 hover:text-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => openRemoveModal(host)}
|
||||
class="text-xs font-semibold text-blue-600 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => handleCleanupOfflineHost(host.id, displayName)}
|
||||
disabled={isRemovingHost(host.id)}
|
||||
>
|
||||
{isRemovingHost(host.id) ? 'Working…' : 'Remove'}
|
||||
{isRemovingHost(host.id) ? 'Cleaning up…' : offlineActionLabel}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-blue-600 hover:text-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => handleCleanupOfflineHost(host.id, displayName)}
|
||||
disabled={isRemovingHost(host.id)}
|
||||
>
|
||||
{isRemovingHost(host.id) ? 'Cleaning up…' : offlineActionLabel}
|
||||
</button>
|
||||
</Show>
|
||||
}>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-red-600 hover:text-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="text-xs font-semibold text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => handleCleanupOfflineHost(host.id, displayName)}
|
||||
disabled={isRemovingHost(host.id)}
|
||||
>
|
||||
|
|
@ -1076,7 +1030,7 @@ WantedBy=multi-user.target`;
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-blue-600 hover:text-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="text-xs font-semibold text-blue-600 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => handleUnhideHost(host.id, displayName)}
|
||||
disabled={isRemovingHost(host.id)}
|
||||
>
|
||||
|
|
@ -1085,173 +1039,8 @@ WantedBy=multi-user.target`;
|
|||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
<Show when={expanded}>
|
||||
<tr class="bg-gray-50 dark:bg-gray-800/40">
|
||||
<td colSpan={7} class="px-4 py-4">
|
||||
<div class="space-y-4 text-sm">
|
||||
<Show when={swarm}>
|
||||
<div class="rounded-lg border border-sky-100 bg-sky-50 px-3 py-2 text-xs text-sky-900 dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-100">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div><span class="font-semibold">Role:</span> {swarm?.nodeRole ?? 'unknown'}</div>
|
||||
<div><span class="font-semibold">Scope:</span> {swarm?.scope ?? 'node'}</div>
|
||||
<Show when={swarm?.clusterName || swarm?.clusterId}>
|
||||
<div><span class="font-semibold">Cluster:</span> {swarm?.clusterName || swarm?.clusterId}</div>
|
||||
</Show>
|
||||
<Show when={swarm?.error}>
|
||||
<div class="text-red-600 dark:text-red-300"><span class="font-semibold">Error:</span> {swarm?.error}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={serviceCount > 0}>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Services ({serviceCount})</h4>
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<For each={host.services}>
|
||||
{(service) => {
|
||||
const desired = service.desiredTasks ?? 0;
|
||||
const running = service.runningTasks ?? 0;
|
||||
const completed = service.completedTasks;
|
||||
const missing = desired > running ? desired - running : 0;
|
||||
const statusLabel =
|
||||
desired <= 0
|
||||
? 'Idle'
|
||||
: missing === 0
|
||||
? 'Healthy'
|
||||
: missing >= desired
|
||||
? 'Down'
|
||||
: 'Degraded';
|
||||
const statusColor =
|
||||
missing === 0
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
|
||||
|
||||
return (
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100 truncate">{service.name || service.id}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={service.mode}>
|
||||
<span class="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{service.mode}
|
||||
</span>
|
||||
</Show>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${statusColor}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>
|
||||
Tasks: {running} / {desired}
|
||||
<Show when={missing > 0}>
|
||||
<span class="ml-1 text-red-600 dark:text-red-300">(-{missing})</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={completed !== undefined}>
|
||||
<div>Completed: {completed}</div>
|
||||
</Show>
|
||||
<Show when={service.stack}>
|
||||
<div>Stack: {service.stack}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={service.image}>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 truncate">{service.image}</div>
|
||||
</Show>
|
||||
<Show when={(service.endpointPorts?.length ?? 0) > 0}>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Ports:{' '}
|
||||
<For each={service.endpointPorts}>
|
||||
{(port, index) => (
|
||||
<span>
|
||||
{index() > 0 && ', '}
|
||||
{port.publishedPort ?? '—'} → {port.targetPort ?? '—'} {port.protocol ? port.protocol.toUpperCase() : ''}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={service.updateStatus?.state || service.updateStatus?.message}>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Update: {service.updateStatus?.state || 'pending'}
|
||||
<Show when={service.updateStatus?.message}>
|
||||
<span class="ml-1">({service.updateStatus?.message})</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={taskCount > 0}>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Tasks ({taskCount})</h4>
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-semibold">Service</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Slot</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">State</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Container</th>
|
||||
<th class="px-3 py-2 text-left font-semibold">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={host.tasks}>
|
||||
{(task) => (
|
||||
<tr class="bg-white dark:bg-gray-900">
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{task.serviceName || task.serviceId || '—'}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{task.slot ?? '—'}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span
|
||||
class={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
|
||||
task.currentState?.toLowerCase() === 'running'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{task.currentState ?? 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">
|
||||
{task.containerName || task.containerId?.slice(0, 12) || '—'}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">
|
||||
{task.updatedAt
|
||||
? formatRelativeTime(task.updatedAt)
|
||||
: task.startedAt
|
||||
? formatRelativeTime(task.startedAt)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={serviceCount === 0 && taskCount === 0}>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
No Swarm services or tasks reported yet.
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error(
|
||||
'Pulse detected a legacy DISABLE_AUTH flag. Remove that environment flag and restart Pulse before enabling security here.',
|
||||
);
|
||||
}
|
||||
const error = await response.text();
|
||||
throw new Error(error || 'Failed to setup security');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -687,6 +687,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
null,
|
||||
);
|
||||
const [showQuickSecuritySetup, setShowQuickSecuritySetup] = createSignal(false);
|
||||
const authDisabledByEnv = createMemo(() => Boolean(securityStatus()?.deprecatedDisableAuth));
|
||||
const [showQuickSecurityWizard, setShowQuickSecurityWizard] = createSignal(false);
|
||||
|
||||
const formatRelativeTime = (timestamp?: number) => {
|
||||
|
|
@ -973,6 +974,12 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (authDisabledByEnv() && showQuickSecuritySetup()) {
|
||||
setShowQuickSecuritySetup(false);
|
||||
}
|
||||
});
|
||||
|
||||
const updateDiscoveredNodesFromServers = (
|
||||
servers: RawDiscoveredServer[] | undefined | null,
|
||||
options: { merge?: boolean } = {},
|
||||
|
|
@ -4228,23 +4235,45 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</svg>
|
||||
</div>
|
||||
<SectionHeader title="Authentication disabled" size="sm" class="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQuickSecuritySetup(!showQuickSecuritySetup())}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 text-amber-800 bg-amber-100/50 hover:bg-amber-100 transition-colors dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/40 whitespace-nowrap"
|
||||
<Show
|
||||
when={!authDisabledByEnv()}
|
||||
fallback={
|
||||
<span class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-amber-300 text-amber-800 bg-amber-100/60 dark:border-amber-700 dark:text-amber-100 dark:bg-amber-900/40 whitespace-nowrap">
|
||||
Controlled by DISABLE_AUTH
|
||||
</span>
|
||||
}
|
||||
>
|
||||
Setup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQuickSecuritySetup(!showQuickSecuritySetup())}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 text-amber-800 bg-amber-100/50 hover:bg-amber-100 transition-colors dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/40 whitespace-nowrap"
|
||||
>
|
||||
Setup
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300 mb-4">
|
||||
DISABLE_AUTH is set, or auth isn't configured yet. Set up password authentication to protect your Pulse instance.
|
||||
<Show
|
||||
when={authDisabledByEnv()}
|
||||
fallback={
|
||||
<>
|
||||
Authentication is currently disabled. Set up password authentication to protect your Pulse instance.
|
||||
</>
|
||||
}
|
||||
>
|
||||
Authentication settings are locked by the legacy{' '}
|
||||
<code class="font-mono text-xs text-amber-800 dark:text-amber-200">
|
||||
DISABLE_AUTH
|
||||
</code>{' '}
|
||||
environment variable. Remove it from your deployment and restart Pulse before enabling security from this page.
|
||||
</Show>
|
||||
</p>
|
||||
|
||||
<Show when={showQuickSecuritySetup()}>
|
||||
<Show when={showQuickSecuritySetup() && !authDisabledByEnv()}>
|
||||
<QuickSecuritySetup
|
||||
onConfigured={() => {
|
||||
setShowQuickSecuritySetup(false);
|
||||
|
|
@ -4297,13 +4326,22 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
>
|
||||
Change password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQuickSecurityWizard(!showQuickSecurityWizard())}
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
<Show
|
||||
when={!authDisabledByEnv()}
|
||||
fallback={
|
||||
<span class="px-4 py-2 text-sm font-semibold border border-amber-300 text-amber-800 bg-amber-50 dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 rounded-lg">
|
||||
Remove DISABLE_AUTH to rotate credentials
|
||||
</span>
|
||||
}
|
||||
>
|
||||
Rotate credentials
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQuickSecurityWizard(!showQuickSecurityWizard())}
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Rotate credentials
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex-1"></div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">User:</span>{' '}
|
||||
|
|
@ -4311,7 +4349,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={showQuickSecurityWizard()}>
|
||||
<Show when={!authDisabledByEnv() && showQuickSecurityWizard()}>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<QuickSecuritySetup
|
||||
mode="rotate"
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function Card(props: CardProps) {
|
|||
);
|
||||
const [local, rest] = splitProps(merged, ['tone', 'padding', 'hoverable', 'border', 'class']);
|
||||
|
||||
const baseClass = 'rounded-lg shadow-sm transition-shadow duration-200';
|
||||
const baseClass = 'rounded-lg shadow-sm transition-shadow duration-200 max-w-full';
|
||||
const toneClass = toneClassMap[local.tone];
|
||||
const paddingClass = paddingClassMap[local.padding];
|
||||
const borderClass = local.border ? 'border border-gray-200 dark:border-gray-700' : '';
|
||||
|
|
|
|||
|
|
@ -5,33 +5,51 @@ interface ScrollableTableProps {
|
|||
children: JSX.Element;
|
||||
class?: string;
|
||||
minWidth?: string;
|
||||
persistKey?: string;
|
||||
}
|
||||
|
||||
const scrollPositions = new Map<string, number>();
|
||||
|
||||
export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
|
||||
const [showLeftFade, setShowLeftFade] = createSignal(false);
|
||||
const [showRightFade, setShowRightFade] = createSignal(false);
|
||||
let scrollContainer: HTMLDivElement | undefined;
|
||||
|
||||
const checkScroll = () => {
|
||||
const checkScroll = (scrollLeftValue?: number) => {
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
|
||||
const scrollLeft = scrollLeftValue ?? scrollContainer.scrollLeft;
|
||||
const { scrollWidth, clientWidth } = scrollContainer;
|
||||
setShowLeftFade(scrollLeft > 0);
|
||||
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
checkScroll();
|
||||
window.addEventListener('resize', checkScroll);
|
||||
const initialLeft = props.persistKey ? scrollPositions.get(props.persistKey) ?? 0 : 0;
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollLeft = initialLeft;
|
||||
}
|
||||
checkScroll(initialLeft);
|
||||
|
||||
const resizeHandler = () => checkScroll();
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.addEventListener('scroll', checkScroll);
|
||||
return () => scrollContainer?.removeEventListener('scroll', checkScroll);
|
||||
const handler = () => {
|
||||
if (!scrollContainer) return;
|
||||
const left = scrollContainer.scrollLeft;
|
||||
if (props.persistKey) {
|
||||
scrollPositions.set(props.persistKey, left);
|
||||
}
|
||||
checkScroll(left);
|
||||
};
|
||||
scrollContainer.addEventListener('scroll', handler, { passive: true });
|
||||
return () => scrollContainer?.removeEventListener('scroll', handler);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -51,7 +69,9 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
|
|||
<style>{`
|
||||
.overflow-x-auto::-webkit-scrollbar { display: none; }
|
||||
`}</style>
|
||||
<div style={{ 'min-width': props.minWidth || 'auto' }}>{props.children}</div>
|
||||
<div style={{ 'min-width': props.minWidth || 'auto' }}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right fade */}
|
||||
|
|
|
|||
22
frontend-modern/src/hooks/useDebouncedValue.ts
Normal file
22
frontend-modern/src/hooks/useDebouncedValue.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createSignal, createEffect, onCleanup, Accessor } from 'solid-js';
|
||||
|
||||
/**
|
||||
* Creates a debounced version of a signal value
|
||||
* @param value - The signal accessor to debounce
|
||||
* @param delay - Delay in milliseconds (default: 300ms)
|
||||
* @returns Debounced signal accessor
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: Accessor<T>, delay: number = 300): Accessor<T> {
|
||||
const [debouncedValue, setDebouncedValue] = createSignal<T>(value());
|
||||
|
||||
createEffect(() => {
|
||||
const currentValue = value();
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(() => currentValue);
|
||||
}, delay);
|
||||
|
||||
onCleanup(() => clearTimeout(timer));
|
||||
});
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
|
@ -136,7 +136,3 @@ export const useAlertsActivation = () => ({
|
|||
deactivate,
|
||||
snooze,
|
||||
});
|
||||
|
||||
// Initialize on module load
|
||||
refreshConfig();
|
||||
refreshActiveAlerts();
|
||||
|
|
|
|||
|
|
@ -207,10 +207,10 @@ export interface DockerTask {
|
|||
message?: string;
|
||||
containerId?: string;
|
||||
containerName?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
createdAt?: number | string; // Unix timestamp or ISO string
|
||||
updatedAt?: number | string; // Unix timestamp or ISO string
|
||||
startedAt?: number | string; // Unix timestamp or ISO string
|
||||
completedAt?: number | string; // Unix timestamp or ISO string
|
||||
}
|
||||
|
||||
export interface DockerSwarmInfo {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ export interface SecurityStatus {
|
|||
proxyAuthLogoutURL?: string;
|
||||
authUsername?: string;
|
||||
authLastModified?: string;
|
||||
disabled?: boolean; // legacy field (removed backend support)
|
||||
deprecatedDisableAuth?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ func min(a, b int) int {
|
|||
|
||||
// CheckAuth checks both basic auth and API token
|
||||
func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool {
|
||||
// Check proxy auth first if configured (even if DISABLE_AUTH is true)
|
||||
// Check proxy auth first if configured
|
||||
if cfg.ProxyAuthSecret != "" {
|
||||
if valid, username, _ := CheckProxyAuth(cfg, r); valid {
|
||||
// Set username in response header for frontend
|
||||
|
|
@ -198,7 +198,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
|||
}
|
||||
}
|
||||
|
||||
// Check for OIDC session cookie (even if DISABLE_AUTH is true)
|
||||
// Check for OIDC session cookie
|
||||
if cfg.OIDC != nil && cfg.OIDC.Enabled {
|
||||
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
|
||||
if ValidateSession(cookie.Value) {
|
||||
|
|
@ -212,13 +212,6 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
|||
}
|
||||
}
|
||||
|
||||
// If auth is explicitly disabled, allow all access
|
||||
// (but only after checking proxy and OIDC auth above)
|
||||
if cfg.DisableAuth {
|
||||
w.Header().Set("X-Auth-Disabled", "true")
|
||||
return true
|
||||
}
|
||||
|
||||
// If no auth is configured at all, allow access unless OIDC is enabled
|
||||
if cfg.AuthUser == "" && cfg.AuthPass == "" && !cfg.HasAPITokens() && cfg.ProxyAuthSecret == "" {
|
||||
if cfg.OIDC != nil && cfg.OIDC.Enabled {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ type DiagnosticsInfo struct {
|
|||
|
||||
// MemorySourceStat aggregates memory-source usage per instance.
|
||||
type MemorySourceStat struct {
|
||||
Instance string `json:"instance"`
|
||||
Instance string `json:"instance"`
|
||||
Source string `json:"source"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
|
|
@ -74,9 +74,9 @@ const diagnosticsCacheTTL = 45 * time.Second
|
|||
var (
|
||||
diagnosticsMetricsOnce sync.Once
|
||||
|
||||
diagnosticsCacheMu sync.RWMutex
|
||||
diagnosticsCache DiagnosticsInfo
|
||||
diagnosticsCacheTimestamp time.Time
|
||||
diagnosticsCacheMu sync.RWMutex
|
||||
diagnosticsCache DiagnosticsInfo
|
||||
diagnosticsCacheTimestamp time.Time
|
||||
|
||||
diagnosticsCacheHits = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: "pulse",
|
||||
|
|
@ -639,7 +639,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A
|
|||
}
|
||||
|
||||
diag := &APITokenDiagnostic{
|
||||
Enabled: cfg.APITokenEnabled && !cfg.DisableAuth,
|
||||
Enabled: cfg.APITokenEnabled,
|
||||
TokenCount: len(cfg.APITokens),
|
||||
}
|
||||
|
||||
|
|
@ -670,9 +670,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A
|
|||
diag.RecommendTokenSetup = len(cfg.APITokens) == 0
|
||||
diag.RecommendTokenRotation = envTokens || legacyToken
|
||||
|
||||
if cfg.DisableAuth {
|
||||
appendNote("Authentication is disabled (DISABLE_AUTH=1). Re-enable it to use per-agent API tokens.")
|
||||
} else if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 {
|
||||
if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 {
|
||||
appendNote("API token authentication is currently disabled. Enable it under Settings → Security so agents can use dedicated tokens.")
|
||||
} else if diag.RecommendTokenSetup {
|
||||
appendNote("No API tokens are configured. Open Settings → Security to generate dedicated tokens for each automation or agent.")
|
||||
|
|
|
|||
|
|
@ -294,47 +294,6 @@ func (r *Router) setupRoutes() {
|
|||
if req.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Check if auth is globally disabled
|
||||
if r.config.DisableAuth {
|
||||
// Even with auth disabled, check for OIDC sessions
|
||||
oidcCfg := r.ensureOIDCConfig()
|
||||
oidcUsername := ""
|
||||
if oidcCfg != nil && oidcCfg.Enabled {
|
||||
if cookie, err := req.Cookie("pulse_session"); err == nil && cookie.Value != "" {
|
||||
if ValidateSession(cookie.Value) {
|
||||
oidcUsername = GetSessionUsername(cookie.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Even with auth disabled, report API token status for API access
|
||||
apiTokenHint := r.config.PrimaryAPITokenHint()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"configured": false,
|
||||
"disabled": true,
|
||||
"message": "Authentication is disabled via DISABLE_AUTH environment variable",
|
||||
"apiTokenConfigured": r.config.HasAPITokens(),
|
||||
"apiTokenHint": apiTokenHint,
|
||||
"hasAuthentication": false,
|
||||
}
|
||||
|
||||
// Add OIDC info if available
|
||||
if oidcCfg != nil {
|
||||
response["oidcEnabled"] = oidcCfg.Enabled
|
||||
response["oidcIssuer"] = oidcCfg.IssuerURL
|
||||
response["oidcClientId"] = oidcCfg.ClientID
|
||||
response["oidcUsername"] = oidcUsername
|
||||
response["oidcLogoutURL"] = oidcCfg.LogoutURL
|
||||
if len(oidcCfg.EnvOverrides) > 0 {
|
||||
response["oidcEnvOverrides"] = oidcCfg.EnvOverrides
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for basic auth configuration
|
||||
// Check both environment variables and loaded config
|
||||
oidcCfg := r.ensureOIDCConfig()
|
||||
|
|
@ -446,6 +405,11 @@ func (r *Router) setupRoutes() {
|
|||
}
|
||||
}
|
||||
|
||||
if r.config.DisableAuthEnvDetected {
|
||||
status["deprecatedDisableAuth"] = true
|
||||
status["message"] = "DISABLE_AUTH is deprecated and no longer disables authentication. Remove the environment variable and restart Pulse to manage authentication from the UI."
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(status)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
|
|
@ -1172,31 +1136,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
// Check if we need authentication
|
||||
needsAuth := true
|
||||
|
||||
// Check if auth is globally disabled
|
||||
// BUT still check for API tokens if provided (for API access when auth is disabled)
|
||||
if r.config.DisableAuth {
|
||||
// Check if an API token was provided
|
||||
providedToken := req.Header.Get("X-API-Token")
|
||||
if providedToken == "" {
|
||||
providedToken = req.URL.Query().Get("token")
|
||||
}
|
||||
|
||||
// If a valid API token is provided, allow access even with DisableAuth
|
||||
if providedToken != "" && r.config.HasAPITokens() {
|
||||
if _, ok := r.config.ValidateAPIToken(providedToken); ok {
|
||||
needsAuth = false
|
||||
w.Header().Set("X-Auth-Method", "api-token")
|
||||
} else {
|
||||
http.Error(w, "Invalid API token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// No API token provided with DisableAuth - allow open access
|
||||
needsAuth = false
|
||||
w.Header().Set("X-Auth-Disabled", "true")
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery mechanism: Check if recovery mode is enabled
|
||||
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
|
||||
if _, err := os.Stat(recoveryFile); err == nil {
|
||||
|
|
@ -1336,10 +1275,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
// CSRF is only needed when using session-based auth
|
||||
// Only skip CSRF for initial setup when no auth is configured
|
||||
skipCSRF := false
|
||||
if (req.URL.Path == "/api/security/quick-setup" || req.URL.Path == "/api/security/apply-restart") &&
|
||||
r.config.AuthUser == "" && r.config.AuthPass == "" {
|
||||
// Only skip CSRF for initial setup and restart when no auth exists
|
||||
skipCSRF = true
|
||||
if req.URL.Path == "/api/security/quick-setup" || req.URL.Path == "/api/security/apply-restart" {
|
||||
if (r.config.AuthUser == "" && r.config.AuthPass == "") || r.config.DisableAuthEnvDetected {
|
||||
// Allow bootstrap or legacy recovery runs without CSRF token
|
||||
skipCSRF = true
|
||||
}
|
||||
}
|
||||
// Skip CSRF for setup-script-url endpoint (generates temporary tokens, not a state change)
|
||||
if req.URL.Path == "/api/setup-script-url" {
|
||||
|
|
@ -2272,7 +2212,7 @@ func (r *Router) handleState(w http.ResponseWriter, req *http.Request) {
|
|||
|
||||
log.Debug().Msg("[DEBUG] handleState: Before auth check")
|
||||
// Use standard auth check (supports both basic auth and API tokens) unless auth is disabled
|
||||
if !r.config.DisableAuth && !CheckAuth(r.config, w, req) {
|
||||
if !CheckAuth(r.config, w, req) {
|
||||
writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
|
||||
"Authentication required", nil)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ func newIntegrationServerWithConfig(t *testing.T, customize func(*config.Config)
|
|||
BackendPort: 7655,
|
||||
ConfigPath: tmpDir,
|
||||
DataPath: tmpDir,
|
||||
DisableAuth: true,
|
||||
DemoMode: false,
|
||||
AllowedOrigins: "*",
|
||||
ConcurrentPolling: true,
|
||||
|
|
@ -205,7 +204,6 @@ func TestProtectedEndpointsRequireAuthentication(t *testing.T) {
|
|||
}
|
||||
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.DisableAuth = false
|
||||
cfg.AuthUser = "admin"
|
||||
cfg.AuthPass = passwordHash
|
||||
})
|
||||
|
|
@ -252,7 +250,6 @@ func TestAPIOnlyModeRequiresToken(t *testing.T) {
|
|||
}
|
||||
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.DisableAuth = false
|
||||
cfg.AuthUser = ""
|
||||
cfg.AuthPass = ""
|
||||
cfg.APITokenEnabled = true
|
||||
|
|
@ -352,33 +349,6 @@ func TestConfigNodesUsesMockTopology(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSecurityStatusReflectsDisableAuth(t *testing.T) {
|
||||
srv := newIntegrationServer(t)
|
||||
|
||||
res, err := http.Get(srv.server.URL + "/api/security/status")
|
||||
if err != nil {
|
||||
t.Fatalf("security status request failed: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: got %d want %d", res.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode security status response: %v", err)
|
||||
}
|
||||
|
||||
disabled, ok := payload["disabled"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("security status response missing disabled flag")
|
||||
}
|
||||
if !disabled {
|
||||
t.Fatalf("expected authentication disabled flag to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockModeToggleEndpoint(t *testing.T) {
|
||||
srv := newIntegrationServer(t)
|
||||
|
||||
|
|
@ -432,7 +402,6 @@ func TestAuthenticatedEndpointsRequireToken(t *testing.T) {
|
|||
const apiToken = "test-token"
|
||||
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.DisableAuth = false
|
||||
cfg.APITokenEnabled = true
|
||||
record, err := config.NewAPITokenRecord(apiToken, "Integration test token", nil)
|
||||
if err != nil {
|
||||
|
|
@ -606,7 +575,6 @@ func TestWebSocketSendsInitialState(t *testing.T) {
|
|||
|
||||
func TestSessionCookieAllowsAuthenticatedAccess(t *testing.T) {
|
||||
srv := newIntegrationServerWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.DisableAuth = false
|
||||
cfg.APITokenEnabled = false
|
||||
hashedPass, err := internalauth.HashPassword("super-secure-pass")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if r.config.DisableAuthEnvDetected {
|
||||
setupRequest.Force = true
|
||||
}
|
||||
|
||||
if r.config.AuthUser != "" && r.config.AuthPass != "" && !setupRequest.Force {
|
||||
log.Info().Msg("Security setup skipped - password auth already configured")
|
||||
response := map[string]interface{}{
|
||||
|
|
@ -367,10 +371,8 @@ PULSE_AUDIT_LOG=true
|
|||
|
||||
// HandleRegenerateAPIToken generates a new API token and updates the .env file
|
||||
func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) {
|
||||
// Only require authentication if auth is already configured AND not disabled
|
||||
// This allows users to set up API-only access without password auth
|
||||
// When auth is disabled, allow API token generation for API-only access
|
||||
if !r.config.DisableAuth && (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) {
|
||||
// Require authentication when password auth is configured
|
||||
if (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -522,7 +524,7 @@ func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request)
|
|||
|
||||
// Require authentication to prevent unauthenticated token guessing oracle
|
||||
// Use same auth logic as regenerate-token endpoint
|
||||
if !r.config.DisableAuth && (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) {
|
||||
if (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,15 +110,15 @@ type Config struct {
|
|||
LogCompress bool `envconfig:"LOG_COMPRESS" default:"true"`
|
||||
|
||||
// Security settings
|
||||
APIToken string `envconfig:"API_TOKEN"`
|
||||
APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"`
|
||||
APITokens []APITokenRecord `json:"-"`
|
||||
AuthUser string `envconfig:"PULSE_AUTH_USER"`
|
||||
AuthPass string `envconfig:"PULSE_AUTH_PASS"`
|
||||
DisableAuth bool `envconfig:"DISABLE_AUTH" default:"false"`
|
||||
DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode
|
||||
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
|
||||
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
|
||||
APIToken string `envconfig:"API_TOKEN"`
|
||||
APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"`
|
||||
APITokens []APITokenRecord `json:"-"`
|
||||
AuthUser string `envconfig:"PULSE_AUTH_USER"`
|
||||
AuthPass string `envconfig:"PULSE_AUTH_PASS"`
|
||||
DisableAuthEnvDetected bool `json:"-"`
|
||||
DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode
|
||||
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
|
||||
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
|
||||
|
||||
// Proxy authentication settings
|
||||
ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"`
|
||||
|
|
@ -878,17 +878,20 @@ func Load() (*Config, error) {
|
|||
log.Info().Msg("Migrated legacy API token into token record store")
|
||||
}
|
||||
}
|
||||
// Check if auth is disabled
|
||||
disableAuthEnv := os.Getenv("DISABLE_AUTH")
|
||||
log.Debug().Str("DISABLE_AUTH_ENV", disableAuthEnv).Msg("Checking DISABLE_AUTH environment variable")
|
||||
if disableAuthEnv != "" {
|
||||
cfg.DisableAuth = disableAuthEnv == "true" || disableAuthEnv == "1"
|
||||
log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DisableAuth set from environment")
|
||||
if cfg.DisableAuth {
|
||||
log.Warn().Msg("⚠️ AUTHENTICATION DISABLED - Pulse is running without authentication!")
|
||||
// Detect deprecated DISABLE_AUTH flag and strip it from the runtime env so downstream
|
||||
// components behave as if it were never set.
|
||||
if disableAuthEnv := os.Getenv("DISABLE_AUTH"); disableAuthEnv != "" {
|
||||
cfg.DisableAuthEnvDetected = true
|
||||
if err := os.Unsetenv("DISABLE_AUTH"); err != nil {
|
||||
log.Warn().
|
||||
Str("DISABLE_AUTH", disableAuthEnv).
|
||||
Err(err).
|
||||
Msg("Failed to remove legacy DISABLE_AUTH environment variable; continuing with authentication enabled")
|
||||
} else {
|
||||
log.Warn().
|
||||
Str("DISABLE_AUTH", disableAuthEnv).
|
||||
Msg("Removed legacy DISABLE_AUTH environment variable. Authentication remains enabled.")
|
||||
}
|
||||
} else {
|
||||
log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DISABLE_AUTH not set, DisableAuth remains")
|
||||
}
|
||||
|
||||
// Check if demo mode is enabled
|
||||
|
|
|
|||
|
|
@ -1036,13 +1036,17 @@ func generateDockerHosts(config MockConfig) []models.DockerHost {
|
|||
containers[idx].CPUPercent = clampFloat(containers[idx].CPUPercent+35, 5, 190)
|
||||
}
|
||||
|
||||
// Determine initial status - only mark last host as offline for testing
|
||||
status := "online"
|
||||
explicitlyOffline := false
|
||||
if hostCount > 2 && i == hostCount-1 {
|
||||
status = "offline"
|
||||
explicitlyOffline = true
|
||||
}
|
||||
|
||||
lastSeen := now.Add(-time.Duration(rand.Intn(20)) * time.Second)
|
||||
if status == "offline" {
|
||||
if explicitlyOffline {
|
||||
// If host is explicitly offline, stop all containers and update metrics
|
||||
lastSeen = now.Add(-time.Duration(5+rand.Intn(20)) * time.Minute)
|
||||
for idx := range containers {
|
||||
exitCode := containers[idx].ExitCode
|
||||
|
|
@ -1060,9 +1064,8 @@ func generateDockerHosts(config MockConfig) []models.DockerHost {
|
|||
containers[idx].FinishedAt = &finished
|
||||
containers[idx].Status = fmt.Sprintf("Exited (%d) %s ago", exitCode, formatDurationForStatus(now.Sub(finished)))
|
||||
}
|
||||
}
|
||||
|
||||
if status != "offline" {
|
||||
} else {
|
||||
// For hosts not explicitly offline, calculate status based on container health
|
||||
running := 0
|
||||
unhealthy := 0
|
||||
for _, ct := range containers {
|
||||
|
|
@ -1075,15 +1078,17 @@ func generateDockerHosts(config MockConfig) []models.DockerHost {
|
|||
}
|
||||
}
|
||||
|
||||
if running == 0 {
|
||||
// Only mark as offline if there are containers but none are running
|
||||
// (Swarm-only hosts with no standalone containers should stay online)
|
||||
if len(containers) > 0 && running == 0 {
|
||||
status = "offline"
|
||||
lastSeen = now.Add(-time.Duration(3+rand.Intn(5)) * time.Minute)
|
||||
} else if unhealthy > 0 || float64(len(containers)-running)/float64(len(containers)) > 0.35 {
|
||||
} else if unhealthy > 0 || (len(containers) > 0 && float64(len(containers)-running)/float64(len(containers)) > 0.35) {
|
||||
status = "degraded"
|
||||
if lastSeen.After(now.Add(-30 * time.Second)) {
|
||||
lastSeen = now.Add(-35 * time.Second)
|
||||
}
|
||||
} else if status != "offline" {
|
||||
} else {
|
||||
status = "online"
|
||||
}
|
||||
}
|
||||
|
|
@ -3677,6 +3682,12 @@ func generateDockerServicesAndTasks(hostname string, containers []models.DockerC
|
|||
currentState = []string{"failed", "shutdown", "pending", "starting"}[rand.Intn(4)]
|
||||
}
|
||||
|
||||
// Create a unique container name for each task
|
||||
taskContainerName := container.Name
|
||||
if slots > 1 {
|
||||
taskContainerName = fmt.Sprintf("%s.%d", container.Name, slot+1)
|
||||
}
|
||||
|
||||
taskID := fmt.Sprintf("%s-task-%d", serviceID, slot)
|
||||
task := models.DockerTask{
|
||||
ID: taskID,
|
||||
|
|
@ -3687,26 +3698,30 @@ func generateDockerServicesAndTasks(hostname string, containers []models.DockerC
|
|||
NodeName: hostname,
|
||||
DesiredState: "running",
|
||||
CurrentState: currentState,
|
||||
ContainerID: container.ID,
|
||||
ContainerName: container.Name,
|
||||
ContainerID: fmt.Sprintf("%s-%d", container.ID, slot),
|
||||
ContainerName: taskContainerName,
|
||||
CreatedAt: now.Add(-time.Duration(rand.Intn(48)) * time.Hour),
|
||||
}
|
||||
|
||||
if container.StartedAt != nil {
|
||||
// Set varied start times for each task (not all identical)
|
||||
if currentState == "running" {
|
||||
startTime := now.Add(-time.Duration(30+rand.Intn(3600*24)) * time.Second)
|
||||
task.StartedAt = &startTime
|
||||
} else if container.StartedAt != nil && (currentState == "failed" || currentState == "shutdown") {
|
||||
started := *container.StartedAt
|
||||
task.StartedAt = &started
|
||||
}
|
||||
if container.FinishedAt != nil {
|
||||
finished := *container.FinishedAt
|
||||
task.CompletedAt = &finished
|
||||
}
|
||||
if currentState == "running" {
|
||||
task.StartedAt = ptrTime(now.Add(-time.Duration(30+rand.Intn(3600)) * time.Second))
|
||||
}
|
||||
|
||||
if currentState == "failed" || currentState == "shutdown" {
|
||||
task.Error = "container exit"
|
||||
task.Message = "Replica exited unexpectedly"
|
||||
if container.FinishedAt != nil {
|
||||
finished := *container.FinishedAt
|
||||
task.CompletedAt = &finished
|
||||
} else {
|
||||
completedTime := now.Add(-time.Duration(rand.Intn(3600)) * time.Second)
|
||||
task.CompletedAt = &completedTime
|
||||
}
|
||||
}
|
||||
|
||||
agg.tasks = append(agg.tasks, task)
|
||||
|
|
|
|||
2
mock.env
2
mock.env
|
|
@ -12,7 +12,7 @@
|
|||
# Documentation: docs/development/MOCK_MODE.md
|
||||
|
||||
# Enable/disable mock mode (false = use real Proxmox infrastructure)
|
||||
PULSE_MOCK_MODE=true
|
||||
PULSE_MOCK_MODE=false
|
||||
|
||||
# Number of mock nodes to generate (mix of clustered and standalone)
|
||||
# First 5 nodes form a cluster, remaining nodes are standalone
|
||||
|
|
|
|||
|
|
@ -182,8 +182,23 @@ if [[ ${PULSE_MOCK_MODE:-false} == "true" ]]; then
|
|||
mkdir -p "$PULSE_DATA_DIR"
|
||||
echo "[hot-dev] Mock mode: Using isolated data directory: ${PULSE_DATA_DIR}"
|
||||
else
|
||||
export PULSE_DATA_DIR=/etc/pulse
|
||||
echo "[hot-dev] Production mode: Using production config: ${PULSE_DATA_DIR}"
|
||||
DEV_CONFIG_DIR="${ROOT_DIR}/tmp/dev-config"
|
||||
mkdir -p "$DEV_CONFIG_DIR"
|
||||
export PULSE_DATA_DIR="${DEV_CONFIG_DIR}"
|
||||
|
||||
DEV_KEY_FILE="${DEV_CONFIG_DIR}/.encryption.key"
|
||||
if [[ ! -f "${DEV_KEY_FILE}" ]]; then
|
||||
openssl rand -hex 32 > "${DEV_KEY_FILE}"
|
||||
chmod 600 "${DEV_KEY_FILE}"
|
||||
echo "[hot-dev] Generated dev encryption key at ${DEV_KEY_FILE}"
|
||||
fi
|
||||
|
||||
# Only set the encryption key if the user hasn't provided one explicitly
|
||||
if [[ -z ${PULSE_ENCRYPTION_KEY:-} ]]; then
|
||||
export PULSE_ENCRYPTION_KEY="$(<"${DEV_KEY_FILE}")"
|
||||
fi
|
||||
|
||||
echo "[hot-dev] Production mode: Using dev config directory: ${PULSE_DATA_DIR}"
|
||||
fi
|
||||
|
||||
./pulse &
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ echo " Source: $PROD_DIR"
|
|||
echo " Target: $DEV_DIR"
|
||||
echo ""
|
||||
|
||||
# CRITICAL: Always sync production encryption key to dev
|
||||
# This ensures dev can decrypt production's encrypted config files
|
||||
# Track whether we have the production encryption key available
|
||||
HAVE_PROD_KEY=false
|
||||
|
||||
# CRITICAL: Always sync production encryption key to dev when it exists
|
||||
if [ -f "$PROD_DIR/.encryption.key" ]; then
|
||||
if [ ! -f "$DEV_DIR/.encryption.key" ]; then
|
||||
cp -f "$PROD_DIR/.encryption.key" "$DEV_DIR/.encryption.key"
|
||||
|
|
@ -37,10 +39,25 @@ if [ -f "$PROD_DIR/.encryption.key" ]; then
|
|||
echo "✓ Dev encryption key matches production"
|
||||
fi
|
||||
fi
|
||||
HAVE_PROD_KEY=true
|
||||
else
|
||||
echo "⚠ Production encryption key not found. Using dev-only key."
|
||||
if [ ! -f "$DEV_DIR/.encryption.key" ]; then
|
||||
# Generate a dev-only encryption key so backend can start
|
||||
openssl rand -hex 32 > "$DEV_DIR/.encryption.key"
|
||||
chmod 600 "$DEV_DIR/.encryption.key"
|
||||
echo "✓ Generated dev encryption key at $DEV_DIR/.encryption.key"
|
||||
else
|
||||
echo "✓ Reusing existing dev encryption key"
|
||||
fi
|
||||
# Remove encrypted artifacts that rely on the missing production key
|
||||
find "$DEV_DIR" -maxdepth 1 -type f -name 'nodes.enc*' -exec rm -f {} \;
|
||||
rm -f "$DEV_DIR/email.enc" "$DEV_DIR/webhooks.enc"
|
||||
echo "✓ Cleared encrypted production artifacts from dev config"
|
||||
fi
|
||||
|
||||
# Copy nodes configuration - WITH VALIDATION
|
||||
if [ -f "$PROD_DIR/nodes.enc" ]; then
|
||||
if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/nodes.enc" ]; then
|
||||
# Check if production nodes.enc is valid (not corrupted)
|
||||
# Only sync if destination doesn't exist OR production file is newer OR dev copy is corrupted
|
||||
SHOULD_SYNC=false
|
||||
|
|
@ -84,6 +101,11 @@ elif [ -f "$PROD_DIR/nodes.json" ]; then
|
|||
echo "✓ Synced nodes configuration (unencrypted)"
|
||||
fi
|
||||
|
||||
# If we had to clear encrypted nodes, ensure we start from a clean slate
|
||||
if [ "$HAVE_PROD_KEY" = false ]; then
|
||||
rm -f "$DEV_DIR/nodes.json"
|
||||
fi
|
||||
|
||||
# Copy system settings (but keep dev-specific log level)
|
||||
if [ -f "$PROD_DIR/system.json" ]; then
|
||||
cp -f "$PROD_DIR/system.json" "$DEV_DIR/system.json"
|
||||
|
|
@ -96,15 +118,15 @@ if [ -f "$PROD_DIR/guest_metadata.json" ]; then
|
|||
echo "✓ Synced guest metadata"
|
||||
fi
|
||||
|
||||
# Copy email config if it exists
|
||||
if [ -f "$PROD_DIR/email.enc" ]; then
|
||||
# Copy email config if it exists and we have the key
|
||||
if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/email.enc" ]; then
|
||||
cp -f "$PROD_DIR/email.enc" "$DEV_DIR/email.enc"
|
||||
chmod 600 "$DEV_DIR/email.enc"
|
||||
echo "✓ Synced email configuration"
|
||||
fi
|
||||
|
||||
# Copy webhook config if it exists
|
||||
if [ -f "$PROD_DIR/webhooks.enc" ]; then
|
||||
# Copy webhook config if it exists and we have the key
|
||||
if [ "$HAVE_PROD_KEY" = true ] && [ -f "$PROD_DIR/webhooks.enc" ]; then
|
||||
cp -f "$PROD_DIR/webhooks.enc" "$DEV_DIR/webhooks.enc"
|
||||
chmod 600 "$DEV_DIR/webhooks.enc"
|
||||
echo "✓ Synced webhook configuration"
|
||||
|
|
@ -122,4 +144,4 @@ mkdir -p "$DEV_DIR/alerts" 2>/dev/null || true
|
|||
echo ""
|
||||
echo "✓ Production config synced to dev environment"
|
||||
echo " Source: $PROD_DIR"
|
||||
echo " Target: $DEV_DIR"
|
||||
echo " Target: $DEV_DIR"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ NC='\033[0m' # No Color
|
|||
MOCK_ENV_FILE="/opt/pulse/mock.env"
|
||||
ROOT_DIR="/opt/pulse"
|
||||
BACKEND_PORT=7656
|
||||
DEV_DATA_DIR="${ROOT_DIR}/tmp/dev-config"
|
||||
DEV_KEY_FILE="${DEV_DATA_DIR}/.encryption.key"
|
||||
|
||||
# Create default mock.env if it doesn't exist
|
||||
if [ ! -f "$MOCK_ENV_FILE" ]; then
|
||||
|
|
@ -85,7 +87,15 @@ restart_backend() {
|
|||
if [ "$PULSE_MOCK_MODE" = "true" ]; then
|
||||
export PULSE_DATA_DIR=/opt/pulse/tmp/mock-data
|
||||
else
|
||||
export PULSE_DATA_DIR=/etc/pulse
|
||||
mkdir -p "$DEV_DATA_DIR"
|
||||
export PULSE_DATA_DIR="$DEV_DATA_DIR"
|
||||
if [ ! -f "$DEV_KEY_FILE" ]; then
|
||||
openssl rand -hex 32 > "$DEV_KEY_FILE"
|
||||
chmod 600 "$DEV_KEY_FILE"
|
||||
fi
|
||||
if [ -z "${PULSE_ENCRYPTION_KEY:-}" ]; then
|
||||
export PULSE_ENCRYPTION_KEY="$(<"$DEV_KEY_FILE")"
|
||||
fi
|
||||
fi
|
||||
|
||||
export PORT=$BACKEND_PORT
|
||||
|
|
@ -116,8 +126,8 @@ show_status() {
|
|||
echo " Docker containers/host: ${PULSE_MOCK_DOCKER_CONTAINERS:-0}"
|
||||
echo " Data dir: ${PULSE_DATA_DIR:-/opt/pulse/tmp/mock-data}"
|
||||
else
|
||||
echo -e "${BLUE}Mock Mode: DISABLED${NC} (using real Proxmox nodes)"
|
||||
echo " Data dir: /etc/pulse"
|
||||
echo -e "${BLUE}Mock Mode: DISABLED${NC}"
|
||||
echo " Data dir: ${DEV_DATA_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +162,7 @@ disable_mock() {
|
|||
sed -i 's/PULSE_MOCK_MODE=.*/PULSE_MOCK_MODE=false/' "$MOCK_ENV_FILE"
|
||||
touch "$MOCK_ENV_FILE"
|
||||
echo -e "${GREEN}✓ Mock mode disabled!${NC}"
|
||||
echo "Using real Proxmox nodes"
|
||||
echo "Using local dev configuration"
|
||||
echo ""
|
||||
restart_backend
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue