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:
rcourtman 2025-10-27 19:46:51 +00:00
parent 68ce8e7520
commit e07336dd9f
32 changed files with 2610 additions and 1715 deletions

View file

@ -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.

View file

@ -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 |
|----------|-------------|---------|

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View 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>
);
};

View 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>
);
};

File diff suppressed because it is too large Load diff

View file

@ -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">

View file

@ -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>

View file

@ -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 &amp; 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>

View file

@ -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');
}

View file

@ -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"

View file

@ -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' : '';

View file

@ -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 */}

View 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;
}

View file

@ -136,7 +136,3 @@ export const useAlertsActivation = () => ({
deactivate,
snooze,
});
// Initialize on module load
refreshConfig();
refreshActiveAlerts();

View file

@ -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 {

View file

@ -113,6 +113,9 @@ export interface SecurityStatus {
proxyAuthLogoutURL?: string;
authUsername?: string;
authLastModified?: string;
disabled?: boolean; // legacy field (removed backend support)
deprecatedDisableAuth?: boolean;
message?: string;
}
/**

View file

@ -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 {

View file

@ -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.")

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 &

View file

@ -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"

View file

@ -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
}