Update Pulse install flow and related components
This commit is contained in:
parent
999da6d900
commit
ff4dc49ae4
30 changed files with 1906 additions and 431 deletions
2
Makefile
2
Makefile
|
|
@ -33,7 +33,7 @@ dev: frontend backend
|
|||
sudo systemctl restart pulse-hot-dev
|
||||
|
||||
dev-hot:
|
||||
./scripts/dev-hot.sh
|
||||
./scripts/hot-dev.sh
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ PROXY_AUTH_LOGOUT_URL=/logout # URL for SSO logout
|
|||
"adaptivePollingBaseInterval": 10, // Target cadence (seconds)
|
||||
"adaptivePollingMinInterval": 5, // Fastest cadence (seconds)
|
||||
"adaptivePollingMaxInterval": 300, // Slowest cadence (seconds)
|
||||
"discoveryEnabled": true, // Enable/disable network discovery for Proxmox/PBS servers
|
||||
"discoveryEnabled": false, // Enable/disable network discovery for Proxmox/PBS servers
|
||||
"discoverySubnet": "auto", // CIDR to scan ("auto" discovers common ranges)
|
||||
"theme": "" // UI theme preference: "", "light", or "dark"
|
||||
}
|
||||
|
|
@ -362,7 +362,7 @@ Settings are loaded in this order (later overrides earlier):
|
|||
#### Configuration Variables (override system.json)
|
||||
These env vars override system.json values. When set, the UI will show a warning and disable the affected fields:
|
||||
|
||||
- `DISCOVERY_ENABLED` - Enable/disable network discovery (default: true)
|
||||
- `DISCOVERY_ENABLED` - Enable/disable network discovery (default: false)
|
||||
- `DISCOVERY_SUBNET` - Custom network to scan (default: auto-scans common networks)
|
||||
- `CONNECTION_TIMEOUT` - API timeout in seconds (default: 10)
|
||||
- `ALLOWED_ORIGINS` - CORS origins (default: same-origin only)
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ If you previously followed the legacy guide (manual `lxc.mount.entry` and `/run/
|
|||
|
||||
This is the same workflow you used originally—no extra commands are required. Just remove the node from Pulse, click “Copy install script,” run it on the Proxmox host, and add the node again.
|
||||
|
||||
> **Advanced option**: If you’d rather refresh in place without removing the node, you can rerun the host-side installer directly (e.g. `sudo /opt/pulse/scripts/install-sensor-proxy.sh --ctid <id>`). The script is idempotent, but re-adding through the UI guarantees the full host + Pulse configuration is rebuilt.
|
||||
> **Advanced option**: If you’d rather refresh in place without removing the node, you can rerun the host-side installer directly (e.g. `sudo /opt/pulse/scripts/install-sensor-proxy.sh --ctid <id> --pulse-server http://<pulse-container-ip>:7655`). The script is idempotent, but re-adding through the UI guarantees the full host + Pulse configuration is rebuilt.
|
||||
|
||||
### Runtime Verification
|
||||
|
||||
|
|
|
|||
|
|
@ -354,14 +354,34 @@ As of v4.24.0, containerized deployments use **pulse-sensor-proxy** which elimin
|
|||
|
||||
**For new installations:** The proxy is installed automatically during LXC setup. No action required.
|
||||
|
||||
**Installed from inside an existing LXC?** The container-only installer cannot create the host bind mount. Run the host-side script below on your Proxmox node to enable temperature monitoring. When Pulse is running in that container, append the server URL so the proxy script can fall back to downloading the binary from Pulse itself if GitHub isn’t available.
|
||||
|
||||
**For existing installations (pre-v4.24.0):** Upgrade your deployment to use the proxy:
|
||||
|
||||
```bash
|
||||
# On your Proxmox host
|
||||
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \
|
||||
bash -s -- --ctid <your-pulse-container-id>
|
||||
bash -s -- --ctid <your-pulse-container-id> --pulse-server http://<pulse-container-ip>:7655
|
||||
```
|
||||
|
||||
> **Heads up for v4.23.x:** Those builds don't ship a standalone `pulse-sensor-proxy` binary yet and the HTTP fallback still requires authentication. Either upgrade to a newer release, install Pulse from source (`install.sh --source main`), or pass a locally built binary with `--local-binary /path/to/pulse-sensor-proxy`.
|
||||
|
||||
### Manual / Automated Host Configuration
|
||||
|
||||
If you prefer to manage the proxy installation yourself (Ansible, Salt, etc.), follow these steps:
|
||||
|
||||
1. **Copy the installer**
|
||||
Download `install-sensor-proxy.sh` from the repository (or cache it internally) and distribute it to the Proxmox host(s).
|
||||
2. **Run with required flags**
|
||||
Execute it with the container ID and Pulse URL:
|
||||
`bash install-sensor-proxy.sh --ctid <ctid> --pulse-server http://<pulse-container-ip>:7655`
|
||||
3. **Verify bind mount**
|
||||
Check `/etc/pve/lxc/<ctid>.conf` for the `mp` entry and ensure `/mnt/pulse-proxy/pulse-sensor-proxy.sock` exists inside the container after the restart.
|
||||
4. **Re-run after adding Proxmox nodes**
|
||||
The script is idempotent; rerun it anytime you add Proxmox nodes so their SSH keys are provisioned.
|
||||
|
||||
Document these steps in your automation so GitHub/network hiccups still succeed by pulling the proxy binary directly from the running Pulse instance.
|
||||
|
||||
### Legacy Security Concerns (Pre-v4.24.0)
|
||||
|
||||
Older versions stored SSH keys inside the container, creating security risks:
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import { DockerIcon } from '@/components/icons/DockerIcon';
|
|||
import { AlertsIcon } from '@/components/icons/AlertsIcon';
|
||||
import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon';
|
||||
import { TokenRevealDialog } from './components/TokenRevealDialog';
|
||||
import { ActivationBanner } from './components/Alerts/ActivationBanner';
|
||||
import { useAlertsActivation } from './stores/alertsActivation';
|
||||
|
||||
// Enhanced store type with proper typing
|
||||
|
|
@ -567,15 +566,6 @@ function App() {
|
|||
<DarkModeContext.Provider value={darkMode}>
|
||||
<SecurityWarning />
|
||||
<DemoBanner />
|
||||
<ActivationBanner
|
||||
activationState={alertsActivation.activationState}
|
||||
activeAlerts={alertsActivation.activeAlerts}
|
||||
config={alertsActivation.config}
|
||||
isPastObservationWindow={alertsActivation.isPastObservationWindow}
|
||||
isLoading={alertsActivation.isLoading}
|
||||
refreshActiveAlerts={alertsActivation.refreshActiveAlerts}
|
||||
activate={alertsActivation.activate}
|
||||
/>
|
||||
<UpdateBanner />
|
||||
<LegacySSHBanner />
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 font-sans py-4 sm:py-6">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { Show, createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import type { JSX } from 'solid-js';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { ActivationState, AlertConfig } from '@/types/alerts';
|
||||
import { ActivationModal } from './ActivationModal';
|
||||
|
||||
interface ActivationBannerProps {
|
||||
activationState: () => ActivationState | null;
|
||||
|
|
@ -14,104 +12,7 @@ interface ActivationBannerProps {
|
|||
activate: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function ActivationBanner(props: ActivationBannerProps): JSX.Element {
|
||||
const [isModalOpen, setIsModalOpen] = createSignal(false);
|
||||
|
||||
const shouldShow = createMemo(() => {
|
||||
const state = props.activationState();
|
||||
return state === 'pending_review' || state === 'snoozed';
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
// Close the modal automatically if activation becomes active while it is open
|
||||
if (!shouldShow() && isModalOpen()) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
const violationCount = createMemo(() => props.activeAlerts()?.length ?? 0);
|
||||
|
||||
const observationSummary = createMemo(() => {
|
||||
const count = violationCount();
|
||||
if (count <= 0) {
|
||||
return 'All systems healthy — no alerts triggered.';
|
||||
}
|
||||
const label = count === 1 ? 'issue' : 'issues';
|
||||
return `${count} ${label} detected.`;
|
||||
});
|
||||
|
||||
const handleReview = async () => {
|
||||
await props.refreshActiveAlerts();
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleActivated = async () => {
|
||||
await props.refreshActiveAlerts();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={shouldShow()}>
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border-b border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100 relative animate-slideDown">
|
||||
<div class="px-4 py-2">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 flex-shrink-0 text-blue-600 dark:text-blue-300 mt-0.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18 16v-5a6 6 0 1 0-12 0v5l-2 2h16l-2-2z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">
|
||||
Monitoring is active. Review your settings to enable notifications.
|
||||
</p>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-200">{observationSummary()}</p>
|
||||
<Show when={props.isPastObservationWindow()}>
|
||||
<p class="text-xs font-semibold text-blue-800 dark:text-blue-100">
|
||||
24-hour setup period ending soon — activate to start receiving notifications.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
onClick={handleReview}
|
||||
disabled={props.isLoading()}
|
||||
>
|
||||
{props.isLoading() ? 'Loading…' : 'Review & Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<ActivationModal
|
||||
isOpen={isModalOpen()}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onActivated={handleActivated}
|
||||
config={props.config}
|
||||
activeAlerts={props.activeAlerts}
|
||||
isLoading={props.isLoading}
|
||||
activate={props.activate}
|
||||
refreshActiveAlerts={props.refreshActiveAlerts}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
export function ActivationBanner(_props: ActivationBannerProps): JSX.Element {
|
||||
// Notifications activation banner/modal intentionally disabled.
|
||||
return <></>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
|
|||
<Portal>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/50 dark:bg-black/60" onClick={props.onClose} />
|
||||
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden border border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Ready to activate notifications</h2>
|
||||
|
|
@ -206,7 +206,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5 space-y-6 overflow-y-auto">
|
||||
<div class="px-6 py-5 space-y-6 flex-1 overflow-y-auto">
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||
Current thresholds
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
} from '@/types/api';
|
||||
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
||||
import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
type OverrideType =
|
||||
| 'guest'
|
||||
| 'node'
|
||||
|
|
@ -232,6 +233,8 @@ interface ThresholdsTableProps {
|
|||
export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
|
|
@ -393,6 +396,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
|||
|
||||
// Check if there's an active alert for a resource/metric
|
||||
const hasActiveAlert = (resourceId: string, metric: string): boolean => {
|
||||
if (!alertsEnabled()) return false;
|
||||
if (!props.activeAlerts) return false;
|
||||
const alertKey = `${resourceId}-${metric}`;
|
||||
return alertKey in props.activeAlerts;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AlertIndicator } from '@/components/shared/AlertIndicators';
|
|||
import { useWebSocket } from '@/App';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
|
||||
interface CompactNodeCardProps {
|
||||
node: Node;
|
||||
|
|
@ -16,6 +17,8 @@ interface CompactNodeCardProps {
|
|||
|
||||
const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
|
||||
const { activeAlerts } = useWebSocket();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
|
||||
const isOnline = () => props.node.status === 'online' && props.node.uptime > 0;
|
||||
|
||||
|
|
@ -29,11 +32,15 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
|
|||
const displayName = () => getNodeDisplayName(props.node);
|
||||
const showActualName = () => hasAlternateDisplayName(props.node);
|
||||
|
||||
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
|
||||
const nodeAlerts = createMemo(() =>
|
||||
getResourceAlerts(props.node.id || props.node.name, activeAlerts),
|
||||
const alertStyles = createMemo(() =>
|
||||
getAlertStyles(props.node.id || props.node.name, activeAlerts, alertsEnabled()),
|
||||
);
|
||||
const nodeAlerts = createMemo(() =>
|
||||
getResourceAlerts(props.node.id || props.node.name, activeAlerts, alertsEnabled()),
|
||||
);
|
||||
const unacknowledgedNodeAlerts = createMemo(() =>
|
||||
nodeAlerts().filter((alert) => !alert.acknowledged),
|
||||
);
|
||||
const unacknowledgedNodeAlerts = createMemo(() => nodeAlerts().filter((alert) => !alert.acknowledged));
|
||||
|
||||
// Get status color
|
||||
const getMetricColor = (value: number, type: 'cpu' | 'mem' | 'disk') => {
|
||||
|
|
@ -72,9 +79,9 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
|
|||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: !isOnline()
|
||||
? 'border-red-500'
|
||||
: alertStyles.hasUnacknowledgedAlert
|
||||
: alertStyles().hasUnacknowledgedAlert
|
||||
? 'border-orange-500'
|
||||
: alertStyles.hasAcknowledgedOnlyAlert
|
||||
: alertStyles().hasAcknowledgedOnlyAlert
|
||||
? 'border-gray-400 dark:border-gray-600'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
} border transition-all cursor-pointer hover:scale-[1.01]`}
|
||||
|
|
@ -119,8 +126,8 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
|
|||
</Show>
|
||||
|
||||
{/* Alert indicator */}
|
||||
<Show when={alertStyles.hasUnacknowledgedAlert}>
|
||||
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
|
||||
<Show when={alertStyles().hasUnacknowledgedAlert}>
|
||||
<AlertIndicator severity={alertStyles().severity} alerts={unacknowledgedNodeAlerts()} />
|
||||
</Show>
|
||||
|
||||
{/* Metrics */}
|
||||
|
|
@ -155,9 +162,9 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
|
|||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: !isOnline()
|
||||
? 'border-red-500'
|
||||
: alertStyles.hasUnacknowledgedAlert
|
||||
: alertStyles().hasUnacknowledgedAlert
|
||||
? 'border-orange-500'
|
||||
: alertStyles.hasAcknowledgedOnlyAlert
|
||||
: alertStyles().hasAcknowledgedOnlyAlert
|
||||
? 'border-gray-400 dark:border-gray-600'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
} cursor-pointer transition-all hover:scale-[1.02]`}
|
||||
|
|
@ -196,8 +203,8 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
|
|||
{props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={alertStyles.hasUnacknowledgedAlert}>
|
||||
<AlertIndicator severity={alertStyles.severity} alerts={unacknowledgedNodeAlerts()} />
|
||||
<Show when={alertStyles().hasUnacknowledgedAlert}>
|
||||
<AlertIndicator severity={alertStyles().severity} alerts={unacknowledgedNodeAlerts()} />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { VM, Container, Node } from '@/types/api';
|
|||
import { GuestRow } from './GuestRow';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { getAlertStyles } from '@/utils/alerts';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
|
||||
|
|
@ -33,6 +34,8 @@ export function Dashboard(props: DashboardProps) {
|
|||
const navigate = useNavigate();
|
||||
const ws = useWebSocket();
|
||||
const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws;
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
|
||||
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
|
||||
|
|
@ -796,7 +799,7 @@ export function Dashboard(props: DashboardProps) {
|
|||
return (
|
||||
<GuestRow
|
||||
guest={guest}
|
||||
alertStyles={getAlertStyles(guestId, activeAlerts)}
|
||||
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
|
||||
customUrl={metadata?.customUrl}
|
||||
onTagClick={handleTagClick}
|
||||
activeSearch={search()}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { getAlertStyles } from '@/utils/alerts';
|
|||
// import type { DockerHostSummary } from './DockerHostSummaryTable';
|
||||
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
|
||||
interface DockerHostsProps {
|
||||
hosts: DockerHost[];
|
||||
|
|
@ -110,6 +111,8 @@ const DockerContainerRow: Component<{
|
|||
activeAlerts?: Record<string, Alert>;
|
||||
}> = (props) => {
|
||||
const { container, host } = props.entry;
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
const containerId = createMemo(() => buildContainerId(container, host.id));
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(containerId()) ?? false);
|
||||
|
||||
|
|
@ -129,11 +132,12 @@ const DockerContainerRow: Component<{
|
|||
};
|
||||
|
||||
const alertStyles = createMemo(() => {
|
||||
if (!alertsEnabled()) return defaultAlertStyles;
|
||||
if (!props.activeAlerts) return defaultAlertStyles;
|
||||
try {
|
||||
// Convert Store to plain object if needed
|
||||
const alertsObj = typeof props.activeAlerts === 'object' ? { ...props.activeAlerts } : props.activeAlerts;
|
||||
return getAlertStyles(containerResourceId(), alertsObj) || defaultAlertStyles;
|
||||
return getAlertStyles(containerResourceId(), alertsObj, alertsEnabled()) || defaultAlertStyles;
|
||||
} catch (e) {
|
||||
console.warn('Error getting alert styles for container:', containerResourceId(), e);
|
||||
return defaultAlertStyles;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@ import { eventBus } from '@/stores/events';
|
|||
import { notificationStore } from '@/stores/notifications';
|
||||
import { showTokenReveal } from '@/stores/tokenReveal';
|
||||
import { updateStore } from '@/stores/updates';
|
||||
|
||||
const COMMON_DISCOVERY_SUBNETS = [
|
||||
'192.168.1.0/24',
|
||||
'192.168.0.0/24',
|
||||
'10.0.0.0/24',
|
||||
'172.16.0.0/24',
|
||||
'192.168.10.0/24',
|
||||
];
|
||||
// Type definitions
|
||||
interface DiscoveredServer {
|
||||
ip: string;
|
||||
|
|
@ -360,9 +368,92 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
// System settings
|
||||
// PBS polling interval removed - fixed at 10 seconds
|
||||
const [allowedOrigins, setAllowedOrigins] = createSignal('*');
|
||||
const [discoveryEnabled, setDiscoveryEnabled] = createSignal(true);
|
||||
const [discoveryEnabled, setDiscoveryEnabled] = createSignal(false);
|
||||
const [discoverySubnet, setDiscoverySubnet] = createSignal('auto');
|
||||
const [discoveryMode, setDiscoveryMode] = createSignal<'auto' | 'custom'>('auto');
|
||||
const [discoverySubnetDraft, setDiscoverySubnetDraft] = createSignal('');
|
||||
const [lastCustomSubnet, setLastCustomSubnet] = createSignal('');
|
||||
const [discoverySubnetError, setDiscoverySubnetError] = createSignal<string | undefined>();
|
||||
const [savingDiscoverySettings, setSavingDiscoverySettings] = createSignal(false);
|
||||
const [envOverrides, setEnvOverrides] = createSignal<Record<string, boolean>>({});
|
||||
let discoverySubnetInputRef: HTMLInputElement | undefined;
|
||||
|
||||
const parseSubnetList = (value: string) => {
|
||||
const seen = new Set<string>();
|
||||
return value
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => {
|
||||
if (!token || token.toLowerCase() === 'auto' || seen.has(token)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(token);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeSubnetList = (value: string) => parseSubnetList(value).join(', ');
|
||||
|
||||
const currentDraftSubnetValue = () => {
|
||||
if (discoveryMode() === 'custom') {
|
||||
return discoverySubnetDraft();
|
||||
}
|
||||
const draft = discoverySubnetDraft();
|
||||
if (draft.trim() !== '') {
|
||||
return draft;
|
||||
}
|
||||
const saved = discoverySubnet();
|
||||
return saved.toLowerCase() === 'auto' ? '' : saved;
|
||||
};
|
||||
|
||||
const isValidCIDR = (value: string) => {
|
||||
const subnets = parseSubnetList(value);
|
||||
if (subnets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return subnets.every((token) => {
|
||||
const [network, prefix] = token.split('/');
|
||||
if (!network || typeof prefix === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prefixNumber = Number(prefix);
|
||||
if (!Number.isInteger(prefixNumber) || prefixNumber < 0 || prefixNumber > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const octets = network.split('.');
|
||||
if (octets.length !== 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return octets.every((octet) => {
|
||||
if (octet === '') return false;
|
||||
if (!/^\d+$/.test(octet)) return false;
|
||||
const valueNumber = Number(octet);
|
||||
return valueNumber >= 0 && valueNumber <= 255;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const applySavedDiscoverySubnet = (subnet?: string | null) => {
|
||||
const raw = typeof subnet === 'string' ? subnet.trim() : '';
|
||||
if (raw === '' || raw.toLowerCase() === 'auto') {
|
||||
setDiscoverySubnet('auto');
|
||||
setDiscoveryMode('auto');
|
||||
setDiscoverySubnetDraft('');
|
||||
} else {
|
||||
setDiscoveryMode('custom');
|
||||
const normalizedValue = normalizeSubnetList(raw);
|
||||
setDiscoverySubnet(normalizedValue);
|
||||
setDiscoverySubnetDraft(normalizedValue);
|
||||
setLastCustomSubnet(normalizedValue);
|
||||
setDiscoverySubnetError(undefined);
|
||||
return;
|
||||
}
|
||||
setDiscoverySubnetError(undefined);
|
||||
};
|
||||
// Connection timeout removed - backend-only setting
|
||||
|
||||
// Iframe embedding settings
|
||||
|
|
@ -893,6 +984,187 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDiscoveryEnabledChange = async (enabled: boolean): Promise<boolean> => {
|
||||
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousEnabled = discoveryEnabled();
|
||||
const previousSubnet = discoverySubnet();
|
||||
let subnetToSend = discoverySubnet();
|
||||
|
||||
if (enabled) {
|
||||
if (discoveryMode() === 'custom') {
|
||||
const trimmedDraft = discoverySubnetDraft().trim();
|
||||
if (!trimmedDraft) {
|
||||
setDiscoverySubnetError('Enter at least one subnet before enabling discovery');
|
||||
notificationStore.error('Enter at least one subnet before enabling discovery');
|
||||
return false;
|
||||
}
|
||||
if (!isValidCIDR(trimmedDraft)) {
|
||||
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
|
||||
notificationStore.error('Enter valid CIDR subnet values before enabling discovery');
|
||||
return false;
|
||||
}
|
||||
const normalizedDraft = normalizeSubnetList(trimmedDraft);
|
||||
setDiscoverySubnetDraft(normalizedDraft);
|
||||
setDiscoverySubnetError(undefined);
|
||||
subnetToSend = normalizedDraft;
|
||||
} else {
|
||||
subnetToSend = 'auto';
|
||||
setDiscoverySubnetError(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
setDiscoveryEnabled(enabled);
|
||||
setSavingDiscoverySettings(true);
|
||||
|
||||
try {
|
||||
await SettingsAPI.updateSystemSettings({
|
||||
discoveryEnabled: enabled,
|
||||
discoverySubnet: subnetToSend,
|
||||
});
|
||||
applySavedDiscoverySubnet(subnetToSend);
|
||||
if (enabled && subnetToSend !== 'auto') {
|
||||
setLastCustomSubnet(subnetToSend);
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
await triggerDiscoveryScan({ quiet: true });
|
||||
notificationStore.success('Discovery enabled — scanning network...', 2000);
|
||||
} else {
|
||||
notificationStore.info('Discovery disabled', 2000);
|
||||
setDiscoveryScanStatus((prev) => ({
|
||||
...prev,
|
||||
scanning: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery setting:', error);
|
||||
notificationStore.error('Failed to update discovery setting');
|
||||
setDiscoveryEnabled(previousEnabled);
|
||||
applySavedDiscoverySubnet(previousSubnet);
|
||||
return false;
|
||||
} finally {
|
||||
setSavingDiscoverySettings(false);
|
||||
await loadDiscoveredNodes();
|
||||
}
|
||||
};
|
||||
|
||||
const commitDiscoverySubnet = async (rawValue: string): Promise<boolean> => {
|
||||
if (envOverrides().discoverySubnet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = rawValue.trim();
|
||||
if (!value) {
|
||||
setDiscoverySubnetError('Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)');
|
||||
return false;
|
||||
}
|
||||
if (!isValidCIDR(value)) {
|
||||
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedValue = normalizeSubnetList(value);
|
||||
if (!normalizedValue) {
|
||||
setDiscoverySubnetError('Enter at least one valid subnet in CIDR format');
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousSubnet = discoverySubnet();
|
||||
const previousNormalized =
|
||||
previousSubnet.toLowerCase() === 'auto' ? '' : normalizeSubnetList(previousSubnet);
|
||||
|
||||
if (normalizedValue === previousNormalized) {
|
||||
setDiscoverySubnetDraft(normalizedValue);
|
||||
setDiscoverySubnetError(undefined);
|
||||
setLastCustomSubnet(normalizedValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
setSavingDiscoverySettings(true);
|
||||
|
||||
try {
|
||||
setDiscoverySubnetError(undefined);
|
||||
await SettingsAPI.updateSystemSettings({
|
||||
discoveryEnabled: discoveryEnabled(),
|
||||
discoverySubnet: normalizedValue,
|
||||
});
|
||||
setLastCustomSubnet(normalizedValue);
|
||||
applySavedDiscoverySubnet(normalizedValue);
|
||||
if (discoveryEnabled()) {
|
||||
await triggerDiscoveryScan({ quiet: true });
|
||||
notificationStore.success('Discovery subnet updated — scanning network...', 2000);
|
||||
} else {
|
||||
notificationStore.success('Discovery subnet saved', 2000);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery subnet:', error);
|
||||
notificationStore.error('Failed to update discovery subnet');
|
||||
applySavedDiscoverySubnet(previousSubnet);
|
||||
setDiscoverySubnetDraft(previousSubnet === 'auto' ? '' : normalizeSubnetList(previousSubnet));
|
||||
return false;
|
||||
} finally {
|
||||
setDiscoverySubnetError(undefined);
|
||||
setSavingDiscoverySettings(false);
|
||||
await loadDiscoveredNodes();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscoveryModeChange = async (mode: 'auto' | 'custom') => {
|
||||
if (envOverrides().discoverySubnet || savingDiscoverySettings()) {
|
||||
return;
|
||||
}
|
||||
if (mode === discoveryMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'auto') {
|
||||
const previousSubnet = discoverySubnet();
|
||||
setDiscoveryMode('auto');
|
||||
setDiscoverySubnetDraft('');
|
||||
setDiscoverySubnetError(undefined);
|
||||
setSavingDiscoverySettings(true);
|
||||
try {
|
||||
await SettingsAPI.updateSystemSettings({
|
||||
discoveryEnabled: discoveryEnabled(),
|
||||
discoverySubnet: 'auto',
|
||||
});
|
||||
applySavedDiscoverySubnet('auto');
|
||||
if (discoveryEnabled()) {
|
||||
await triggerDiscoveryScan({ quiet: true });
|
||||
}
|
||||
notificationStore.info(
|
||||
'Auto discovery scans each network phase. Large networks may take longer.',
|
||||
4000,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery subnet:', error);
|
||||
notificationStore.error('Failed to update discovery subnet');
|
||||
applySavedDiscoverySubnet(previousSubnet);
|
||||
} finally {
|
||||
setSavingDiscoverySettings(false);
|
||||
await loadDiscoveredNodes();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setDiscoveryMode('custom');
|
||||
const rawDraft =
|
||||
discoverySubnet() !== 'auto' ? discoverySubnet() : lastCustomSubnet() || '';
|
||||
const normalizedDraft = normalizeSubnetList(rawDraft);
|
||||
setDiscoverySubnetDraft(normalizedDraft);
|
||||
setDiscoverySubnetError(undefined);
|
||||
queueMicrotask(() => {
|
||||
discoverySubnetInputRef?.focus();
|
||||
discoverySubnetInputRef?.select();
|
||||
});
|
||||
};
|
||||
|
||||
// Load nodes and system settings on mount
|
||||
onMount(async () => {
|
||||
// Subscribe to events
|
||||
|
|
@ -963,6 +1235,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
lastScanStartedAt: data.scanning ? (data.timestamp ?? Date.now()) : prev.lastScanStartedAt,
|
||||
lastResultAt: !data.scanning && data.timestamp ? data.timestamp : prev.lastResultAt,
|
||||
}));
|
||||
|
||||
if (typeof data.subnet === 'string' && data.subnet !== discoverySubnet()) {
|
||||
applySavedDiscoverySubnet(data.subnet);
|
||||
}
|
||||
});
|
||||
|
||||
// Poll for node updates when modal is open
|
||||
|
|
@ -1026,9 +1302,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
setAllowedOrigins(systemSettings.allowedOrigins || '*');
|
||||
// Connection timeout is backend-only
|
||||
// Load discovery settings
|
||||
// Backend defaults to true, so we should respect that
|
||||
setDiscoveryEnabled(systemSettings.discoveryEnabled ?? true); // Default to true if undefined
|
||||
setDiscoverySubnet(systemSettings.discoverySubnet || 'auto');
|
||||
// Backend defaults to false, so we should respect that
|
||||
setDiscoveryEnabled(systemSettings.discoveryEnabled ?? false); // Default to false if undefined
|
||||
applySavedDiscoverySubnet(systemSettings.discoverySubnet);
|
||||
// Load embedding settings
|
||||
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
||||
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
|
||||
|
|
@ -1623,38 +1899,18 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<Toggle
|
||||
checked={discoveryEnabled()}
|
||||
onChange={async (e) => {
|
||||
if (envOverrides().discoveryEnabled) {
|
||||
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const newValue = e.currentTarget.checked;
|
||||
setDiscoveryEnabled(newValue);
|
||||
try {
|
||||
await SettingsAPI.updateSystemSettings({
|
||||
discoveryEnabled: newValue,
|
||||
discoverySubnet: discoverySubnet(),
|
||||
});
|
||||
if (newValue) {
|
||||
await triggerDiscoveryScan({ quiet: true });
|
||||
notificationStore.success(
|
||||
'Discovery enabled — scanning network...',
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
notificationStore.info('Discovery disabled', 2000);
|
||||
setDiscoveryScanStatus((prev) => ({
|
||||
...prev,
|
||||
scanning: false,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery setting:', error);
|
||||
notificationStore.error('Failed to update discovery setting');
|
||||
setDiscoveryEnabled(!newValue);
|
||||
} finally {
|
||||
await loadDiscoveredNodes();
|
||||
const success = await handleDiscoveryEnabledChange(
|
||||
e.currentTarget.checked,
|
||||
);
|
||||
if (!success) {
|
||||
e.currentTarget.checked = discoveryEnabled();
|
||||
}
|
||||
}}
|
||||
disabled={envOverrides().discoveryEnabled}
|
||||
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
|
||||
containerClass="gap-2"
|
||||
label={
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
|
|
@ -2048,6 +2304,19 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{(err) => <li>{err}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
<Show
|
||||
when={
|
||||
discoveryMode() === 'auto' &&
|
||||
(discoveryScanStatus().errors || []).some((err) =>
|
||||
/timed out|timeout/i.test(err),
|
||||
)
|
||||
}
|
||||
>
|
||||
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
|
||||
Large networks can time out in auto mode. Switch to a custom subnet
|
||||
for faster, targeted scans.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
|
|
@ -2169,38 +2438,18 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<Toggle
|
||||
checked={discoveryEnabled()}
|
||||
onChange={async (e) => {
|
||||
if (envOverrides().discoveryEnabled) {
|
||||
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const newValue = e.currentTarget.checked;
|
||||
setDiscoveryEnabled(newValue);
|
||||
try {
|
||||
await SettingsAPI.updateSystemSettings({
|
||||
discoveryEnabled: newValue,
|
||||
discoverySubnet: discoverySubnet(),
|
||||
});
|
||||
if (newValue) {
|
||||
await triggerDiscoveryScan({ quiet: true });
|
||||
notificationStore.success(
|
||||
'Discovery enabled — scanning network...',
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
notificationStore.info('Discovery disabled', 2000);
|
||||
setDiscoveryScanStatus((prev) => ({
|
||||
...prev,
|
||||
scanning: false,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery setting:', error);
|
||||
notificationStore.error('Failed to update discovery setting');
|
||||
setDiscoveryEnabled(!newValue);
|
||||
} finally {
|
||||
await loadDiscoveredNodes();
|
||||
const success = await handleDiscoveryEnabledChange(
|
||||
e.currentTarget.checked,
|
||||
);
|
||||
if (!success) {
|
||||
e.currentTarget.checked = discoveryEnabled();
|
||||
}
|
||||
}}
|
||||
disabled={envOverrides().discoveryEnabled}
|
||||
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
|
||||
containerClass="gap-2"
|
||||
label={
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
|
|
@ -2483,6 +2732,19 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{(err) => <li>{err}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
<Show
|
||||
when={
|
||||
discoveryMode() === 'auto' &&
|
||||
(discoveryScanStatus().errors || []).some((err) =>
|
||||
/timed out|timeout/i.test(err),
|
||||
)
|
||||
}
|
||||
>
|
||||
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
|
||||
Large networks can time out in auto mode. Switch to a custom subnet
|
||||
for faster, targeted scans.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
|
|
@ -2605,38 +2867,18 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<Toggle
|
||||
checked={discoveryEnabled()}
|
||||
onChange={async (e) => {
|
||||
if (envOverrides().discoveryEnabled) {
|
||||
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const newValue = e.currentTarget.checked;
|
||||
setDiscoveryEnabled(newValue);
|
||||
try {
|
||||
await SettingsAPI.updateSystemSettings({
|
||||
discoveryEnabled: newValue,
|
||||
discoverySubnet: discoverySubnet(),
|
||||
});
|
||||
if (newValue) {
|
||||
await triggerDiscoveryScan({ quiet: true });
|
||||
notificationStore.success(
|
||||
'Discovery enabled — scanning network...',
|
||||
2000,
|
||||
);
|
||||
} else {
|
||||
notificationStore.info('Discovery disabled', 2000);
|
||||
setDiscoveryScanStatus((prev) => ({
|
||||
...prev,
|
||||
scanning: false,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update discovery setting:', error);
|
||||
notificationStore.error('Failed to update discovery setting');
|
||||
setDiscoveryEnabled(!newValue);
|
||||
} finally {
|
||||
await loadDiscoveredNodes();
|
||||
const success = await handleDiscoveryEnabledChange(
|
||||
e.currentTarget.checked,
|
||||
);
|
||||
if (!success) {
|
||||
e.currentTarget.checked = discoveryEnabled();
|
||||
}
|
||||
}}
|
||||
disabled={envOverrides().discoveryEnabled}
|
||||
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
|
||||
containerClass="gap-2"
|
||||
label={
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
|
|
@ -2899,6 +3141,19 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{(err) => <li>{err}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
<Show
|
||||
when={
|
||||
discoveryMode() === 'auto' &&
|
||||
(discoveryScanStatus().errors || []).some((err) =>
|
||||
/timed out|timeout/i.test(err),
|
||||
)
|
||||
}
|
||||
>
|
||||
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
|
||||
Large networks can time out in auto mode. Switch to a custom subnet
|
||||
for faster, targeted scans.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
|
|
@ -3038,6 +3293,288 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="space-y-5">
|
||||
<SectionHeader
|
||||
title="Network discovery"
|
||||
description="Control how Pulse scans for Proxmox services on your network."
|
||||
size="sm"
|
||||
align="left"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Automatic scanning</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enable discovery to surface Proxmox VE, PBS, and PMG endpoints automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={discoveryEnabled()}
|
||||
onChange={async (e) => {
|
||||
if (envOverrides().discoveryEnabled || savingDiscoverySettings()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const success = await handleDiscoveryEnabledChange(e.currentTarget.checked);
|
||||
if (!success) {
|
||||
e.currentTarget.checked = discoveryEnabled();
|
||||
}
|
||||
}}
|
||||
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()}
|
||||
containerClass="gap-2"
|
||||
label={
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{discoveryEnabled() ? 'On' : 'Off'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={discoveryEnabled()}>
|
||||
<div class="space-y-4 rounded-lg border border-gray-200 bg-white/40 p-3 dark:border-gray-600 dark:bg-gray-800/40">
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Scan scope
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
|
||||
discoveryMode() === 'auto'
|
||||
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
|
||||
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="discoveryMode"
|
||||
value="auto"
|
||||
checked={discoveryMode() === 'auto'}
|
||||
onChange={async () => {
|
||||
if (discoveryMode() !== 'auto') {
|
||||
await handleDiscoveryModeChange('auto');
|
||||
}
|
||||
}}
|
||||
disabled={envOverrides().discoverySubnet || savingDiscoverySettings()}
|
||||
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Auto (slower, full scan)
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Scans container, local, and gateway networks. Large networks may time
|
||||
out after two minutes.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
|
||||
discoveryMode() === 'custom'
|
||||
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
|
||||
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="discoveryMode"
|
||||
value="custom"
|
||||
checked={discoveryMode() === 'custom'}
|
||||
onChange={() => {
|
||||
if (discoveryMode() !== 'custom') {
|
||||
handleDiscoveryModeChange('custom');
|
||||
}
|
||||
}}
|
||||
disabled={envOverrides().discoverySubnet || savingDiscoverySettings()}
|
||||
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Custom subnet (faster)
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Limit discovery to one or more CIDR ranges to finish faster on large networks.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<Show when={discoveryMode() === 'custom'}>
|
||||
<div class="flex flex-wrap items-center gap-2 pl-9 pr-2">
|
||||
<span class="text-[0.68rem] uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Common networks:
|
||||
</span>
|
||||
<For each={COMMON_DISCOVERY_SUBNETS}>
|
||||
{(preset) => {
|
||||
const baseValue = currentDraftSubnetValue();
|
||||
const currentSelections = parseSubnetList(baseValue);
|
||||
const isActive = currentSelections.includes(preset);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded-full border px-2.5 py-1 text-[0.7rem] transition-colors ${
|
||||
isActive
|
||||
? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-700 hover:border-blue-300 hover:bg-blue-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
if (envOverrides().discoverySubnet) {
|
||||
return;
|
||||
}
|
||||
let selections = [...currentSelections];
|
||||
if (isActive) {
|
||||
selections = selections.filter((item) => item !== preset);
|
||||
} else {
|
||||
selections.push(preset);
|
||||
}
|
||||
|
||||
if (selections.length === 0) {
|
||||
setDiscoverySubnetDraft('');
|
||||
setLastCustomSubnet('');
|
||||
setDiscoverySubnetError(
|
||||
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedValue = normalizeSubnetList(selections.join(', '));
|
||||
setDiscoveryMode('custom');
|
||||
setDiscoverySubnetDraft(updatedValue);
|
||||
setLastCustomSubnet(updatedValue);
|
||||
setDiscoverySubnetError(undefined);
|
||||
await commitDiscoverySubnet(updatedValue);
|
||||
}}
|
||||
disabled={envOverrides().discoverySubnet}
|
||||
classList={{
|
||||
'cursor-not-allowed opacity-60': envOverrides().discoverySubnet,
|
||||
}}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label
|
||||
for="discoverySubnetInput"
|
||||
class="text-xs font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Discovery subnet
|
||||
</label>
|
||||
<span
|
||||
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-300 cursor-help"
|
||||
title="Use CIDR notation (comma-separated for multiple), e.g. 192.168.1.0/24, 10.0.0.0/24. Smaller ranges keep scans quick."
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 16v-4"></path>
|
||||
<path d="M12 8h.01"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="discoverySubnetInput"
|
||||
ref={(el) => {
|
||||
discoverySubnetInputRef = el;
|
||||
}}
|
||||
type="text"
|
||||
value={discoverySubnetDraft()}
|
||||
placeholder={
|
||||
discoveryMode() === 'auto'
|
||||
? 'auto (scan every network phase)'
|
||||
: '192.168.1.0/24, 10.0.0.0/24'
|
||||
}
|
||||
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
envOverrides().discoverySubnet
|
||||
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60'
|
||||
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70'
|
||||
}`}
|
||||
disabled={envOverrides().discoverySubnet}
|
||||
onInput={(e) => {
|
||||
if (envOverrides().discoverySubnet) {
|
||||
return;
|
||||
}
|
||||
const rawValue = e.currentTarget.value;
|
||||
setDiscoverySubnetDraft(rawValue);
|
||||
if (discoveryMode() !== 'custom') {
|
||||
setDiscoveryMode('custom');
|
||||
}
|
||||
setLastCustomSubnet(rawValue);
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
setDiscoverySubnetError(undefined);
|
||||
return;
|
||||
}
|
||||
if (!isValidCIDR(trimmed)) {
|
||||
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
|
||||
} else {
|
||||
setDiscoverySubnetError(undefined);
|
||||
}
|
||||
}}
|
||||
onBlur={async (e) => {
|
||||
if (envOverrides().discoverySubnet || discoveryMode() !== 'custom') {
|
||||
return;
|
||||
}
|
||||
const rawValue = e.currentTarget.value;
|
||||
setDiscoverySubnetDraft(rawValue);
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
setDiscoverySubnetError(
|
||||
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isValidCIDR(trimmed)) {
|
||||
setDiscoverySubnetError('Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)');
|
||||
return;
|
||||
}
|
||||
setDiscoverySubnetError(undefined);
|
||||
await commitDiscoverySubnet(rawValue);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={discoverySubnetError()}>
|
||||
<p class="text-xs text-red-600 dark:text-red-400">
|
||||
{discoverySubnetError()}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={!discoverySubnetError() && discoveryMode() === 'auto'}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Auto scans every reachable network phase. Large networks may time out —
|
||||
switch to custom subnets to narrow the search.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={!discoverySubnetError() && discoveryMode() === 'custom'}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Example: 192.168.1.0/24, 10.0.0.0/24 (comma-separated). Smaller ranges finish faster and avoid timeouts.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={envOverrides().discoveryEnabled || envOverrides().discoverySubnet}>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-100/80 p-3 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
Discovery settings are locked by environment variables. Update the service
|
||||
configuration and restart Pulse to change them here.
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg" class="space-y-3">
|
||||
<SectionHeader
|
||||
title="Appearance"
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
|
|||
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
|
||||
import { getNodeDisplayName } from '@/utils/nodes';
|
||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
|
||||
type StorageSortKey = 'name' | 'node' | 'type' | 'status' | 'usage' | 'free' | 'total';
|
||||
|
||||
const Storage: Component = () => {
|
||||
const navigate = useNavigate();
|
||||
const { state, connected, activeAlerts, initialDataReceived } = useWebSocket();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
const [viewMode, setViewMode] = usePersistentSignal<'node' | 'storage'>(
|
||||
'storageViewMode',
|
||||
'node',
|
||||
|
|
@ -795,9 +798,12 @@ const Storage: Component = () => {
|
|||
return nodes.join(', ');
|
||||
});
|
||||
|
||||
const alertStyles = getAlertStyles(
|
||||
storage.id || `${storage.instance}-${storage.node}-${storage.name}`,
|
||||
activeAlerts,
|
||||
const alertStyles = createMemo(() =>
|
||||
getAlertStyles(
|
||||
storage.id || `${storage.instance}-${storage.node}-${storage.name}`,
|
||||
activeAlerts,
|
||||
alertsEnabled(),
|
||||
),
|
||||
);
|
||||
|
||||
const parentNodeOnline = createMemo(() => {
|
||||
|
|
@ -808,7 +814,7 @@ const Storage: Component = () => {
|
|||
});
|
||||
|
||||
const showAlertHighlight = createMemo(
|
||||
() => alertStyles.hasUnacknowledgedAlert && parentNodeOnline(),
|
||||
() => alertStyles().hasUnacknowledgedAlert && parentNodeOnline(),
|
||||
);
|
||||
|
||||
const isCephStorage = createMemo(() => isCephType(storage.type));
|
||||
|
|
@ -934,7 +940,7 @@ const Storage: Component = () => {
|
|||
);
|
||||
|
||||
const hasAcknowledgedOnlyAlert = createMemo(
|
||||
() => alertStyles.hasAcknowledgedOnlyAlert && parentNodeOnline(),
|
||||
() => alertStyles().hasAcknowledgedOnlyAlert && parentNodeOnline(),
|
||||
);
|
||||
|
||||
const rowClass = createMemo(() => {
|
||||
|
|
@ -946,7 +952,7 @@ const Storage: Component = () => {
|
|||
|
||||
if (showAlertHighlight()) {
|
||||
classes.push(
|
||||
alertStyles.severity === 'critical'
|
||||
alertStyles().severity === 'critical'
|
||||
? 'bg-red-50 dark:bg-red-950/30'
|
||||
: 'bg-yellow-50 dark:bg-yellow-950/20',
|
||||
);
|
||||
|
|
@ -972,7 +978,7 @@ const Storage: Component = () => {
|
|||
const rowStyle = createMemo(() => {
|
||||
if (showAlertHighlight()) {
|
||||
const color =
|
||||
alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
|
||||
alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
||||
return {
|
||||
'box-shadow': `inset 4px 0 0 0 ${color}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export const ToastContainer: Component = () => {
|
|||
|
||||
return (
|
||||
<Portal>
|
||||
<div class="fixed top-4 right-4 z-[9999] space-y-2 max-w-sm">
|
||||
<div class="fixed bottom-4 right-4 z-[9999] space-y-2 max-w-sm">
|
||||
<For each={toasts()}>{(toast) => <Toast toast={toast} onRemove={removeToast} />}</For>
|
||||
</div>
|
||||
</Portal>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Component } from 'solid-js';
|
||||
import { Component, createMemo } from 'solid-js';
|
||||
import type { Alert } from '@/types/api';
|
||||
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
|
||||
const getMetadataUnit = (alert: Alert): string | undefined => {
|
||||
const rawUnit = alert.metadata?.['unit'];
|
||||
|
|
@ -69,7 +70,9 @@ interface AlertIndicatorProps {
|
|||
}
|
||||
|
||||
export const AlertIndicator: Component<AlertIndicatorProps> = (props) => {
|
||||
if (!props.severity) return null;
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
if (!alertsEnabled() || !props.severity) return null;
|
||||
|
||||
const dotClass = props.severity === 'critical' ? 'bg-red-500 animate-pulse' : 'bg-orange-500';
|
||||
|
||||
|
|
@ -110,6 +113,10 @@ interface AlertCountBadgeProps {
|
|||
}
|
||||
|
||||
export const AlertCountBadge: Component<AlertCountBadgeProps> = (props) => {
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
if (!alertsEnabled()) return null;
|
||||
|
||||
const badgeClass =
|
||||
props.severity === 'critical' ? 'bg-red-500 text-white' : 'bg-orange-500 text-white';
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getAlertStyles } from '@/utils/alerts';
|
|||
import { Card } from '@/components/shared/Card';
|
||||
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
|
||||
import { getCpuTemperature } from '@/utils/temperature';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
|
||||
interface NodeSummaryTableProps {
|
||||
nodes: Node[];
|
||||
|
|
@ -22,6 +23,8 @@ interface NodeSummaryTableProps {
|
|||
|
||||
export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||
const { activeAlerts, state } = useWebSocket();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
type CountSortKey = 'vmCount' | 'containerCount' | 'storageCount' | 'diskCount' | 'backupCount';
|
||||
type SortKey =
|
||||
| 'default'
|
||||
|
|
@ -423,15 +426,19 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
|||
const isSelected = () => props.selectedNode === nodeId;
|
||||
// Use the full resource ID for alert matching
|
||||
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
|
||||
const alertStyles = getAlertStyles(resourceId, activeAlerts);
|
||||
const showAlertHighlight = alertStyles.hasUnacknowledgedAlert && online;
|
||||
const alertStyles = createMemo(() =>
|
||||
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
||||
);
|
||||
const showAlertHighlight = createMemo(
|
||||
() => alertStyles().hasUnacknowledgedAlert && online,
|
||||
);
|
||||
|
||||
const rowStyle = createMemo(() => {
|
||||
const styles: Record<string, string> = {};
|
||||
const shadows: string[] = [];
|
||||
|
||||
if (showAlertHighlight) {
|
||||
const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
|
||||
if (showAlertHighlight()) {
|
||||
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
||||
shadows.push(`inset 4px 0 0 0 ${color}`);
|
||||
}
|
||||
|
||||
|
|
@ -454,8 +461,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
|||
return `cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10`;
|
||||
}
|
||||
|
||||
if (showAlertHighlight) {
|
||||
return alertStyles.severity === 'critical'
|
||||
if (showAlertHighlight()) {
|
||||
return alertStyles().severity === 'critical'
|
||||
? 'cursor-pointer transition-all duration-200 relative bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40 hover:shadow-sm'
|
||||
: 'cursor-pointer transition-all duration-200 relative bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30 hover:shadow-sm';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, Back
|
|||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { SettingsPanel } from '@/components/shared/SettingsPanel';
|
||||
import { Toggle } from '@/components/shared/Toggle';
|
||||
import { formField, formControl, formHelpText, labelClass, controlClass } from '@/components/shared/Form';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
|
|
@ -19,6 +18,7 @@ import type { EmailConfig, AppriseConfig } from '@/api/notifications';
|
|||
import type { HysteresisThreshold } from '@/types/alerts';
|
||||
import type { Alert, State, VM, Container, DockerHost, DockerContainer } from '@/types/api';
|
||||
import { useNavigate, useLocation } from '@solidjs/router';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import LayoutDashboard from 'lucide-solid/icons/layout-dashboard';
|
||||
import History from 'lucide-solid/icons/history';
|
||||
import Gauge from 'lucide-solid/icons/gauge';
|
||||
|
|
@ -317,6 +317,56 @@ export function Alerts() {
|
|||
const { state, activeAlerts, updateAlert, removeAlerts } = useWebSocket();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const [isSwitchingActivation, setIsSwitchingActivation] = createSignal(false);
|
||||
const isAlertsActive = createMemo(() => alertsActivation.activationState() === 'active');
|
||||
|
||||
const handleActivateAlerts = async () => {
|
||||
if (alertsActivation.isLoading() || isSwitchingActivation()) {
|
||||
return;
|
||||
}
|
||||
setIsSwitchingActivation(true);
|
||||
try {
|
||||
const success = await alertsActivation.activate();
|
||||
if (success) {
|
||||
showSuccess('Alerts activated! You\'ll now receive alerts when issues are detected.');
|
||||
try {
|
||||
await alertsActivation.refreshActiveAlerts();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh alerts after activation', error);
|
||||
}
|
||||
} else {
|
||||
showError('Unable to activate alerts. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsSwitchingActivation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivateAlerts = async () => {
|
||||
if (isSwitchingActivation()) {
|
||||
return;
|
||||
}
|
||||
setIsSwitchingActivation(true);
|
||||
try {
|
||||
const success = await alertsActivation.deactivate();
|
||||
if (success) {
|
||||
showSuccess('Alerts deactivated. Nothing will be sent until you activate them again.');
|
||||
try {
|
||||
await alertsActivation.refreshActiveAlerts();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh alerts after deactivation', error);
|
||||
}
|
||||
} else {
|
||||
showError('Unable to deactivate alerts. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Deactivate alerts failed', error);
|
||||
showError('Unable to deactivate alerts. Please try again.');
|
||||
} finally {
|
||||
setIsSwitchingActivation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = createSignal<AlertTab>(tabFromPath(location.pathname));
|
||||
|
||||
|
|
@ -344,6 +394,12 @@ export function Alerts() {
|
|||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!isAlertsActive() && activeTab() !== 'overview') {
|
||||
handleTabChange('overview');
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = (tab: AlertTab) => {
|
||||
const targetPath = pathForTab(tab);
|
||||
if (location.pathname !== targetPath) {
|
||||
|
|
@ -1211,11 +1267,51 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
<div class="space-y-4">
|
||||
{/* Header with better styling */}
|
||||
<Card padding="md">
|
||||
<SectionHeader
|
||||
title={headerMeta().title}
|
||||
description={headerMeta().description}
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<SectionHeader
|
||||
title={headerMeta().title}
|
||||
description={headerMeta().description}
|
||||
size="lg"
|
||||
/>
|
||||
<Show when={activeTab() === 'overview'}>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class={`text-sm font-medium ${
|
||||
isAlertsActive() ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{isAlertsActive() ? 'Alerts enabled' : 'Alerts disabled'}
|
||||
</span>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<span class="sr-only">Toggle alerts</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
checked={isAlertsActive()}
|
||||
disabled={alertsActivation.isLoading() || isSwitchingActivation()}
|
||||
onChange={(event) => {
|
||||
if (event.currentTarget.checked) {
|
||||
void handleActivateAlerts();
|
||||
} else {
|
||||
void handleDeactivateAlerts();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class={`relative w-11 h-6 rounded-full transition ${
|
||||
isAlertsActive() ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
} ${alertsActivation.isLoading() || isSwitchingActivation() ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
class={`absolute top-[2px] left-[2px] h-5 w-5 rounded-full bg-white transition-all shadow ${
|
||||
isAlertsActive() ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save notification bar - only show when there are unsaved changes */}
|
||||
|
|
@ -1419,6 +1515,10 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
</Card>
|
||||
</Show>
|
||||
|
||||
<div class={`transition-opacity ${
|
||||
isAlertsActive() ? 'opacity-100' : 'opacity-50 pointer-events-none'
|
||||
}`}>
|
||||
|
||||
<Card padding="none" class="relative lg:flex">
|
||||
<div
|
||||
class={`hidden lg:flex lg:flex-col ${sidebarCollapsed() ? 'w-16' : 'w-72'} ${sidebarCollapsed() ? 'lg:min-w-[4rem] lg:max-w-[4rem] lg:basis-[4rem]' : 'lg:min-w-[18rem] lg:max-w-[18rem] lg:basis-[18rem]'} relative border-b border-gray-200 dark:border-gray-700 lg:border-b-0 lg:border-r lg:border-gray-200 dark:lg:border-gray-700 lg:align-top flex-shrink-0 transition-all duration-300`}
|
||||
|
|
@ -1439,24 +1539,39 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
</Show>
|
||||
<div class="space-y-1.5">
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={activeTab() === item.id ? 'page' : undefined}
|
||||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
|
||||
activeTab() === item.id
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => handleTabChange(item.id)}
|
||||
title={sidebarCollapsed() ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<Show when={!sidebarCollapsed()}>
|
||||
<span class="truncate">{item.label}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
{(item) => {
|
||||
const disabled = () => item.id !== 'overview' && !isAlertsActive();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={activeTab() === item.id ? 'page' : undefined}
|
||||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'} text-sm font-medium transition-colors ${
|
||||
activeTab() === item.id
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
|
||||
} ${disabled() ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''}`}
|
||||
disabled={disabled()}
|
||||
onClick={() => {
|
||||
if (disabled()) return;
|
||||
handleTabChange(item.id);
|
||||
}}
|
||||
title={
|
||||
sidebarCollapsed()
|
||||
? disabled()
|
||||
? 'Activate alerts to configure'
|
||||
: item.label
|
||||
: disabled()
|
||||
? 'Activate alerts to configure'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<Show when={!sidebarCollapsed()}>
|
||||
<span class="truncate">{item.label}</span>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1475,19 +1590,27 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
style="-webkit-overflow-scrolling: touch;"
|
||||
>
|
||||
<For each={flatTabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
|
||||
activeTab() === tab.id
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)}
|
||||
{(tab) => {
|
||||
const disabled = () => tab.id !== 'overview' && !isAlertsActive();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex-1 px-3 py-2 text-xs font-medium rounded-md transition-all whitespace-nowrap ${
|
||||
activeTab() === tab.id
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
} ${disabled() ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''}`}
|
||||
disabled={disabled()}
|
||||
onClick={() => {
|
||||
if (disabled()) return;
|
||||
handleTabChange(tab.id);
|
||||
}}
|
||||
title={disabled() ? 'Activate alerts to configure' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1616,6 +1739,7 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,29 @@ import { createSignal } from 'solid-js';
|
|||
import { AlertsAPI } from '@/api/alerts';
|
||||
import type { AlertConfig, ActivationState as ActivationStateType } from '@/types/alerts';
|
||||
import type { Alert } from '@/types/api';
|
||||
import { setGlobalActivationState } from '@/utils/alertsActivation';
|
||||
|
||||
// Create signals for activation state
|
||||
const [config, setConfig] = createSignal<AlertConfig | null>(null);
|
||||
const [activationState, setActivationState] = createSignal<ActivationStateType | null>(null);
|
||||
const [activationState, setActivationStateSignal] = createSignal<ActivationStateType | null>(null);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [activeAlerts, setActiveAlerts] = createSignal<Alert[]>([]);
|
||||
const [lastError, setLastError] = createSignal<string | null>(null);
|
||||
|
||||
const applyActivationState = (state: ActivationStateType | null) => {
|
||||
setActivationStateSignal(state);
|
||||
setGlobalActivationState(state);
|
||||
};
|
||||
|
||||
const ensureConfigLoaded = async (): Promise<AlertConfig | null> => {
|
||||
let current = config();
|
||||
if (!current) {
|
||||
await refreshConfig();
|
||||
current = config();
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// Refresh config from API
|
||||
const refreshConfig = async (): Promise<void> => {
|
||||
try {
|
||||
|
|
@ -17,7 +32,7 @@ const refreshConfig = async (): Promise<void> => {
|
|||
setLastError(null);
|
||||
const alertConfig = await AlertsAPI.getConfig();
|
||||
setConfig(alertConfig);
|
||||
setActivationState(alertConfig.activationState || 'active');
|
||||
applyActivationState(alertConfig.activationState || 'active');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch alert config:', error);
|
||||
setLastError(error instanceof Error ? error.message : 'Unknown error');
|
||||
|
|
@ -59,6 +74,35 @@ const activate = async (): Promise<boolean> => {
|
|||
}
|
||||
};
|
||||
|
||||
const updateActivationState = async (state: ActivationStateType): Promise<boolean> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setLastError(null);
|
||||
const current = await ensureConfigLoaded();
|
||||
if (!current) {
|
||||
throw new Error('Alert configuration is unavailable');
|
||||
}
|
||||
const updated: AlertConfig = { ...current, activationState: state };
|
||||
const result = await AlertsAPI.updateConfig(updated);
|
||||
if (!result.success) {
|
||||
return false;
|
||||
}
|
||||
setConfig(updated);
|
||||
applyActivationState(state);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update activation state:', error);
|
||||
setLastError(error instanceof Error ? error.message : 'Unknown error');
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = async (): Promise<boolean> => updateActivationState('pending_review');
|
||||
|
||||
const snooze = async (): Promise<boolean> => updateActivationState('snoozed');
|
||||
|
||||
// Check if past observation window
|
||||
const isPastObservationWindow = (): boolean => {
|
||||
const cfg = config();
|
||||
|
|
@ -89,6 +133,8 @@ export const useAlertsActivation = () => ({
|
|||
refreshConfig,
|
||||
refreshActiveAlerts,
|
||||
activate,
|
||||
deactivate,
|
||||
snooze,
|
||||
});
|
||||
|
||||
// Initialize on module load
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import type {
|
|||
VM,
|
||||
Container,
|
||||
} from '@/types/api';
|
||||
import type { ActivationState as ActivationStateType } from '@/types/alerts';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { POLLING_INTERVALS, WEBSOCKET } from '@/constants';
|
||||
import { notificationStore } from './notifications';
|
||||
import { eventBus } from './events';
|
||||
import { ALERTS_ACTIVATION_EVENT, isAlertsActivationEnabled } from '@/utils/alertsActivation';
|
||||
|
||||
// Type-safe WebSocket store
|
||||
export function createWebSocketStore(url: string) {
|
||||
|
|
@ -79,6 +81,66 @@ export function createWebSocketStore(url: string) {
|
|||
// Track alerts with pending acknowledgment changes to prevent race conditions
|
||||
const pendingAckChanges = new Map<string, { ack: boolean; previousAckTime?: string }>();
|
||||
|
||||
let alertsEnabled = isAlertsActivationEnabled();
|
||||
let lastActiveAlertsPayload: Record<string, Alert> = {};
|
||||
|
||||
const applyActiveAlerts = (alertsMap: Record<string, Alert>) => {
|
||||
// Remove alerts that no longer exist
|
||||
const currentAlertIds = Object.keys(activeAlerts);
|
||||
currentAlertIds.forEach((id) => {
|
||||
if (!alertsMap[id]) {
|
||||
setActiveAlerts(id, undefined as unknown as Alert);
|
||||
}
|
||||
});
|
||||
|
||||
// Add or update alerts with pending acknowledgment safeguards
|
||||
Object.entries(alertsMap).forEach(([id, alert]) => {
|
||||
if (pendingAckChanges.has(id)) {
|
||||
const pending = pendingAckChanges.get(id)!;
|
||||
|
||||
if (pending.ack) {
|
||||
if (!alert.acknowledged) {
|
||||
logger.debug(
|
||||
`Skipping update for alert ${id} - awaiting server acknowledgment confirmation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverAckTime = alert.ackTime || '';
|
||||
const previousAckTime = pending.previousAckTime || '';
|
||||
if (serverAckTime === previousAckTime) {
|
||||
logger.debug(
|
||||
`Server ack time for alert ${id} unchanged (${serverAckTime}); treating as confirmed`,
|
||||
);
|
||||
}
|
||||
} else if (alert.acknowledged) {
|
||||
logger.debug(
|
||||
`Skipping update for alert ${id} - awaiting server unacknowledge confirmation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAckChanges.delete(id);
|
||||
}
|
||||
|
||||
setActiveAlerts(id, alert);
|
||||
});
|
||||
|
||||
setState('activeAlerts', Object.values(alertsMap));
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener(
|
||||
ALERTS_ACTIVATION_EVENT,
|
||||
(event: Event) => {
|
||||
const detail = (event as CustomEvent<ActivationStateType | null>).detail;
|
||||
alertsEnabled = detail === 'active';
|
||||
applyActiveAlerts(alertsEnabled ? lastActiveAlertsPayload : {});
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimeout: number;
|
||||
let heartbeatInterval: number;
|
||||
|
|
@ -317,9 +379,6 @@ export function createWebSocketStore(url: string) {
|
|||
setState('physicalDisks', message.data.physicalDisks);
|
||||
// Sync active alerts from state
|
||||
if (message.data.activeAlerts !== undefined) {
|
||||
// Received activeAlerts update
|
||||
|
||||
// Update alerts atomically to prevent race conditions
|
||||
const newAlerts: Record<string, Alert> = {};
|
||||
if (message.data.activeAlerts && Array.isArray(message.data.activeAlerts)) {
|
||||
message.data.activeAlerts.forEach((alert: Alert) => {
|
||||
|
|
@ -327,53 +386,8 @@ export function createWebSocketStore(url: string) {
|
|||
});
|
||||
}
|
||||
|
||||
// Clear existing alerts and set new ones
|
||||
const currentAlertIds = Object.keys(activeAlerts);
|
||||
currentAlertIds.forEach((id) => {
|
||||
if (!newAlerts[id]) {
|
||||
setActiveAlerts(id, undefined as unknown as Alert);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new alerts (skip those with pending acknowledgment changes until server confirms)
|
||||
Object.entries(newAlerts).forEach(([id, alert]) => {
|
||||
// Skip updating if this alert has a pending acknowledgment change
|
||||
if (pendingAckChanges.has(id)) {
|
||||
const pending = pendingAckChanges.get(id)!;
|
||||
|
||||
if (pending.ack) {
|
||||
// Expecting an acknowledged alert
|
||||
if (!alert.acknowledged) {
|
||||
logger.debug(
|
||||
`Skipping update for alert ${id} - awaiting server acknowledgment confirmation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverAckTime = alert.ackTime || '';
|
||||
const previousAckTime = pending.previousAckTime || '';
|
||||
if (serverAckTime === previousAckTime) {
|
||||
logger.debug(
|
||||
`Server ack time for alert ${id} unchanged (${serverAckTime}); treating as confirmed`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Expecting an unacknowledged alert
|
||||
if (alert.acknowledged) {
|
||||
logger.debug(
|
||||
`Skipping update for alert ${id} - awaiting server unacknowledge confirmation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pendingAckChanges.delete(id);
|
||||
}
|
||||
setActiveAlerts(id, alert);
|
||||
});
|
||||
|
||||
// Updated activeAlerts - also sync to state for badge count
|
||||
setState('activeAlerts', message.data.activeAlerts);
|
||||
lastActiveAlertsPayload = newAlerts;
|
||||
applyActiveAlerts(alertsEnabled ? newAlerts : {});
|
||||
}
|
||||
// Sync recently resolved alerts
|
||||
if (message.data.recentlyResolved !== undefined) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,31 @@
|
|||
import type { Alert } from '@/types/api';
|
||||
import { isAlertsActivationEnabled } from '@/utils/alertsActivation';
|
||||
|
||||
const noAlertStyles = {
|
||||
rowClass: '',
|
||||
indicatorClass: '',
|
||||
badgeClass: '',
|
||||
hasAlert: false,
|
||||
alertCount: 0,
|
||||
severity: null as 'critical' | 'warning' | null,
|
||||
hasPoweredOffAlert: false,
|
||||
hasNonPoweredOffAlert: false,
|
||||
hasUnacknowledgedAlert: false,
|
||||
unacknowledgedCount: 0,
|
||||
acknowledgedCount: 0,
|
||||
hasAcknowledgedOnlyAlert: false,
|
||||
};
|
||||
|
||||
// Get alert highlighting styles based on active alerts for a resource
|
||||
export const getAlertStyles = (resourceId: string, activeAlerts: Record<string, Alert>) => {
|
||||
export const getAlertStyles = (
|
||||
resourceId: string,
|
||||
activeAlerts: Record<string, Alert>,
|
||||
alertsEnabled: boolean | undefined = isAlertsActivationEnabled(),
|
||||
) => {
|
||||
if (!alertsEnabled) {
|
||||
return noAlertStyles;
|
||||
}
|
||||
|
||||
const alertsForResource = Object.values(activeAlerts).filter(
|
||||
(alert) => alert.resourceId === resourceId,
|
||||
);
|
||||
|
|
@ -86,6 +110,8 @@ export const getAlertStyles = (resourceId: string, activeAlerts: Record<string,
|
|||
export const getResourceAlerts = (
|
||||
resourceId: string,
|
||||
activeAlerts: Record<string, Alert>,
|
||||
alertsEnabled: boolean | undefined = isAlertsActivationEnabled(),
|
||||
): Alert[] => {
|
||||
if (!alertsEnabled) return [];
|
||||
return Object.values(activeAlerts).filter((alert) => alert.resourceId === resourceId);
|
||||
};
|
||||
|
|
|
|||
30
frontend-modern/src/utils/alertsActivation.ts
Normal file
30
frontend-modern/src/utils/alertsActivation.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { ActivationState } from '@/types/alerts';
|
||||
|
||||
export const ALERTS_ACTIVATION_EVENT = 'pulse-alerts-activation-change';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pulseAlertsActivationState?: ActivationState | null;
|
||||
}
|
||||
}
|
||||
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
export const setGlobalActivationState = (state: ActivationState | null): void => {
|
||||
if (!isBrowser) return;
|
||||
window.__pulseAlertsActivationState = state;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ActivationState | null>(ALERTS_ACTIVATION_EVENT, {
|
||||
detail: state,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const isAlertsActivationEnabled = (): boolean => {
|
||||
if (!isBrowser) return true;
|
||||
const state = window.__pulseAlertsActivationState;
|
||||
if (state === undefined || state === null) {
|
||||
return true;
|
||||
}
|
||||
return state === 'active';
|
||||
};
|
||||
100
install.sh
100
install.sh
|
|
@ -31,10 +31,94 @@ PROXY_PREPARE_MOUNT=false
|
|||
CURRENT_INSTALL_CTID=""
|
||||
CONTAINER_CREATED_FOR_CLEANUP=false
|
||||
BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE"
|
||||
DETECTED_CTID=""
|
||||
|
||||
DEBIAN_TEMPLATE_FALLBACK="debian-12-standard_12.12-1_amd64.tar.zst"
|
||||
DEBIAN_TEMPLATE=""
|
||||
|
||||
detect_lxc_ctid() {
|
||||
local ctid=""
|
||||
|
||||
if [[ -r /proc/1/cgroup ]]; then
|
||||
ctid=$(sed 's/\\x2d/-/g' /proc/1/cgroup 2>/dev/null | grep -Eo '(lxc|machine-lxc)-[0-9]+' | tail -n1 | grep -Eo '[0-9]+' | tail -n1)
|
||||
if [[ -n "$ctid" ]]; then
|
||||
echo "$ctid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v hostname >/dev/null 2>&1; then
|
||||
ctid=$(hostname 2>/dev/null || true)
|
||||
if [[ "$ctid" =~ ^[0-9]+$ ]]; then
|
||||
echo "$ctid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v hostnamectl >/dev/null 2>&1; then
|
||||
ctid=$(hostnamectl hostname 2>/dev/null || true)
|
||||
if [[ "$ctid" =~ ^[0-9]+$ ]]; then
|
||||
echo "$ctid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v findmnt >/dev/null 2>&1; then
|
||||
local mount_src
|
||||
mount_src=$(findmnt -no SOURCE / 2>/dev/null || true)
|
||||
if [[ "$mount_src" =~ -([0-9]+)-disk ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v df >/dev/null 2>&1; then
|
||||
local root_src
|
||||
root_src=$(df -P / 2>/dev/null | awk 'NR==2 {print $1}')
|
||||
if [[ "$root_src" =~ -([0-9]+)-disk ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
auto_detect_container_environment() {
|
||||
if [[ "$IN_CONTAINER" == "true" ]]; then
|
||||
if [[ -z "$DETECTED_CTID" ]]; then
|
||||
DETECTED_CTID=$(detect_lxc_ctid 2>/dev/null || true)
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
local virt_type=""
|
||||
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||||
virt_type=$(systemd-detect-virt --container 2>/dev/null || true)
|
||||
if [[ -n "$virt_type" && "$virt_type" != "none" ]]; then
|
||||
IN_CONTAINER=true
|
||||
if [[ "$virt_type" == "docker" ]]; then
|
||||
IN_DOCKER=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$IN_CONTAINER" != "true" ]]; then
|
||||
if [[ -f /.dockerenv ]] || grep -qa docker /proc/1/cgroup 2>/dev/null || grep -qa docker /proc/self/cgroup 2>/dev/null; then
|
||||
IN_CONTAINER=true
|
||||
IN_DOCKER=true
|
||||
elif grep -qaE '(lxc|machine-lxc)' /proc/1/cgroup 2>/dev/null; then
|
||||
IN_CONTAINER=true
|
||||
elif [[ -r /proc/1/environ ]] && grep -qa 'container=lxc' /proc/1/environ 2>/dev/null; then
|
||||
IN_CONTAINER=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$IN_CONTAINER" == "true" ]]; then
|
||||
if [[ -z "$DETECTED_CTID" ]]; then
|
||||
DETECTED_CTID=$(detect_lxc_ctid 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
handle_install_interrupt() {
|
||||
echo ""
|
||||
print_error "Installation cancelled"
|
||||
|
|
@ -2375,7 +2459,9 @@ print_completion() {
|
|||
print_header
|
||||
print_success "Pulse installation completed!"
|
||||
echo
|
||||
echo -e "${GREEN}Access Pulse at:${NC} http://${IP}:${PORT}"
|
||||
local PULSE_URL="http://${IP}:${PORT}"
|
||||
|
||||
echo -e "${GREEN}Access Pulse at:${NC} ${PULSE_URL}"
|
||||
echo
|
||||
echo -e "${YELLOW}Quick commands:${NC}"
|
||||
echo " systemctl status $SERVICE_NAME - Check status"
|
||||
|
|
@ -2386,6 +2472,16 @@ print_completion() {
|
|||
echo " Update: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash"
|
||||
echo " Reset: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --reset"
|
||||
echo " Uninstall: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --uninstall"
|
||||
|
||||
if [[ "$IN_CONTAINER" == "true" ]]; then
|
||||
local proxy_ctid="${DETECTED_CTID:-<your-container-id>}"
|
||||
echo
|
||||
echo -e "${YELLOW}Temperature monitoring:${NC}"
|
||||
echo " Run on the Proxmox host to enable secure temperature collection:"
|
||||
echo " curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \\"
|
||||
echo " bash -s -- --ctid ${proxy_ctid} --pulse-server ${PULSE_URL}"
|
||||
echo " See docs/TEMPERATURE_MONITORING.md for details."
|
||||
fi
|
||||
|
||||
# Show auto-update status if timer exists
|
||||
if systemctl list-unit-files --no-legend | grep -q "^pulse-update.timer"; then
|
||||
|
|
@ -3057,6 +3153,8 @@ while [[ $# -gt 0 ]]; do
|
|||
esac
|
||||
done
|
||||
|
||||
auto_detect_container_environment
|
||||
|
||||
# Export for use in download_pulse function
|
||||
export FORCE_VERSION FORCE_CHANNEL
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,10 @@ func (h *AlertHandlers) UpdateAlertConfig(w http.ResponseWriter, r *http.Request
|
|||
log.Error().Err(err).Msg("Failed to save alert configuration")
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, map[string]string{"status": "success"}); err != nil {
|
||||
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Alert configuration updated successfully",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write alert config update response")
|
||||
}
|
||||
}
|
||||
|
|
@ -111,9 +114,10 @@ func (h *AlertHandlers) ActivateAlerts(w http.ResponseWriter, r *http.Request) {
|
|||
// Check if already active
|
||||
if config.ActivationState == alerts.ActivationActive {
|
||||
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "Alerts already activated",
|
||||
"state": string(config.ActivationState),
|
||||
"success": true,
|
||||
"message": "Alerts already activated",
|
||||
"state": string(config.ActivationState),
|
||||
"activationTime": config.ActivationTime,
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write activate response")
|
||||
}
|
||||
|
|
@ -155,7 +159,7 @@ func (h *AlertHandlers) ActivateAlerts(w http.ResponseWriter, r *http.Request) {
|
|||
log.Info().Msg("Alert notifications activated")
|
||||
|
||||
if err := utils.WriteJSONResponse(w, map[string]interface{}{
|
||||
"status": "success",
|
||||
"success": true,
|
||||
"message": "Alert notifications activated",
|
||||
"state": string(config.ActivationState),
|
||||
"activationTime": config.ActivationTime,
|
||||
|
|
|
|||
|
|
@ -3104,41 +3104,117 @@ if [ "$EUID" -ne 0 ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Check if running inside a container (LXC/Docker)
|
||||
if [ -f /run/systemd/container ] || [ -f /.dockerenv ] || [ ! -z "${container:-}" ]; then
|
||||
echo ""
|
||||
echo "❌ ERROR: This script is running inside a container!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "This setup script must be run on the Proxmox VE host itself,"
|
||||
echo "not inside an LXC container or Docker container."
|
||||
echo ""
|
||||
echo "Please:"
|
||||
echo " 1. Exit this container (type 'exit')"
|
||||
echo " 2. Run the script directly on your Proxmox host"
|
||||
echo ""
|
||||
echo "The script needs access to 'pveum' commands which are only"
|
||||
echo "available on the Proxmox VE host system."
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
# Detect environment (Proxmox host vs LXC guest)
|
||||
detect_environment() {
|
||||
if command -v pveum >/dev/null 2>&1 && command -v pveversion >/dev/null 2>&1; then
|
||||
echo "pve_host"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if pveum command exists
|
||||
if ! command -v pveum &> /dev/null; then
|
||||
echo ""
|
||||
echo "❌ ERROR: 'pveum' command not found!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "This script must be run on a Proxmox VE host."
|
||||
echo "The 'pveum' command is required to create users and tokens."
|
||||
echo ""
|
||||
echo "If you're seeing this error, you might be:"
|
||||
echo " • Running on a non-Proxmox system"
|
||||
echo " • Inside an LXC container (exit and run on the host)"
|
||||
echo " • On a PBS server (use the PBS setup script instead)"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
if [ -f /proc/1/cgroup ] && grep -qE '/(lxc|machine\.slice/machine-lxc)' /proc/1/cgroup 2>/dev/null; then
|
||||
echo "lxc_guest"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||||
if systemd-detect-virt -q -c 2>/dev/null; then
|
||||
local virt_type
|
||||
virt_type=$(systemd-detect-virt -c 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
||||
if echo "$virt_type" | grep -q "lxc"; then
|
||||
echo "lxc_guest"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "unknown"
|
||||
}
|
||||
|
||||
detect_lxc_ctid() {
|
||||
local ctid=""
|
||||
if [ -f /proc/1/cgroup ]; then
|
||||
ctid=$(sed 's/\\x2d/-/g' /proc/1/cgroup 2>/dev/null | grep -Eo '(lxc|machine-lxc)-[0-9]+' | tail -n1 | grep -Eo '[0-9]+' | tail -n1)
|
||||
if [ -n "$ctid" ]; then
|
||||
echo "$ctid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v hostname >/dev/null 2>&1; then
|
||||
ctid=$(hostname 2>/dev/null)
|
||||
if echo "$ctid" | grep -qE '^[0-9]+$'; then
|
||||
echo "$ctid"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
ENVIRONMENT=$(detect_environment)
|
||||
|
||||
case "$ENVIRONMENT" in
|
||||
pve_host)
|
||||
echo "Detected Proxmox VE host environment."
|
||||
echo ""
|
||||
;;
|
||||
lxc_guest)
|
||||
echo "Detected Proxmox LXC container environment."
|
||||
echo ""
|
||||
LXC_CTID=$(detect_lxc_ctid)
|
||||
CTID_DISPLAY="$LXC_CTID"
|
||||
if [ -n "$LXC_CTID" ]; then
|
||||
echo " • Container ID: $LXC_CTID"
|
||||
else
|
||||
CTID_DISPLAY="<your-pulse-container-id>"
|
||||
echo " • Unable to auto-detect container ID."
|
||||
echo " Replace '${CTID_DISPLAY}' in the commands below with your container ID."
|
||||
fi
|
||||
echo ""
|
||||
echo "Run the following commands on your Proxmox host to continue:"
|
||||
echo ""
|
||||
cat <<EOF
|
||||
# 1) Create or reuse the Pulse monitoring API token
|
||||
pveum user add pulse-monitor@pam --comment "Pulse monitoring service"
|
||||
pveum aclmod / -user pulse-monitor@pam -role PVEAuditor
|
||||
pveum user token add pulse-monitor@pam %s --privsep 0
|
||||
|
||||
# 2) Install or update pulse-sensor-proxy on the host
|
||||
curl -sSL "%s/api/install/install-sensor-proxy.sh" | bash -s -- --ctid ${CTID_DISPLAY} --pulse-server "%s"
|
||||
|
||||
# 3) Ensure the proxy socket is mounted into this container
|
||||
NEXT_MP=\$(pct config ${CTID_DISPLAY} | awk '\$1 ~ /^mp[0-9]+:/ && index(\$0, "mp=/mnt/pulse-proxy") {gsub(":", "", \$1); print \$1; exit}')
|
||||
if [ -z "\$NEXT_MP" ]; then NEXT_MP="mp0"; fi
|
||||
pct set ${CTID_DISPLAY} -\${NEXT_MP} /run/pulse-sensor-proxy,mp=/mnt/pulse-proxy,replicate=0
|
||||
pct exec ${CTID_DISPLAY} -- test -S /mnt/pulse-proxy/pulse-sensor-proxy.sock && echo "Socket OK"
|
||||
|
||||
EOF
|
||||
echo "For the simplest experience, run this script on your Proxmox host instead:"
|
||||
echo " curl -sSL \"%s/api/setup-script?type=pve&host=%s&pulse_url=%s\" | bash"
|
||||
echo ""
|
||||
echo "Exiting without error. Re-run after completing the host steps."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "This script requires Proxmox host tooling (pveum)."
|
||||
echo ""
|
||||
echo "Run on your Proxmox host:"
|
||||
echo " curl -sSL \"%s/api/setup-script?type=pve&host=%s&pulse_url=%s\" | bash"
|
||||
echo ""
|
||||
echo "Manual setup steps:"
|
||||
echo " 1. On Proxmox host, create API token:"
|
||||
echo " pveum user add pulse-monitor@pam --comment \"Pulse monitoring service\""
|
||||
echo " pveum aclmod / -user pulse-monitor@pam -role PVEAuditor"
|
||||
echo " pveum user token add pulse-monitor@pam %s --privsep 0"
|
||||
echo ""
|
||||
echo " 2. In Pulse: Settings → Nodes → Add Node (enter token from above)"
|
||||
echo ""
|
||||
echo " 3. (Optional) For temperature monitoring on containerized Pulse:"
|
||||
echo " %s/api/install/install-sensor-proxy.sh --ctid <your-pulse-container-id> --pulse-server %s"
|
||||
echo ""
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Main Menu
|
||||
|
|
@ -4345,11 +4421,14 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then
|
|||
echo " Host URL: YOUR_PROXMOX_HOST:8006"
|
||||
echo ""
|
||||
fi
|
||||
`, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP,
|
||||
`, serverName, time.Now().Format("2006-01-02 15:04:05"),
|
||||
tokenName, pulseURL, pulseURL, pulseURL, serverHost, pulseURL,
|
||||
pulseURL, serverHost, pulseURL, tokenName, pulseURL, pulseURL,
|
||||
pulseIP,
|
||||
tokenName, tokenName, tokenName, tokenName, tokenName, tokenName,
|
||||
authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms,
|
||||
sshKeys.ProxyPublicKey, sshKeys.SensorsPublicKey, minProxyReadyVersion,
|
||||
pulseURL, "%s", "%s", pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, authToken, pulseURL, authToken, pulseURL, tokenName)
|
||||
pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, authToken, pulseURL, authToken, pulseURL, tokenName)
|
||||
|
||||
} else { // PBS
|
||||
script = fmt.Sprintf(`#!/bin/bash
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ type Config struct {
|
|||
AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"`
|
||||
|
||||
// Discovery settings
|
||||
DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"true"`
|
||||
DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"false"`
|
||||
DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"`
|
||||
Discovery DiscoveryConfig `json:"discoveryConfig"`
|
||||
|
||||
|
|
@ -511,7 +511,7 @@ func Load() (*Config, error) {
|
|||
IframeEmbeddingAllow: "SAMEORIGIN",
|
||||
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
|
||||
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
|
||||
DiscoveryEnabled: true,
|
||||
DiscoveryEnabled: false,
|
||||
DiscoverySubnet: "auto",
|
||||
EnvOverrides: make(map[string]bool),
|
||||
OIDC: NewOIDCConfig(),
|
||||
|
|
|
|||
|
|
@ -717,7 +717,7 @@ func DefaultSystemSettings() *SystemSettings {
|
|||
PBSPollingInterval: 60,
|
||||
PMGPollingInterval: 60,
|
||||
AutoUpdateEnabled: false,
|
||||
DiscoveryEnabled: true,
|
||||
DiscoveryEnabled: false,
|
||||
DiscoverySubnet: "auto",
|
||||
DiscoveryConfig: defaultDiscovery,
|
||||
AllowEmbedding: false,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,24 @@ type DiscoveryResult struct {
|
|||
Environment *EnvironmentInfo `json:"environment,omitempty"`
|
||||
}
|
||||
|
||||
// friendlyPhaseName converts technical phase names to user-friendly descriptions
|
||||
func friendlyPhaseName(phase string) string {
|
||||
friendlyNames := map[string]string{
|
||||
"lxc_container_network": "Container network",
|
||||
"docker_bridge_network": "Docker bridge network",
|
||||
"docker_container_network": "Docker container network",
|
||||
"host_local_network": "Local network",
|
||||
"inferred_gateway_network": "Gateway network",
|
||||
"extra_targets": "Additional targets",
|
||||
"proxmox_cluster_network": "Proxmox cluster network",
|
||||
}
|
||||
|
||||
if friendly, ok := friendlyNames[phase]; ok {
|
||||
return friendly
|
||||
}
|
||||
return phase
|
||||
}
|
||||
|
||||
// AddError adds a structured error to the result (also maintains backward-compatible error list)
|
||||
func (r *DiscoveryResult) AddError(phase, errorType, message, ip string, port int) {
|
||||
structuredErr := DiscoveryError{
|
||||
|
|
@ -60,13 +78,14 @@ func (r *DiscoveryResult) AddError(phase, errorType, message, ip string, port in
|
|||
}
|
||||
r.StructuredErrors = append(r.StructuredErrors, structuredErr)
|
||||
|
||||
// Also add to legacy errors for backward compatibility
|
||||
// Also add to legacy errors for backward compatibility (use friendly phase name for display)
|
||||
friendlyPhase := friendlyPhaseName(phase)
|
||||
if ip != "" && port > 0 {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("%s [%s:%d]: %s", phase, ip, port, message))
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("%s [%s:%d]: %s", friendlyPhase, ip, port, message))
|
||||
} else if ip != "" {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("%s [%s]: %s", phase, ip, message))
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("%s [%s]: %s", friendlyPhase, ip, message))
|
||||
} else {
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", phase, message))
|
||||
r.Errors = append(r.Errors, fmt.Sprintf("%s: %s", friendlyPhase, message))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,12 +278,16 @@ func (s *Scanner) DiscoverServersWithCallbacks(ctx context.Context, subnet strin
|
|||
Msg("Starting discovery for explicit extra targets")
|
||||
if err := s.runPhaseWithProgress(ctx, "extra_targets", phaseNumber, totalPhases, extraIPs, serverCallback, progressCallback, &totalProcessed, totalTargets, result); err != nil {
|
||||
errType := "phase_error"
|
||||
errMsg := err.Error()
|
||||
|
||||
if errors.Is(err, context.Canceled) {
|
||||
errType = "canceled"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
errType = "timeout"
|
||||
// Provide user-friendly timeout message
|
||||
errMsg = fmt.Sprintf("Scan timed out after 2 minutes - this is normal for large networks. Servers found in earlier phases are still available.")
|
||||
}
|
||||
result.AddError("extra_targets", errType, err.Error(), "", 0)
|
||||
result.AddError("extra_targets", errType, errMsg, "", 0)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
|
@ -303,12 +326,16 @@ func (s *Scanner) DiscoverServersWithCallbacks(ctx context.Context, subnet strin
|
|||
|
||||
if err := s.runPhaseWithProgress(ctx, phase.Name, phaseNumber, totalPhases, phaseIPs, serverCallback, progressCallback, &totalProcessed, totalTargets, result); err != nil {
|
||||
errType := "phase_error"
|
||||
errMsg := err.Error()
|
||||
|
||||
if errors.Is(err, context.Canceled) {
|
||||
errType = "canceled"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
errType = "timeout"
|
||||
// Provide user-friendly timeout message
|
||||
errMsg = fmt.Sprintf("Scan timed out after 2 minutes - this is normal for large networks. Servers found in earlier phases are still available.")
|
||||
}
|
||||
result.AddError(phase.Name, errType, err.Error(), "", 0)
|
||||
result.AddError(phase.Name, errType, errMsg, "", 0)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
|
|
|||
50
scripts/codex-router.sh
Executable file
50
scripts/codex-router.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/bin/bash
|
||||
# Heuristic router for selecting a Codex reasoning tier.
|
||||
# Usage: codex-router.sh "Fix the typo" or echo "..." | codex-router.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORKDIR="/opt/pulse"
|
||||
|
||||
# Collect a prompt from stdin and/or arguments.
|
||||
PROMPT_INPUT=""
|
||||
if [[ ! -t 0 ]]; then
|
||||
PROMPT_INPUT="$(cat)"
|
||||
fi
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
if [[ -n "${PROMPT_INPUT}" ]]; then
|
||||
PROMPT="${PROMPT_INPUT}"$'\n'"$*"
|
||||
else
|
||||
PROMPT="$*"
|
||||
fi
|
||||
else
|
||||
PROMPT="${PROMPT_INPUT}"
|
||||
fi
|
||||
|
||||
PROMPT="$(printf '%s' "${PROMPT}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
|
||||
if [[ -z "${PROMPT}" ]]; then
|
||||
echo "Usage: $0 <prompt>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
reasoning_profile="low"
|
||||
|
||||
# Escalate when the task hints at analysis, design, or larger edits.
|
||||
if [[ ${#PROMPT} -gt 400 ]] \
|
||||
|| grep -qiE '(analysis|investigate|explain|design|architecture|strategy|refactor|spec|diagnos|trade[- ]?off|postmortem)' <<<"${PROMPT}"; then
|
||||
reasoning_profile="medium"
|
||||
fi
|
||||
|
||||
case "${reasoning_profile}" in
|
||||
medium)
|
||||
profile_arg=(--profile medium)
|
||||
;;
|
||||
*)
|
||||
profile_arg=(--profile low)
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Routing prompt to Codex profile '${reasoning_profile}'" >&2
|
||||
exec codex exec "${profile_arg[@]}" -C "${WORKDIR}" "${PROMPT}"
|
||||
|
|
@ -6,6 +6,387 @@ set -euo pipefail
|
|||
|
||||
PULSE_IMAGE="${PULSE_IMAGE:-rcourtman/pulse:latest}"
|
||||
PULSE_PORT="${PULSE_PORT:-7655}"
|
||||
PULSE_PROXY_CHANNEL="${PULSE_PROXY_CHANNEL:-stable}"
|
||||
DETERMINED_PROXY_VERSION=""
|
||||
PROXY_INSTALLER_SOURCE_LABEL="unset"
|
||||
PROXY_ARCH_LABEL=""
|
||||
|
||||
compute_proxy_arch_label() {
|
||||
local arch
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64)
|
||||
PROXY_ARCH_LABEL="linux-amd64"
|
||||
;;
|
||||
aarch64 | arm64)
|
||||
PROXY_ARCH_LABEL="linux-arm64"
|
||||
;;
|
||||
armv7l | armhf)
|
||||
PROXY_ARCH_LABEL="linux-armv7"
|
||||
;;
|
||||
*)
|
||||
PROXY_ARCH_LABEL=""
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
set_github_fallback_url() {
|
||||
local ref="$1"
|
||||
if [[ -z "$PROXY_ARCH_LABEL" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local base=""
|
||||
if [[ "$ref" == "main" ]]; then
|
||||
base="https://raw.githubusercontent.com/rcourtman/Pulse/${ref}/release/pulse-sensor-proxy-${PROXY_ARCH_LABEL}"
|
||||
else
|
||||
base="https://github.com/rcourtman/Pulse/releases/download/${ref}/pulse-sensor-proxy-${PROXY_ARCH_LABEL}"
|
||||
fi
|
||||
|
||||
export PULSE_SENSOR_PROXY_FALLBACK_URL="$base"
|
||||
}
|
||||
|
||||
EMBEDDED_INSTALLER_ARCHIVE_B64=$(cat <<'EOF'
|
||||
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
|
||||
EOF
|
||||
)
|
||||
|
||||
determine_proxy_release() {
|
||||
if [[ -n "${PULSE_PROXY_VERSION:-}" ]]; then
|
||||
DETERMINED_PROXY_VERSION="${PULSE_PROXY_VERSION}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local channel="${PULSE_PROXY_CHANNEL:-stable}"
|
||||
local tag=""
|
||||
local api_url=""
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [[ "$channel" == "rc" ]]; then
|
||||
api_url="https://api.github.com/repos/rcourtman/Pulse/releases"
|
||||
tag=$(curl -fsSL --connect-timeout 5 --max-time 15 "$api_url" 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
|
||||
else
|
||||
api_url="https://api.github.com/repos/rcourtman/Pulse/releases/latest"
|
||||
tag=$(curl -fsSL --connect-timeout 5 --max-time 15 "$api_url" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$tag" ]]; then
|
||||
DETERMINED_PROXY_VERSION="$tag"
|
||||
return 0
|
||||
fi
|
||||
|
||||
DETERMINED_PROXY_VERSION="main"
|
||||
return 1
|
||||
}
|
||||
|
||||
download_installer_from_local() {
|
||||
local destination="$1"
|
||||
local base_url="${PULSE_SERVER:-http://localhost:7655}"
|
||||
local url="${base_url%/}/api/install/install-sensor-proxy.sh"
|
||||
|
||||
if curl --fail --silent --location --connect-timeout 5 --max-time 20 "$url" -o "$destination" 2>/dev/null; then
|
||||
chmod +x "$destination"
|
||||
PROXY_INSTALLER_SOURCE_LABEL="local"
|
||||
export PULSE_SENSOR_PROXY_FALLBACK_URL="${base_url%/}/api/install/pulse-sensor-proxy"
|
||||
echo " ✓ Downloaded proxy installer from ${url}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " ⚠️ Unable to download proxy installer from ${url}"
|
||||
return 1
|
||||
}
|
||||
|
||||
download_installer_from_github() {
|
||||
local destination="$1"
|
||||
determine_proxy_release
|
||||
local tag="$DETERMINED_PROXY_VERSION"
|
||||
local ref="$tag"
|
||||
local attempted=false
|
||||
|
||||
if [[ "$tag" == "main" ]]; then
|
||||
ref="main"
|
||||
fi
|
||||
|
||||
if [[ "$tag" != "main" ]]; then
|
||||
local asset_url="https://github.com/rcourtman/Pulse/releases/download/${tag}/install-sensor-proxy.sh"
|
||||
attempted=true
|
||||
if curl --fail --silent --location --connect-timeout 5 --max-time 30 "$asset_url" -o "$destination" 2>/dev/null; then
|
||||
chmod +x "$destination"
|
||||
PROXY_INSTALLER_SOURCE_LABEL="github:${tag}"
|
||||
set_github_fallback_url "$tag"
|
||||
echo " ✓ Downloaded proxy installer from ${asset_url}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local raw_url="https://raw.githubusercontent.com/rcourtman/Pulse/${ref}/scripts/install-sensor-proxy.sh"
|
||||
attempted=true
|
||||
if curl --fail --silent --location --connect-timeout 5 --max-time 30 "$raw_url" -o "$destination" 2>/dev/null; then
|
||||
chmod +x "$destination"
|
||||
PROXY_INSTALLER_SOURCE_LABEL="github:${ref}"
|
||||
set_github_fallback_url "$ref"
|
||||
echo " ✓ Downloaded proxy installer from ${raw_url}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$attempted" == true ]]; then
|
||||
echo " ⚠️ Unable to download proxy installer from GitHub (tag=${tag})"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
write_embedded_installer() {
|
||||
local destination="$1"
|
||||
|
||||
if ! command -v base64 >/dev/null 2>&1; then
|
||||
echo " ⚠️ base64 command not available; cannot use embedded installer"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v gzip >/dev/null 2>&1; then
|
||||
echo " ⚠️ gzip command not available; cannot use embedded installer"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local decode_output
|
||||
if decode_output=$(printf '%s' "$EMBEDDED_INSTALLER_ARCHIVE_B64" | base64 -d 2>&1 | gzip -dc 2>&1 >"$destination"); then
|
||||
chmod +x "$destination"
|
||||
PROXY_INSTALLER_SOURCE_LABEL="embedded"
|
||||
echo " ✓ Used embedded proxy installer fallback"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " ⚠️ Embedded proxy installer fallback failed"
|
||||
if [[ -n "$decode_output" ]]; then
|
||||
echo " → $decode_output"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
download_proxy_installer() {
|
||||
local destination="$1"
|
||||
|
||||
if download_installer_from_local "$destination"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Attempting GitHub fallback..."
|
||||
if download_installer_from_github "$destination"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Attempting embedded installer fallback..."
|
||||
if write_embedded_installer "$destination"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Helper Functions
|
||||
|
|
@ -36,6 +417,8 @@ validate_socket() {
|
|||
# Pre-flight Checks
|
||||
# ============================================
|
||||
|
||||
compute_proxy_arch_label
|
||||
|
||||
echo "============================================"
|
||||
echo " Pulse Turnkey Docker Installation"
|
||||
echo "============================================"
|
||||
|
|
@ -134,23 +517,40 @@ else
|
|||
|
||||
# Download and run the proxy installer
|
||||
PROXY_INSTALLER="/tmp/install-sensor-proxy-$$.sh"
|
||||
INSTALLER_URL="${PULSE_SERVER:-http://localhost:7655}/api/install/install-sensor-proxy.sh"
|
||||
|
||||
if ! curl --fail --silent --location "$INSTALLER_URL" -o "$PROXY_INSTALLER" 2>/dev/null; then
|
||||
echo "❌ ERROR: Could not download installer from Pulse server"
|
||||
unset PULSE_SENSOR_PROXY_FALLBACK_URL
|
||||
|
||||
if ! download_proxy_installer "$PROXY_INSTALLER"; then
|
||||
echo "❌ ERROR: Could not obtain pulse-sensor-proxy installer"
|
||||
echo ""
|
||||
echo "Please ensure:"
|
||||
echo " 1. Pulse server is running at ${PULSE_SERVER:-http://localhost:7655}"
|
||||
echo " 2. Network connectivity is available"
|
||||
echo "Sources attempted:"
|
||||
echo " • ${PULSE_SERVER:-http://localhost:7655}/api/install/install-sensor-proxy.sh"
|
||||
if [[ -n "$DETERMINED_PROXY_VERSION" ]]; then
|
||||
echo " • GitHub release/tag: ${DETERMINED_PROXY_VERSION}"
|
||||
else
|
||||
echo " • GitHub releases (auto-detected)"
|
||||
fi
|
||||
echo " • Embedded fallback installer"
|
||||
echo ""
|
||||
echo "Alternatively, download from GitHub releases (coming soon)"
|
||||
echo "Hints:"
|
||||
echo " 1. Ensure network connectivity to the Pulse host or GitHub."
|
||||
echo " 2. Set PULSE_PROXY_VERSION (e.g. v4.24.0) to pin a specific release."
|
||||
echo " 3. Confirm curl is allowed through firewalls/proxies."
|
||||
rm -f "$PROXY_INSTALLER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x "$PROXY_INSTALLER"
|
||||
|
||||
# Set fallback URL for proxy binary download
|
||||
export PULSE_SENSOR_PROXY_FALLBACK_URL="${PULSE_SERVER:-http://localhost:7655}/api/install/pulse-sensor-proxy"
|
||||
case "$PROXY_INSTALLER_SOURCE_LABEL" in
|
||||
local)
|
||||
# already set during download
|
||||
;;
|
||||
github:*)
|
||||
# fallback URL set in download helper
|
||||
;;
|
||||
*)
|
||||
unset PULSE_SENSOR_PROXY_FALLBACK_URL
|
||||
;;
|
||||
esac
|
||||
|
||||
# Run installer in standalone mode (no container)
|
||||
if ! "$PROXY_INSTALLER" --standalone --pulse-server "${PULSE_SERVER:-http://localhost:7655}" --quiet; then
|
||||
|
|
|
|||
|
|
@ -397,64 +397,155 @@ else
|
|||
;;
|
||||
esac
|
||||
|
||||
# If fallback URL is provided (e.g., from Pulse setup script), use it directly
|
||||
# Try GitHub first for releases
|
||||
GITHUB_REPO="rcourtman/Pulse"
|
||||
DOWNLOAD_SUCCESS=false
|
||||
ATTEMPTED_SOURCES=()
|
||||
LATEST_RELEASE_TAG=""
|
||||
|
||||
if [[ "$VERSION" == "latest" ]]; then
|
||||
RELEASE_URL="https://api.github.com/repos/$GITHUB_REPO/releases/latest"
|
||||
print_info "Fetching latest release info..."
|
||||
RELEASE_ERROR=$(mktemp)
|
||||
RELEASE_DATA=$(curl -fSL "$RELEASE_URL" 2>"$RELEASE_ERROR")
|
||||
CURL_EXIT=$?
|
||||
if [ $CURL_EXIT -ne 0 ] && [ -s "$RELEASE_ERROR" ]; then
|
||||
print_warn "Failed to fetch GitHub release info: $(cat "$RELEASE_ERROR")"
|
||||
fi
|
||||
rm -f "$RELEASE_ERROR"
|
||||
VERSION=$(echo "$RELEASE_DATA" | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
if [[ -n "$VERSION" ]]; then
|
||||
print_info "Latest version: $VERSION"
|
||||
DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION/$BINARY_NAME"
|
||||
print_info "Downloading $BINARY_NAME from GitHub..."
|
||||
DOWNLOAD_ERROR=$(mktemp)
|
||||
if curl -fSL "$DOWNLOAD_URL" -o "$BINARY_PATH.tmp" 2>"$DOWNLOAD_ERROR"; then
|
||||
DOWNLOAD_SUCCESS=true
|
||||
rm -f "$DOWNLOAD_ERROR"
|
||||
fetch_latest_release_tag() {
|
||||
local api_url="https://api.github.com/repos/$GITHUB_REPO/releases/latest"
|
||||
local tmp_err
|
||||
tmp_err=$(mktemp)
|
||||
local response
|
||||
response=$(curl --fail --silent --location --connect-timeout 10 --max-time 30 "$api_url" 2>"$tmp_err")
|
||||
local status=$?
|
||||
if [[ $status -ne 0 ]]; then
|
||||
if [[ -s "$tmp_err" ]]; then
|
||||
print_warn "Failed to resolve latest GitHub release: $(cat "$tmp_err")"
|
||||
else
|
||||
if [ -s "$DOWNLOAD_ERROR" ]; then
|
||||
print_warn "GitHub download failed: $(cat "$DOWNLOAD_ERROR")"
|
||||
fi
|
||||
rm -f "$DOWNLOAD_ERROR"
|
||||
print_warn "Failed to resolve latest GitHub release (HTTP $status)"
|
||||
fi
|
||||
rm -f "$tmp_err"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$tmp_err"
|
||||
local tag
|
||||
tag=$(echo "$response" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [[ -z "$tag" ]]; then
|
||||
print_warn "Could not parse latest release tag from GitHub response"
|
||||
return 1
|
||||
fi
|
||||
LATEST_RELEASE_TAG="$tag"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fall back to Pulse server if GitHub failed or fallback is provided
|
||||
if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$FALLBACK_BASE" ]]; then
|
||||
FALLBACK_URL="${FALLBACK_BASE%/}?arch=${ARCH_LABEL}"
|
||||
print_info "Downloading $BINARY_NAME from Pulse server..."
|
||||
FALLBACK_ERROR=$(mktemp)
|
||||
if curl -fSL "$FALLBACK_URL" -o "$BINARY_PATH.tmp" 2>"$FALLBACK_ERROR"; then
|
||||
attempt_github_asset_or_tarball() {
|
||||
local tag="$1"
|
||||
[[ -z "$tag" ]] && return 1
|
||||
|
||||
local asset_url="https://github.com/$GITHUB_REPO/releases/download/${tag}/${BINARY_NAME}"
|
||||
ATTEMPTED_SOURCES+=("GitHub release asset ${tag}")
|
||||
print_info "Downloading $BINARY_NAME from GitHub release ${tag}..."
|
||||
local tmp_err
|
||||
tmp_err=$(mktemp)
|
||||
if curl --fail --silent --location --connect-timeout 10 --max-time 120 "$asset_url" -o "$BINARY_PATH.tmp" 2>"$tmp_err"; then
|
||||
rm -f "$tmp_err"
|
||||
DOWNLOAD_SUCCESS=true
|
||||
rm -f "$FALLBACK_ERROR"
|
||||
else
|
||||
if [ -s "$FALLBACK_ERROR" ]; then
|
||||
print_error "Pulse server download failed: $(cat "$FALLBACK_ERROR")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local asset_error=""
|
||||
if [[ -s "$tmp_err" ]]; then
|
||||
asset_error="$(cat "$tmp_err")"
|
||||
fi
|
||||
rm -f "$tmp_err"
|
||||
rm -f "$BINARY_PATH.tmp" 2>/dev/null || true
|
||||
|
||||
local tarball_name="pulse-${tag}-linux-${ARCH_LABEL#linux-}.tar.gz"
|
||||
local tarball_url="https://github.com/$GITHUB_REPO/releases/download/${tag}/${tarball_name}"
|
||||
ATTEMPTED_SOURCES+=("GitHub release tarball ${tarball_name}")
|
||||
print_info "Downloading ${tarball_name} to extract pulse-sensor-proxy..."
|
||||
tmp_err=$(mktemp)
|
||||
local tarball_tmp
|
||||
tarball_tmp=$(mktemp)
|
||||
if curl --fail --silent --location --connect-timeout 10 --max-time 240 "$tarball_url" -o "$tarball_tmp" 2>"$tmp_err"; then
|
||||
if tar -tzf "$tarball_tmp" >/dev/null 2>&1 && tar -xzf "$tarball_tmp" -C "$(dirname "$tarball_tmp")" ./bin/pulse-sensor-proxy >/dev/null 2>&1; then
|
||||
mv "$(dirname "$tarball_tmp")/bin/pulse-sensor-proxy" "$BINARY_PATH.tmp"
|
||||
rm -f "$tarball_tmp" "$tmp_err"
|
||||
DOWNLOAD_SUCCESS=true
|
||||
return 0
|
||||
else
|
||||
print_warn "Release tarball did not contain expected ./bin/pulse-sensor-proxy"
|
||||
fi
|
||||
rm -f "$FALLBACK_ERROR"
|
||||
else
|
||||
if [[ -s "$tmp_err" ]]; then
|
||||
print_warn "Tarball download failed: $(cat "$tmp_err")"
|
||||
else
|
||||
print_warn "Tarball download failed (HTTP error)"
|
||||
fi
|
||||
fi
|
||||
rm -f "$tarball_tmp" "$tmp_err"
|
||||
if [[ -n "$asset_error" ]]; then
|
||||
print_warn "GitHub release asset error: $asset_error"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
REQUESTED_VERSION="${VERSION:-latest}"
|
||||
if [[ "$REQUESTED_VERSION" == "latest" || "$REQUESTED_VERSION" == "main" || -z "$REQUESTED_VERSION" ]]; then
|
||||
if fetch_latest_release_tag; then
|
||||
attempt_github_asset_or_tarball "$LATEST_RELEASE_TAG" || true
|
||||
fi
|
||||
else
|
||||
attempt_github_asset_or_tarball "$REQUESTED_VERSION" || true
|
||||
fi
|
||||
|
||||
if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$FALLBACK_BASE" ]]; then
|
||||
fallback_url="$FALLBACK_BASE"
|
||||
if [[ "$fallback_url" == *"?"* ]]; then
|
||||
fallback_url="$fallback_url"
|
||||
elif [[ "$fallback_url" == *"pulse-sensor-proxy-"* ]]; then
|
||||
fallback_url="${fallback_url}"
|
||||
else
|
||||
fallback_url="${fallback_url%/}?arch=${ARCH_LABEL}"
|
||||
fi
|
||||
|
||||
ATTEMPTED_SOURCES+=("Fallback ${fallback_url}")
|
||||
print_info "Downloading $BINARY_NAME from fallback source..."
|
||||
fallback_err=$(mktemp)
|
||||
if curl --fail --silent --location --connect-timeout 10 --max-time 120 "$fallback_url" -o "$BINARY_PATH.tmp" 2>"$fallback_err"; then
|
||||
rm -f "$fallback_err"
|
||||
DOWNLOAD_SUCCESS=true
|
||||
else
|
||||
if [[ -s "$fallback_err" ]]; then
|
||||
print_error "Fallback download failed: $(cat "$fallback_err")"
|
||||
else
|
||||
print_error "Fallback download failed (HTTP error)"
|
||||
fi
|
||||
rm -f "$fallback_err"
|
||||
rm -f "$BINARY_PATH.tmp" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Exit if both methods failed
|
||||
if [[ "$DOWNLOAD_SUCCESS" != true ]] && [[ -n "$CTID" ]] && command -v pct >/dev/null 2>&1; then
|
||||
pull_targets=(
|
||||
"/opt/pulse/bin/${BINARY_NAME}"
|
||||
"/opt/pulse/bin/pulse-sensor-proxy"
|
||||
)
|
||||
for src in "${pull_targets[@]}"; do
|
||||
tmp_pull=$(mktemp)
|
||||
if pct pull "$CTID" "$src" "$tmp_pull" >/dev/null 2>&1; then
|
||||
mv "$tmp_pull" "$BINARY_PATH.tmp"
|
||||
print_info "Copied pulse-sensor-proxy binary from container $CTID ($src)"
|
||||
DOWNLOAD_SUCCESS=true
|
||||
break
|
||||
fi
|
||||
rm -f "$tmp_pull"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$DOWNLOAD_SUCCESS" != true ]]; then
|
||||
print_error "Failed to download binary from GitHub and Pulse server"
|
||||
print_error "Check network connectivity and firewall rules"
|
||||
print_error "Unable to download pulse-sensor-proxy binary."
|
||||
if [[ ${#ATTEMPTED_SOURCES[@]} -gt 0 ]]; then
|
||||
print_error "Sources attempted:"
|
||||
for src in "${ATTEMPTED_SOURCES[@]}"; do
|
||||
print_error " - $src"
|
||||
done
|
||||
fi
|
||||
print_error "Publish a GitHub release with binary assets or ensure a Pulse server is reachable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make executable and move to final location
|
||||
chmod +x "$BINARY_PATH.tmp"
|
||||
mv "$BINARY_PATH.tmp" "$BINARY_PATH"
|
||||
print_info "Binary installed to $BINARY_PATH"
|
||||
|
|
|
|||
Loading…
Reference in a new issue