chore: remove pulse-test-staging directory

- Clean up temporary test staging files
- Remove accidentally committed staging directory
This commit is contained in:
Pulse Monitor 2025-08-04 08:12:38 +00:00
parent 8a40db959c
commit 994d5eeeaa
68 changed files with 0 additions and 18755 deletions

View file

@ -1 +0,0 @@
4.0.0-rc.1-test

View file

@ -1,32 +0,0 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

View file

@ -1,31 +0,0 @@
{
"hash": "c68cb1be",
"configHash": "5ad14a4a",
"lockfileHash": "ed97e1da",
"browserHash": "e2aaadd5",
"optimized": {
"solid-js/web": {
"src": "../../node_modules/solid-js/web/dist/dev.js",
"file": "solid-js_web.js",
"fileHash": "772f198c",
"needsInterop": false
},
"solid-js": {
"src": "../../node_modules/solid-js/dist/dev.js",
"file": "solid-js.js",
"fileHash": "c1c8cb80",
"needsInterop": false
},
"solid-js/store": {
"src": "../../node_modules/solid-js/store/dist/dev.js",
"file": "solid-js_store.js",
"fileHash": "a2c5a714",
"needsInterop": false
}
},
"chunks": {
"chunk-IZS3CG5R": {
"file": "chunk-IZS3CG5R.js"
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,3 +0,0 @@
{
"type": "module"
}

View file

@ -1,113 +0,0 @@
import {
$DEVCOMP,
$PROXY,
$TRACK,
DEV,
ErrorBoundary,
For,
Index,
Match,
Show,
Suspense,
SuspenseList,
Switch,
batch,
cancelCallback,
catchError,
children,
createComponent,
createComputed,
createContext,
createDeferred,
createEffect,
createMemo,
createReaction,
createRenderEffect,
createResource,
createRoot,
createSelector,
createSignal,
createUniqueId,
enableExternalSource,
enableHydration,
enableScheduling,
equalFn,
from,
getListener,
getOwner,
indexArray,
lazy,
mapArray,
mergeProps,
observable,
on,
onCleanup,
onError,
onMount,
requestCallback,
resetErrorBoundaries,
runWithOwner,
sharedConfig,
splitProps,
startTransition,
untrack,
useContext,
useTransition
} from "./chunk-IZS3CG5R.js";
export {
$DEVCOMP,
$PROXY,
$TRACK,
DEV,
ErrorBoundary,
For,
Index,
Match,
Show,
Suspense,
SuspenseList,
Switch,
batch,
cancelCallback,
catchError,
children,
createComponent,
createComputed,
createContext,
createDeferred,
createEffect,
createMemo,
createReaction,
createRenderEffect,
createResource,
createRoot,
createSelector,
createSignal,
createUniqueId,
enableExternalSource,
enableHydration,
enableScheduling,
equalFn,
from,
getListener,
getOwner,
indexArray,
lazy,
mapArray,
mergeProps,
observable,
on,
onCleanup,
onError,
onMount,
requestCallback,
resetErrorBoundaries,
runWithOwner,
sharedConfig,
splitProps,
startTransition,
untrack,
useContext,
useTransition
};
//# sourceMappingURL=solid-js.js.map

View file

@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View file

@ -1,451 +0,0 @@
import {
$PROXY,
$TRACK,
DEV,
batch,
createSignal,
getListener
} from "./chunk-IZS3CG5R.js";
// node_modules/solid-js/store/dist/dev.js
var $RAW = Symbol("store-raw");
var $NODE = Symbol("store-node");
var $HAS = Symbol("store-has");
var $SELF = Symbol("store-self");
var DevHooks = {
onStoreNodeUpdate: null
};
function wrap$1(value) {
let p = value[$PROXY];
if (!p) {
Object.defineProperty(value, $PROXY, {
value: p = new Proxy(value, proxyTraps$1)
});
if (!Array.isArray(value)) {
const keys = Object.keys(value), desc = Object.getOwnPropertyDescriptors(value);
for (let i = 0, l = keys.length; i < l; i++) {
const prop = keys[i];
if (desc[prop].get) {
Object.defineProperty(value, prop, {
enumerable: desc[prop].enumerable,
get: desc[prop].get.bind(p)
});
}
}
}
}
return p;
}
function isWrappable(obj) {
let proto;
return obj != null && typeof obj === "object" && (obj[$PROXY] || !(proto = Object.getPrototypeOf(obj)) || proto === Object.prototype || Array.isArray(obj));
}
function unwrap(item, set = /* @__PURE__ */ new Set()) {
let result, unwrapped, v, prop;
if (result = item != null && item[$RAW]) return result;
if (!isWrappable(item) || set.has(item)) return item;
if (Array.isArray(item)) {
if (Object.isFrozen(item)) item = item.slice(0);
else set.add(item);
for (let i = 0, l = item.length; i < l; i++) {
v = item[i];
if ((unwrapped = unwrap(v, set)) !== v) item[i] = unwrapped;
}
} else {
if (Object.isFrozen(item)) item = Object.assign({}, item);
else set.add(item);
const keys = Object.keys(item), desc = Object.getOwnPropertyDescriptors(item);
for (let i = 0, l = keys.length; i < l; i++) {
prop = keys[i];
if (desc[prop].get) continue;
v = item[prop];
if ((unwrapped = unwrap(v, set)) !== v) item[prop] = unwrapped;
}
}
return item;
}
function getNodes(target, symbol) {
let nodes = target[symbol];
if (!nodes) Object.defineProperty(target, symbol, {
value: nodes = /* @__PURE__ */ Object.create(null)
});
return nodes;
}
function getNode(nodes, property, value) {
if (nodes[property]) return nodes[property];
const [s, set] = createSignal(value, {
equals: false,
internal: true
});
s.$ = set;
return nodes[property] = s;
}
function proxyDescriptor$1(target, property) {
const desc = Reflect.getOwnPropertyDescriptor(target, property);
if (!desc || desc.get || !desc.configurable || property === $PROXY || property === $NODE) return desc;
delete desc.value;
delete desc.writable;
desc.get = () => target[$PROXY][property];
return desc;
}
function trackSelf(target) {
getListener() && getNode(getNodes(target, $NODE), $SELF)();
}
function ownKeys(target) {
trackSelf(target);
return Reflect.ownKeys(target);
}
var proxyTraps$1 = {
get(target, property, receiver) {
if (property === $RAW) return target;
if (property === $PROXY) return receiver;
if (property === $TRACK) {
trackSelf(target);
return receiver;
}
const nodes = getNodes(target, $NODE);
const tracked = nodes[property];
let value = tracked ? tracked() : target[property];
if (property === $NODE || property === $HAS || property === "__proto__") return value;
if (!tracked) {
const desc = Object.getOwnPropertyDescriptor(target, property);
if (getListener() && (typeof value !== "function" || target.hasOwnProperty(property)) && !(desc && desc.get)) value = getNode(nodes, property, value)();
}
return isWrappable(value) ? wrap$1(value) : value;
},
has(target, property) {
if (property === $RAW || property === $PROXY || property === $TRACK || property === $NODE || property === $HAS || property === "__proto__") return true;
getListener() && getNode(getNodes(target, $HAS), property)();
return property in target;
},
set() {
console.warn("Cannot mutate a Store directly");
return true;
},
deleteProperty() {
console.warn("Cannot mutate a Store directly");
return true;
},
ownKeys,
getOwnPropertyDescriptor: proxyDescriptor$1
};
function setProperty(state, property, value, deleting = false) {
if (!deleting && state[property] === value) return;
const prev = state[property], len = state.length;
DevHooks.onStoreNodeUpdate && DevHooks.onStoreNodeUpdate(state, property, value, prev);
if (value === void 0) {
delete state[property];
if (state[$HAS] && state[$HAS][property] && prev !== void 0) state[$HAS][property].$();
} else {
state[property] = value;
if (state[$HAS] && state[$HAS][property] && prev === void 0) state[$HAS][property].$();
}
let nodes = getNodes(state, $NODE), node;
if (node = getNode(nodes, property, prev)) node.$(() => value);
if (Array.isArray(state) && state.length !== len) {
for (let i = state.length; i < len; i++) (node = nodes[i]) && node.$();
(node = getNode(nodes, "length", len)) && node.$(state.length);
}
(node = nodes[$SELF]) && node.$();
}
function mergeStoreNode(state, value) {
const keys = Object.keys(value);
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i];
setProperty(state, key, value[key]);
}
}
function updateArray(current, next) {
if (typeof next === "function") next = next(current);
next = unwrap(next);
if (Array.isArray(next)) {
if (current === next) return;
let i = 0, len = next.length;
for (; i < len; i++) {
const value = next[i];
if (current[i] !== value) setProperty(current, i, value);
}
setProperty(current, "length", len);
} else mergeStoreNode(current, next);
}
function updatePath(current, path, traversed = []) {
let part, prev = current;
if (path.length > 1) {
part = path.shift();
const partType = typeof part, isArray = Array.isArray(current);
if (Array.isArray(part)) {
for (let i = 0; i < part.length; i++) {
updatePath(current, [part[i]].concat(path), traversed);
}
return;
} else if (isArray && partType === "function") {
for (let i = 0; i < current.length; i++) {
if (part(current[i], i)) updatePath(current, [i].concat(path), traversed);
}
return;
} else if (isArray && partType === "object") {
const {
from = 0,
to = current.length - 1,
by = 1
} = part;
for (let i = from; i <= to; i += by) {
updatePath(current, [i].concat(path), traversed);
}
return;
} else if (path.length > 1) {
updatePath(current[part], path, [part].concat(traversed));
return;
}
prev = current[part];
traversed = [part].concat(traversed);
}
let value = path[0];
if (typeof value === "function") {
value = value(prev, traversed);
if (value === prev) return;
}
if (part === void 0 && value == void 0) return;
value = unwrap(value);
if (part === void 0 || isWrappable(prev) && isWrappable(value) && !Array.isArray(value)) {
mergeStoreNode(prev, value);
} else setProperty(current, part, value);
}
function createStore(...[store, options]) {
const unwrappedStore = unwrap(store || {});
const isArray = Array.isArray(unwrappedStore);
if (typeof unwrappedStore !== "object" && typeof unwrappedStore !== "function") throw new Error(`Unexpected type ${typeof unwrappedStore} received when initializing 'createStore'. Expected an object.`);
const wrappedStore = wrap$1(unwrappedStore);
DEV.registerGraph({
value: unwrappedStore,
name: options && options.name
});
function setStore(...args) {
batch(() => {
isArray && args.length === 1 ? updateArray(unwrappedStore, args[0]) : updatePath(unwrappedStore, args);
});
}
return [wrappedStore, setStore];
}
function proxyDescriptor(target, property) {
const desc = Reflect.getOwnPropertyDescriptor(target, property);
if (!desc || desc.get || desc.set || !desc.configurable || property === $PROXY || property === $NODE) return desc;
delete desc.value;
delete desc.writable;
desc.get = () => target[$PROXY][property];
desc.set = (v) => target[$PROXY][property] = v;
return desc;
}
var proxyTraps = {
get(target, property, receiver) {
if (property === $RAW) return target;
if (property === $PROXY) return receiver;
if (property === $TRACK) {
trackSelf(target);
return receiver;
}
const nodes = getNodes(target, $NODE);
const tracked = nodes[property];
let value = tracked ? tracked() : target[property];
if (property === $NODE || property === $HAS || property === "__proto__") return value;
if (!tracked) {
const desc = Object.getOwnPropertyDescriptor(target, property);
const isFunction = typeof value === "function";
if (getListener() && (!isFunction || target.hasOwnProperty(property)) && !(desc && desc.get)) value = getNode(nodes, property, value)();
else if (value != null && isFunction && value === Array.prototype[property]) {
return (...args) => batch(() => Array.prototype[property].apply(receiver, args));
}
}
return isWrappable(value) ? wrap(value) : value;
},
has(target, property) {
if (property === $RAW || property === $PROXY || property === $TRACK || property === $NODE || property === $HAS || property === "__proto__") return true;
getListener() && getNode(getNodes(target, $HAS), property)();
return property in target;
},
set(target, property, value) {
batch(() => setProperty(target, property, unwrap(value)));
return true;
},
deleteProperty(target, property) {
batch(() => setProperty(target, property, void 0, true));
return true;
},
ownKeys,
getOwnPropertyDescriptor: proxyDescriptor
};
function wrap(value) {
let p = value[$PROXY];
if (!p) {
Object.defineProperty(value, $PROXY, {
value: p = new Proxy(value, proxyTraps)
});
const keys = Object.keys(value), desc = Object.getOwnPropertyDescriptors(value);
const proto = Object.getPrototypeOf(value);
const isClass = proto !== null && value !== null && typeof value === "object" && !Array.isArray(value) && proto !== Object.prototype;
if (isClass) {
const descriptors = Object.getOwnPropertyDescriptors(proto);
keys.push(...Object.keys(descriptors));
Object.assign(desc, descriptors);
}
for (let i = 0, l = keys.length; i < l; i++) {
const prop = keys[i];
if (isClass && prop === "constructor") continue;
if (desc[prop].get) {
const get = desc[prop].get.bind(p);
Object.defineProperty(value, prop, {
get,
configurable: true
});
}
if (desc[prop].set) {
const og = desc[prop].set, set = (v) => batch(() => og.call(p, v));
Object.defineProperty(value, prop, {
set,
configurable: true
});
}
}
}
return p;
}
function createMutable(state, options) {
const unwrappedStore = unwrap(state || {});
if (typeof unwrappedStore !== "object" && typeof unwrappedStore !== "function") throw new Error(`Unexpected type ${typeof unwrappedStore} received when initializing 'createMutable'. Expected an object.`);
const wrappedStore = wrap(unwrappedStore);
DEV.registerGraph({
value: unwrappedStore,
name: options && options.name
});
return wrappedStore;
}
function modifyMutable(state, modifier) {
batch(() => modifier(unwrap(state)));
}
var $ROOT = Symbol("store-root");
function applyState(target, parent, property, merge, key) {
const previous = parent[property];
if (target === previous) return;
const isArray = Array.isArray(target);
if (property !== $ROOT && (!isWrappable(target) || !isWrappable(previous) || isArray !== Array.isArray(previous) || key && target[key] !== previous[key])) {
setProperty(parent, property, target);
return;
}
if (isArray) {
if (target.length && previous.length && (!merge || key && target[0] && target[0][key] != null)) {
let i, j, start, end, newEnd, item, newIndicesNext, keyVal;
for (start = 0, end = Math.min(previous.length, target.length); start < end && (previous[start] === target[start] || key && previous[start] && target[start] && previous[start][key] === target[start][key]); start++) {
applyState(target[start], previous, start, merge, key);
}
const temp = new Array(target.length), newIndices = /* @__PURE__ */ new Map();
for (end = previous.length - 1, newEnd = target.length - 1; end >= start && newEnd >= start && (previous[end] === target[newEnd] || key && previous[end] && target[newEnd] && previous[end][key] === target[newEnd][key]); end--, newEnd--) {
temp[newEnd] = previous[end];
}
if (start > newEnd || start > end) {
for (j = start; j <= newEnd; j++) setProperty(previous, j, target[j]);
for (; j < target.length; j++) {
setProperty(previous, j, temp[j]);
applyState(target[j], previous, j, merge, key);
}
if (previous.length > target.length) setProperty(previous, "length", target.length);
return;
}
newIndicesNext = new Array(newEnd + 1);
for (j = newEnd; j >= start; j--) {
item = target[j];
keyVal = key && item ? item[key] : item;
i = newIndices.get(keyVal);
newIndicesNext[j] = i === void 0 ? -1 : i;
newIndices.set(keyVal, j);
}
for (i = start; i <= end; i++) {
item = previous[i];
keyVal = key && item ? item[key] : item;
j = newIndices.get(keyVal);
if (j !== void 0 && j !== -1) {
temp[j] = previous[i];
j = newIndicesNext[j];
newIndices.set(keyVal, j);
}
}
for (j = start; j < target.length; j++) {
if (j in temp) {
setProperty(previous, j, temp[j]);
applyState(target[j], previous, j, merge, key);
} else setProperty(previous, j, target[j]);
}
} else {
for (let i = 0, len = target.length; i < len; i++) {
applyState(target[i], previous, i, merge, key);
}
}
if (previous.length > target.length) setProperty(previous, "length", target.length);
return;
}
const targetKeys = Object.keys(target);
for (let i = 0, len = targetKeys.length; i < len; i++) {
applyState(target[targetKeys[i]], previous, targetKeys[i], merge, key);
}
const previousKeys = Object.keys(previous);
for (let i = 0, len = previousKeys.length; i < len; i++) {
if (target[previousKeys[i]] === void 0) setProperty(previous, previousKeys[i], void 0);
}
}
function reconcile(value, options = {}) {
const {
merge,
key = "id"
} = options, v = unwrap(value);
return (state) => {
if (!isWrappable(state) || !isWrappable(v)) return v;
const res = applyState(v, {
[$ROOT]: state
}, $ROOT, merge, key);
return res === void 0 ? state : res;
};
}
var producers = /* @__PURE__ */ new WeakMap();
var setterTraps = {
get(target, property) {
if (property === $RAW) return target;
const value = target[property];
let proxy;
return isWrappable(value) ? producers.get(value) || (producers.set(value, proxy = new Proxy(value, setterTraps)), proxy) : value;
},
set(target, property, value) {
setProperty(target, property, unwrap(value));
return true;
},
deleteProperty(target, property) {
setProperty(target, property, void 0, true);
return true;
}
};
function produce(fn) {
return (state) => {
if (isWrappable(state)) {
let proxy;
if (!(proxy = producers.get(state))) {
producers.set(state, proxy = new Proxy(state, setterTraps));
}
fn(proxy);
}
return state;
};
}
var DEV2 = {
$NODE,
isWrappable,
hooks: DevHooks
};
export {
$RAW,
DEV2 as DEV,
createMutable,
createStore,
modifyMutable,
produce,
reconcile,
unwrap
};
//# sourceMappingURL=solid-js_store.js.map

File diff suppressed because one or more lines are too long

View file

@ -1,865 +0,0 @@
import {
$DEVCOMP,
ErrorBoundary,
For,
Index,
Match,
Show,
Suspense,
SuspenseList,
Switch,
createComponent,
createEffect,
createMemo,
createRenderEffect,
createRoot,
createSignal,
enableHydration,
getOwner,
mergeProps,
onCleanup,
runWithOwner,
sharedConfig,
splitProps,
untrack
} from "./chunk-IZS3CG5R.js";
// node_modules/solid-js/web/dist/dev.js
var booleans = ["allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "default", "disabled", "formnovalidate", "hidden", "indeterminate", "inert", "ismap", "loop", "multiple", "muted", "nomodule", "novalidate", "open", "playsinline", "readonly", "required", "reversed", "seamless", "selected"];
var Properties = /* @__PURE__ */ new Set(["className", "value", "readOnly", "noValidate", "formNoValidate", "isMap", "noModule", "playsInline", ...booleans]);
var ChildProperties = /* @__PURE__ */ new Set(["innerHTML", "textContent", "innerText", "children"]);
var Aliases = Object.assign(/* @__PURE__ */ Object.create(null), {
className: "class",
htmlFor: "for"
});
var PropAliases = Object.assign(/* @__PURE__ */ Object.create(null), {
class: "className",
novalidate: {
$: "noValidate",
FORM: 1
},
formnovalidate: {
$: "formNoValidate",
BUTTON: 1,
INPUT: 1
},
ismap: {
$: "isMap",
IMG: 1
},
nomodule: {
$: "noModule",
SCRIPT: 1
},
playsinline: {
$: "playsInline",
VIDEO: 1
},
readonly: {
$: "readOnly",
INPUT: 1,
TEXTAREA: 1
}
});
function getPropAlias(prop, tagName) {
const a = PropAliases[prop];
return typeof a === "object" ? a[tagName] ? a["$"] : void 0 : a;
}
var DelegatedEvents = /* @__PURE__ */ new Set(["beforeinput", "click", "dblclick", "contextmenu", "focusin", "focusout", "input", "keydown", "keyup", "mousedown", "mousemove", "mouseout", "mouseover", "mouseup", "pointerdown", "pointermove", "pointerout", "pointerover", "pointerup", "touchend", "touchmove", "touchstart"]);
var SVGElements = /* @__PURE__ */ new Set([
"altGlyph",
"altGlyphDef",
"altGlyphItem",
"animate",
"animateColor",
"animateMotion",
"animateTransform",
"circle",
"clipPath",
"color-profile",
"cursor",
"defs",
"desc",
"ellipse",
"feBlend",
"feColorMatrix",
"feComponentTransfer",
"feComposite",
"feConvolveMatrix",
"feDiffuseLighting",
"feDisplacementMap",
"feDistantLight",
"feDropShadow",
"feFlood",
"feFuncA",
"feFuncB",
"feFuncG",
"feFuncR",
"feGaussianBlur",
"feImage",
"feMerge",
"feMergeNode",
"feMorphology",
"feOffset",
"fePointLight",
"feSpecularLighting",
"feSpotLight",
"feTile",
"feTurbulence",
"filter",
"font",
"font-face",
"font-face-format",
"font-face-name",
"font-face-src",
"font-face-uri",
"foreignObject",
"g",
"glyph",
"glyphRef",
"hkern",
"image",
"line",
"linearGradient",
"marker",
"mask",
"metadata",
"missing-glyph",
"mpath",
"path",
"pattern",
"polygon",
"polyline",
"radialGradient",
"rect",
"set",
"stop",
"svg",
"switch",
"symbol",
"text",
"textPath",
"tref",
"tspan",
"use",
"view",
"vkern"
]);
var SVGNamespace = {
xlink: "http://www.w3.org/1999/xlink",
xml: "http://www.w3.org/XML/1998/namespace"
};
var DOMElements = /* @__PURE__ */ new Set(["html", "base", "head", "link", "meta", "style", "title", "body", "address", "article", "aside", "footer", "header", "main", "nav", "section", "body", "blockquote", "dd", "div", "dl", "dt", "figcaption", "figure", "hr", "li", "ol", "p", "pre", "ul", "a", "abbr", "b", "bdi", "bdo", "br", "cite", "code", "data", "dfn", "em", "i", "kbd", "mark", "q", "rp", "rt", "ruby", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", "area", "audio", "img", "map", "track", "video", "embed", "iframe", "object", "param", "picture", "portal", "source", "svg", "math", "canvas", "noscript", "script", "del", "ins", "caption", "col", "colgroup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "button", "datalist", "fieldset", "form", "input", "label", "legend", "meter", "optgroup", "option", "output", "progress", "select", "textarea", "details", "dialog", "menu", "summary", "details", "slot", "template", "acronym", "applet", "basefont", "bgsound", "big", "blink", "center", "content", "dir", "font", "frame", "frameset", "hgroup", "image", "keygen", "marquee", "menuitem", "nobr", "noembed", "noframes", "plaintext", "rb", "rtc", "shadow", "spacer", "strike", "tt", "xmp", "a", "abbr", "acronym", "address", "applet", "area", "article", "aside", "audio", "b", "base", "basefont", "bdi", "bdo", "bgsound", "big", "blink", "blockquote", "body", "br", "button", "canvas", "caption", "center", "cite", "code", "col", "colgroup", "content", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "dir", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption", "figure", "font", "footer", "form", "frame", "frameset", "head", "header", "hgroup", "hr", "html", "i", "iframe", "image", "img", "input", "ins", "kbd", "keygen", "label", "legend", "li", "link", "main", "map", "mark", "marquee", "menu", "menuitem", "meta", "meter", "nav", "nobr", "noembed", "noframes", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "plaintext", "portal", "pre", "progress", "q", "rb", "rp", "rt", "rtc", "ruby", "s", "samp", "script", "section", "select", "shadow", "slot", "small", "source", "spacer", "span", "strike", "strong", "style", "sub", "summary", "sup", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "tt", "u", "ul", "var", "video", "wbr", "xmp", "input", "h1", "h2", "h3", "h4", "h5", "h6"]);
var memo = (fn) => createMemo(() => fn());
function reconcileArrays(parentNode, a, b) {
let bLength = b.length, aEnd = a.length, bEnd = bLength, aStart = 0, bStart = 0, after = a[aEnd - 1].nextSibling, map = null;
while (aStart < aEnd || bStart < bEnd) {
if (a[aStart] === b[bStart]) {
aStart++;
bStart++;
continue;
}
while (a[aEnd - 1] === b[bEnd - 1]) {
aEnd--;
bEnd--;
}
if (aEnd === aStart) {
const node = bEnd < bLength ? bStart ? b[bStart - 1].nextSibling : b[bEnd - bStart] : after;
while (bStart < bEnd) parentNode.insertBefore(b[bStart++], node);
} else if (bEnd === bStart) {
while (aStart < aEnd) {
if (!map || !map.has(a[aStart])) a[aStart].remove();
aStart++;
}
} else if (a[aStart] === b[bEnd - 1] && b[bStart] === a[aEnd - 1]) {
const node = a[--aEnd].nextSibling;
parentNode.insertBefore(b[bStart++], a[aStart++].nextSibling);
parentNode.insertBefore(b[--bEnd], node);
a[aEnd] = b[bEnd];
} else {
if (!map) {
map = /* @__PURE__ */ new Map();
let i = bStart;
while (i < bEnd) map.set(b[i], i++);
}
const index = map.get(a[aStart]);
if (index != null) {
if (bStart < index && index < bEnd) {
let i = aStart, sequence = 1, t;
while (++i < aEnd && i < bEnd) {
if ((t = map.get(a[i])) == null || t !== index + sequence) break;
sequence++;
}
if (sequence > index - bStart) {
const node = a[aStart];
while (bStart < index) parentNode.insertBefore(b[bStart++], node);
} else parentNode.replaceChild(b[bStart++], a[aStart++]);
} else aStart++;
} else a[aStart++].remove();
}
}
}
var $$EVENTS = "_$DX_DELEGATE";
function render(code, element, init, options = {}) {
if (!element) {
throw new Error("The `element` passed to `render(..., element)` doesn't exist. Make sure `element` exists in the document.");
}
let disposer;
createRoot((dispose) => {
disposer = dispose;
element === document ? code() : insert(element, code(), element.firstChild ? null : void 0, init);
}, options.owner);
return () => {
disposer();
element.textContent = "";
};
}
function template(html, isImportNode, isSVG, isMathML) {
let node;
const create = () => {
if (isHydrating()) throw new Error("Failed attempt to create new DOM elements during hydration. Check that the libraries you are using support hydration.");
const t = isMathML ? document.createElementNS("http://www.w3.org/1998/Math/MathML", "template") : document.createElement("template");
t.innerHTML = html;
return isSVG ? t.content.firstChild.firstChild : isMathML ? t.firstChild : t.content.firstChild;
};
const fn = isImportNode ? () => untrack(() => document.importNode(node || (node = create()), true)) : () => (node || (node = create())).cloneNode(true);
fn.cloneNode = fn;
return fn;
}
function delegateEvents(eventNames, document2 = window.document) {
const e = document2[$$EVENTS] || (document2[$$EVENTS] = /* @__PURE__ */ new Set());
for (let i = 0, l = eventNames.length; i < l; i++) {
const name = eventNames[i];
if (!e.has(name)) {
e.add(name);
document2.addEventListener(name, eventHandler);
}
}
}
function clearDelegatedEvents(document2 = window.document) {
if (document2[$$EVENTS]) {
for (let name of document2[$$EVENTS].keys()) document2.removeEventListener(name, eventHandler);
delete document2[$$EVENTS];
}
}
function setProperty(node, name, value) {
if (isHydrating(node)) return;
node[name] = value;
}
function setAttribute(node, name, value) {
if (isHydrating(node)) return;
if (value == null) node.removeAttribute(name);
else node.setAttribute(name, value);
}
function setAttributeNS(node, namespace, name, value) {
if (isHydrating(node)) return;
if (value == null) node.removeAttributeNS(namespace, name);
else node.setAttributeNS(namespace, name, value);
}
function setBoolAttribute(node, name, value) {
if (isHydrating(node)) return;
value ? node.setAttribute(name, "") : node.removeAttribute(name);
}
function className(node, value) {
if (isHydrating(node)) return;
if (value == null) node.removeAttribute("class");
else node.className = value;
}
function addEventListener(node, name, handler, delegate) {
if (delegate) {
if (Array.isArray(handler)) {
node[`$$${name}`] = handler[0];
node[`$$${name}Data`] = handler[1];
} else node[`$$${name}`] = handler;
} else if (Array.isArray(handler)) {
const handlerFn = handler[0];
node.addEventListener(name, handler[0] = (e) => handlerFn.call(node, handler[1], e));
} else node.addEventListener(name, handler, typeof handler !== "function" && handler);
}
function classList(node, value, prev = {}) {
const classKeys = Object.keys(value || {}), prevKeys = Object.keys(prev);
let i, len;
for (i = 0, len = prevKeys.length; i < len; i++) {
const key = prevKeys[i];
if (!key || key === "undefined" || value[key]) continue;
toggleClassKey(node, key, false);
delete prev[key];
}
for (i = 0, len = classKeys.length; i < len; i++) {
const key = classKeys[i], classValue = !!value[key];
if (!key || key === "undefined" || prev[key] === classValue || !classValue) continue;
toggleClassKey(node, key, true);
prev[key] = classValue;
}
return prev;
}
function style(node, value, prev) {
if (!value) return prev ? setAttribute(node, "style") : value;
const nodeStyle = node.style;
if (typeof value === "string") return nodeStyle.cssText = value;
typeof prev === "string" && (nodeStyle.cssText = prev = void 0);
prev || (prev = {});
value || (value = {});
let v, s;
for (s in prev) {
value[s] == null && nodeStyle.removeProperty(s);
delete prev[s];
}
for (s in value) {
v = value[s];
if (v !== prev[s]) {
nodeStyle.setProperty(s, v);
prev[s] = v;
}
}
return prev;
}
function spread(node, props = {}, isSVG, skipChildren) {
const prevProps = {};
if (!skipChildren) {
createRenderEffect(() => prevProps.children = insertExpression(node, props.children, prevProps.children));
}
createRenderEffect(() => typeof props.ref === "function" && use(props.ref, node));
createRenderEffect(() => assign(node, props, isSVG, true, prevProps, true));
return prevProps;
}
function dynamicProperty(props, key) {
const src = props[key];
Object.defineProperty(props, key, {
get() {
return src();
},
enumerable: true
});
return props;
}
function use(fn, element, arg) {
return untrack(() => fn(element, arg));
}
function insert(parent, accessor, marker, initial) {
if (marker !== void 0 && !initial) initial = [];
if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
createRenderEffect((current) => insertExpression(parent, accessor(), current, marker), initial);
}
function assign(node, props, isSVG, skipChildren, prevProps = {}, skipRef = false) {
props || (props = {});
for (const prop in prevProps) {
if (!(prop in props)) {
if (prop === "children") continue;
prevProps[prop] = assignProp(node, prop, null, prevProps[prop], isSVG, skipRef, props);
}
}
for (const prop in props) {
if (prop === "children") {
if (!skipChildren) insertExpression(node, props.children);
continue;
}
const value = props[prop];
prevProps[prop] = assignProp(node, prop, value, prevProps[prop], isSVG, skipRef, props);
}
}
function hydrate$1(code, element, options = {}) {
if (globalThis._$HY.done) return render(code, element, [...element.childNodes], options);
sharedConfig.completed = globalThis._$HY.completed;
sharedConfig.events = globalThis._$HY.events;
sharedConfig.load = (id) => globalThis._$HY.r[id];
sharedConfig.has = (id) => id in globalThis._$HY.r;
sharedConfig.gather = (root) => gatherHydratable(element, root);
sharedConfig.registry = /* @__PURE__ */ new Map();
sharedConfig.context = {
id: options.renderId || "",
count: 0
};
try {
gatherHydratable(element, options.renderId);
return render(code, element, [...element.childNodes], options);
} finally {
sharedConfig.context = null;
}
}
function getNextElement(template2) {
let node, key, hydrating = isHydrating();
if (!hydrating || !(node = sharedConfig.registry.get(key = getHydrationKey()))) {
if (hydrating) {
sharedConfig.done = true;
throw new Error(`Hydration Mismatch. Unable to find DOM nodes for hydration key: ${key}
${template2 ? template2().outerHTML : ""}`);
}
return template2();
}
if (sharedConfig.completed) sharedConfig.completed.add(node);
sharedConfig.registry.delete(key);
return node;
}
function getNextMatch(el, nodeName) {
while (el && el.localName !== nodeName) el = el.nextSibling;
return el;
}
function getNextMarker(start) {
let end = start, count = 0, current = [];
if (isHydrating(start)) {
while (end) {
if (end.nodeType === 8) {
const v = end.nodeValue;
if (v === "$") count++;
else if (v === "/") {
if (count === 0) return [end, current];
count--;
}
}
current.push(end);
end = end.nextSibling;
}
}
return [end, current];
}
function runHydrationEvents() {
if (sharedConfig.events && !sharedConfig.events.queued) {
queueMicrotask(() => {
const {
completed,
events
} = sharedConfig;
if (!events) return;
events.queued = false;
while (events.length) {
const [el, e] = events[0];
if (!completed.has(el)) return;
events.shift();
eventHandler(e);
}
if (sharedConfig.done) {
sharedConfig.events = _$HY.events = null;
sharedConfig.completed = _$HY.completed = null;
}
});
sharedConfig.events.queued = true;
}
}
function isHydrating(node) {
return !!sharedConfig.context && !sharedConfig.done && (!node || node.isConnected);
}
function toPropertyName(name) {
return name.toLowerCase().replace(/-([a-z])/g, (_, w) => w.toUpperCase());
}
function toggleClassKey(node, key, value) {
const classNames = key.trim().split(/\s+/);
for (let i = 0, nameLen = classNames.length; i < nameLen; i++) node.classList.toggle(classNames[i], value);
}
function assignProp(node, prop, value, prev, isSVG, skipRef, props) {
let isCE, isProp, isChildProp, propAlias, forceProp;
if (prop === "style") return style(node, value, prev);
if (prop === "classList") return classList(node, value, prev);
if (value === prev) return prev;
if (prop === "ref") {
if (!skipRef) value(node);
} else if (prop.slice(0, 3) === "on:") {
const e = prop.slice(3);
prev && node.removeEventListener(e, prev, typeof prev !== "function" && prev);
value && node.addEventListener(e, value, typeof value !== "function" && value);
} else if (prop.slice(0, 10) === "oncapture:") {
const e = prop.slice(10);
prev && node.removeEventListener(e, prev, true);
value && node.addEventListener(e, value, true);
} else if (prop.slice(0, 2) === "on") {
const name = prop.slice(2).toLowerCase();
const delegate = DelegatedEvents.has(name);
if (!delegate && prev) {
const h = Array.isArray(prev) ? prev[0] : prev;
node.removeEventListener(name, h);
}
if (delegate || value) {
addEventListener(node, name, value, delegate);
delegate && delegateEvents([name]);
}
} else if (prop.slice(0, 5) === "attr:") {
setAttribute(node, prop.slice(5), value);
} else if (prop.slice(0, 5) === "bool:") {
setBoolAttribute(node, prop.slice(5), value);
} else if ((forceProp = prop.slice(0, 5) === "prop:") || (isChildProp = ChildProperties.has(prop)) || !isSVG && ((propAlias = getPropAlias(prop, node.tagName)) || (isProp = Properties.has(prop))) || (isCE = node.nodeName.includes("-") || "is" in props)) {
if (forceProp) {
prop = prop.slice(5);
isProp = true;
} else if (isHydrating(node)) return value;
if (prop === "class" || prop === "className") className(node, value);
else if (isCE && !isProp && !isChildProp) node[toPropertyName(prop)] = value;
else node[propAlias || prop] = value;
} else {
const ns = isSVG && prop.indexOf(":") > -1 && SVGNamespace[prop.split(":")[0]];
if (ns) setAttributeNS(node, ns, prop, value);
else setAttribute(node, Aliases[prop] || prop, value);
}
return value;
}
function eventHandler(e) {
if (sharedConfig.registry && sharedConfig.events) {
if (sharedConfig.events.find(([el, ev]) => ev === e)) return;
}
let node = e.target;
const key = `$$${e.type}`;
const oriTarget = e.target;
const oriCurrentTarget = e.currentTarget;
const retarget = (value) => Object.defineProperty(e, "target", {
configurable: true,
value
});
const handleNode = () => {
const handler = node[key];
if (handler && !node.disabled) {
const data = node[`${key}Data`];
data !== void 0 ? handler.call(node, data, e) : handler.call(node, e);
if (e.cancelBubble) return;
}
node.host && typeof node.host !== "string" && !node.host._$host && node.contains(e.target) && retarget(node.host);
return true;
};
const walkUpTree = () => {
while (handleNode() && (node = node._$host || node.parentNode || node.host)) ;
};
Object.defineProperty(e, "currentTarget", {
configurable: true,
get() {
return node || document;
}
});
if (sharedConfig.registry && !sharedConfig.done) sharedConfig.done = _$HY.done = true;
if (e.composedPath) {
const path = e.composedPath();
retarget(path[0]);
for (let i = 0; i < path.length - 2; i++) {
node = path[i];
if (!handleNode()) break;
if (node._$host) {
node = node._$host;
walkUpTree();
break;
}
if (node.parentNode === oriCurrentTarget) {
break;
}
}
} else walkUpTree();
retarget(oriTarget);
}
function insertExpression(parent, value, current, marker, unwrapArray) {
const hydrating = isHydrating(parent);
if (hydrating) {
!current && (current = [...parent.childNodes]);
let cleaned = [];
for (let i = 0; i < current.length; i++) {
const node = current[i];
if (node.nodeType === 8 && node.data.slice(0, 2) === "!$") node.remove();
else cleaned.push(node);
}
current = cleaned;
}
while (typeof current === "function") current = current();
if (value === current) return current;
const t = typeof value, multi = marker !== void 0;
parent = multi && current[0] && current[0].parentNode || parent;
if (t === "string" || t === "number") {
if (hydrating) return current;
if (t === "number") {
value = value.toString();
if (value === current) return current;
}
if (multi) {
let node = current[0];
if (node && node.nodeType === 3) {
node.data !== value && (node.data = value);
} else node = document.createTextNode(value);
current = cleanChildren(parent, current, marker, node);
} else {
if (current !== "" && typeof current === "string") {
current = parent.firstChild.data = value;
} else current = parent.textContent = value;
}
} else if (value == null || t === "boolean") {
if (hydrating) return current;
current = cleanChildren(parent, current, marker);
} else if (t === "function") {
createRenderEffect(() => {
let v = value();
while (typeof v === "function") v = v();
current = insertExpression(parent, v, current, marker);
});
return () => current;
} else if (Array.isArray(value)) {
const array = [];
const currentArray = current && Array.isArray(current);
if (normalizeIncomingArray(array, value, current, unwrapArray)) {
createRenderEffect(() => current = insertExpression(parent, array, current, marker, true));
return () => current;
}
if (hydrating) {
if (!array.length) return current;
if (marker === void 0) return current = [...parent.childNodes];
let node = array[0];
if (node.parentNode !== parent) return current;
const nodes = [node];
while ((node = node.nextSibling) !== marker) nodes.push(node);
return current = nodes;
}
if (array.length === 0) {
current = cleanChildren(parent, current, marker);
if (multi) return current;
} else if (currentArray) {
if (current.length === 0) {
appendNodes(parent, array, marker);
} else reconcileArrays(parent, current, array);
} else {
current && cleanChildren(parent);
appendNodes(parent, array);
}
current = array;
} else if (value.nodeType) {
if (hydrating && value.parentNode) return current = multi ? [value] : value;
if (Array.isArray(current)) {
if (multi) return current = cleanChildren(parent, current, marker, value);
cleanChildren(parent, current, null, value);
} else if (current == null || current === "" || !parent.firstChild) {
parent.appendChild(value);
} else parent.replaceChild(value, parent.firstChild);
current = value;
} else console.warn(`Unrecognized value. Skipped inserting`, value);
return current;
}
function normalizeIncomingArray(normalized, array, current, unwrap) {
let dynamic = false;
for (let i = 0, len = array.length; i < len; i++) {
let item = array[i], prev = current && current[normalized.length], t;
if (item == null || item === true || item === false) ;
else if ((t = typeof item) === "object" && item.nodeType) {
normalized.push(item);
} else if (Array.isArray(item)) {
dynamic = normalizeIncomingArray(normalized, item, prev) || dynamic;
} else if (t === "function") {
if (unwrap) {
while (typeof item === "function") item = item();
dynamic = normalizeIncomingArray(normalized, Array.isArray(item) ? item : [item], Array.isArray(prev) ? prev : [prev]) || dynamic;
} else {
normalized.push(item);
dynamic = true;
}
} else {
const value = String(item);
if (prev && prev.nodeType === 3 && prev.data === value) normalized.push(prev);
else normalized.push(document.createTextNode(value));
}
}
return dynamic;
}
function appendNodes(parent, array, marker = null) {
for (let i = 0, len = array.length; i < len; i++) parent.insertBefore(array[i], marker);
}
function cleanChildren(parent, current, marker, replacement) {
if (marker === void 0) return parent.textContent = "";
const node = replacement || document.createTextNode("");
if (current.length) {
let inserted = false;
for (let i = current.length - 1; i >= 0; i--) {
const el = current[i];
if (node !== el) {
const isParent = el.parentNode === parent;
if (!inserted && !i) isParent ? parent.replaceChild(node, el) : parent.insertBefore(node, marker);
else isParent && el.remove();
} else inserted = true;
}
} else parent.insertBefore(node, marker);
return [node];
}
function gatherHydratable(element, root) {
const templates = element.querySelectorAll(`*[data-hk]`);
for (let i = 0; i < templates.length; i++) {
const node = templates[i];
const key = node.getAttribute("data-hk");
if ((!root || key.startsWith(root)) && !sharedConfig.registry.has(key)) sharedConfig.registry.set(key, node);
}
}
function getHydrationKey() {
return sharedConfig.getNextContextId();
}
function NoHydration(props) {
return sharedConfig.context ? void 0 : props.children;
}
function Hydration(props) {
return props.children;
}
var voidFn = () => void 0;
var RequestContext = Symbol();
function innerHTML(parent, content) {
!sharedConfig.context && (parent.innerHTML = content);
}
function throwInBrowser(func) {
const err = new Error(`${func.name} is not supported in the browser, returning undefined`);
console.error(err);
}
function renderToString(fn, options) {
throwInBrowser(renderToString);
}
function renderToStringAsync(fn, options) {
throwInBrowser(renderToStringAsync);
}
function renderToStream(fn, options) {
throwInBrowser(renderToStream);
}
function ssr(template2, ...nodes) {
}
function ssrElement(name, props, children, needsId) {
}
function ssrClassList(value) {
}
function ssrStyle(value) {
}
function ssrAttribute(key, value) {
}
function ssrHydrationKey() {
}
function resolveSSRNode(node) {
}
function escape(html) {
}
function ssrSpread(props, isSVG, skipChildren) {
}
var isServer = false;
var isDev = true;
var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
function createElement(tagName, isSVG = false) {
return isSVG ? document.createElementNS(SVG_NAMESPACE, tagName) : document.createElement(tagName);
}
var hydrate = (...args) => {
enableHydration();
return hydrate$1(...args);
};
function Portal(props) {
const {
useShadow
} = props, marker = document.createTextNode(""), mount = () => props.mount || document.body, owner = getOwner();
let content;
let hydrating = !!sharedConfig.context;
createEffect(() => {
if (hydrating) getOwner().user = hydrating = false;
content || (content = runWithOwner(owner, () => createMemo(() => props.children)));
const el = mount();
if (el instanceof HTMLHeadElement) {
const [clean, setClean] = createSignal(false);
const cleanup = () => setClean(true);
createRoot((dispose) => insert(el, () => !clean() ? content() : dispose(), null));
onCleanup(cleanup);
} else {
const container = createElement(props.isSVG ? "g" : "div", props.isSVG), renderRoot = useShadow && container.attachShadow ? container.attachShadow({
mode: "open"
}) : container;
Object.defineProperty(container, "_$host", {
get() {
return marker.parentNode;
},
configurable: true
});
insert(renderRoot, content);
el.appendChild(container);
props.ref && props.ref(container);
onCleanup(() => el.removeChild(container));
}
}, void 0, {
render: !hydrating
});
return marker;
}
function createDynamic(component, props) {
const cached = createMemo(component);
return createMemo(() => {
const component2 = cached();
switch (typeof component2) {
case "function":
Object.assign(component2, {
[$DEVCOMP]: true
});
return untrack(() => component2(props));
case "string":
const isSvg = SVGElements.has(component2);
const el = sharedConfig.context ? getNextElement() : createElement(component2, isSvg);
spread(el, props, isSvg);
return el;
}
});
}
function Dynamic(props) {
const [, others] = splitProps(props, ["component"]);
return createDynamic(() => props.component, others);
}
export {
Aliases,
voidFn as Assets,
ChildProperties,
DOMElements,
DelegatedEvents,
Dynamic,
ErrorBoundary,
For,
Hydration,
voidFn as HydrationScript,
Index,
Match,
NoHydration,
Portal,
Properties,
RequestContext,
SVGElements,
SVGNamespace,
Show,
Suspense,
SuspenseList,
Switch,
addEventListener,
assign,
classList,
className,
clearDelegatedEvents,
createComponent,
createDynamic,
delegateEvents,
dynamicProperty,
createRenderEffect as effect,
escape,
voidFn as generateHydrationScript,
voidFn as getAssets,
getHydrationKey,
getNextElement,
getNextMarker,
getNextMatch,
getOwner,
getPropAlias,
voidFn as getRequestEvent,
hydrate,
innerHTML,
insert,
isDev,
isServer,
memo,
mergeProps,
render,
renderToStream,
renderToString,
renderToStringAsync,
resolveSSRNode,
runHydrationEvents,
setAttribute,
setAttributeNS,
setBoolAttribute,
setProperty,
spread,
ssr,
ssrAttribute,
ssrClassList,
ssrElement,
ssrHydrationKey,
ssrSpread,
ssrStyle,
style,
template,
untrack,
use,
voidFn as useAssets
};
//# sourceMappingURL=solid-js_web.js.map

File diff suppressed because one or more lines are too long

View file

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>Pulse Monitor - Modern</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,36 +0,0 @@
{
"name": "pulse-modern",
"version": "1.0.0",
"description": "Modern type-safe frontend for Pulse monitoring",
"type": "module",
"author": "rcourtman",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/rcourtman/Pulse.git"
},
"bugs": {
"url": "https://github.com/rcourtman/Pulse/issues"
},
"homepage": "https://github.com/rcourtman/Pulse",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"generate-types": "cd ../scripts && go run generate-types.go",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@solidjs/router": "^0.10.0",
"solid-js": "^1.8.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.8.0"
}
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,16 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<title>Pulse Logo</title>
<style>
.pulse-bg { fill: #2563eb; }
.pulse-ring { fill: none; stroke: #ffffff; stroke-width: 14; opacity: 0.92; }
.pulse-center { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.pulse-bg { fill: #3b82f6; }
.pulse-ring { stroke: #dbeafe; }
.pulse-center { fill: #dbeafe; }
}
</style>
<circle class="pulse-bg" cx="128" cy="128" r="122"/>
<circle class="pulse-ring" cx="128" cy="128" r="84"/>
<circle class="pulse-center" cx="128" cy="128" r="26"/>
</svg>

Before

Width:  |  Height:  |  Size: 625 B

View file

@ -1,290 +0,0 @@
import { Show, createSignal, createContext, useContext, createEffect, onMount } from 'solid-js';
import { getGlobalWebSocketStore } from './stores/websocket-global';
import { Dashboard } from './components/Dashboard/Dashboard';
import StorageComponent from './components/Storage/Storage';
import Backups from './components/Backups/Backups';
import Settings from './components/Settings/Settings';
import { Alerts } from './pages/Alerts';
import { ToastContainer } from './components/Toast/Toast';
import { ErrorBoundary } from './components/ErrorBoundary';
import { logger } from './utils/logger';
import { POLLING_INTERVALS, STORAGE_KEYS } from './constants';
import { UpdatesAPI } from './api/updates';
import type { VersionInfo } from './api/updates';
type TabType = 'main' | 'storage' | 'backups' | 'alerts' | 'settings';
// Enhanced store type with proper typing
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
// Export WebSocket context for other components
export const WebSocketContext = createContext<EnhancedStore>();
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within WebSocketContext.Provider');
}
return context;
};
function App() {
// Get singleton WebSocket store
const wsStore = getGlobalWebSocketStore();
const { state, connected, reconnecting } = wsStore;
// Data update indicator
const [dataUpdated, setDataUpdated] = createSignal(false);
let updateTimeout: number;
// Flash indicator when data updates
createEffect(() => {
// Watch for state changes
const updateTime = state.lastUpdate;
if (updateTime && updateTime !== '') {
setDataUpdated(true);
window.clearTimeout(updateTimeout);
updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH);
}
});
// Tab management
const [activeTab, setActiveTab] = createSignal<TabType>('main');
// Version info
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
// Dark mode
const [darkMode, setDarkMode] = createSignal(
localStorage.getItem(STORAGE_KEYS.DARK_MODE) === 'true' ||
window.matchMedia('(prefers-color-scheme: dark)').matches
);
// Toggle dark mode
const toggleDarkMode = () => {
const newMode = !darkMode();
setDarkMode(newMode);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(newMode));
if (newMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
logger.info('Theme changed', { mode: newMode ? 'dark' : 'light' });
};
// Initialize dark mode
if (darkMode()) {
document.documentElement.classList.add('dark');
}
// Load version info on mount
onMount(async () => {
try {
const version = await UpdatesAPI.getVersion();
setVersionInfo(version);
} catch (error) {
console.error('Failed to load version:', error);
}
});
// Pass through the store directly
const enhancedStore: EnhancedStore = wsStore;
return (
<ErrorBoundary>
<WebSocketContext.Provider value={enhancedStore}>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 p-2 font-sans">
<div class="container w-[95%] max-w-screen-xl mx-auto">
{/* Header */}
<div class="header flex flex-row justify-between items-center mb-2">
<div class="hidden md:block md:flex-1"></div>
<div class="flex items-center gap-1 flex-none">
<svg
width="20"
height="20"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class={`pulse-logo ${connected() && dataUpdated() ? 'animate-pulse-logo' : ''}`}
>
<title>Pulse Logo</title>
<circle class="pulse-bg fill-blue-600 dark:fill-blue-500" cx="128" cy="128" r="122"/>
<circle class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]" cx="128" cy="128" r="84"/>
<circle class="pulse-center fill-white dark:fill-[#dbeafe]" cx="128" cy="128" r="26"/>
</svg>
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
<span class="text-xs font-bold text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-900 px-1 py-0.5 rounded ml-1">RC</span>
</div>
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
<button
onClick={toggleDarkMode}
class="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title={darkMode() ? "Switch to light mode" : "Switch to dark mode"}
>
<Show
when={darkMode()}
fallback={
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</Show>
</button>
<div class="flex items-center gap-2">
<div class={`status text-xs px-2 py-1 rounded-full flex items-center gap-1 ${
connected()
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300'
: reconnecting()
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300'
: 'disconnected bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}>
<Show when={reconnecting()}>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</Show>
{connected() ? 'Connected' : reconnecting() ? 'Reconnecting...' : 'Disconnected'}
</div>
</div>
</div>
</div>
{/* Tabs */}
<div class="tabs flex mb-2 border-b border-gray-300 dark:border-gray-700 overflow-x-auto overflow-y-hidden whitespace-nowrap scrollbar-hide" role="tablist">
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'main'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('main')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span>Main</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'storage'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('storage')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<span>Storage</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'backups'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('backups')}
role="tab"
title="PVE backups, PBS backups, and VM/CT snapshots"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Backups</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'alerts'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('alerts')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>Alerts</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'settings'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('settings')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>Settings</span>
</div>
</div>
{/* Main Content */}
<main id="main" class="tab-content block bg-white dark:bg-gray-800 rounded-b rounded-tr shadow mb-2">
<div class="p-3">
<Show when={activeTab() === 'main'}>
<Dashboard
vms={state.vms}
containers={state.containers}
nodes={state.nodes}
/>
</Show>
<Show when={activeTab() === 'storage'}>
<StorageComponent />
</Show>
<Show when={activeTab() === 'backups'}>
<Backups />
</Show>
<Show when={activeTab() === 'alerts'}>
<Alerts />
</Show>
<Show when={activeTab() === 'settings'}>
<Settings />
</Show>
</div>
</main>
{/* Footer */}
<footer class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">
Pulse | Version: {' '}
<a
href="https://github.com/rcourtman/Pulse/releases"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline"
>
{versionInfo()?.version || 'loading...'}
</a>
{versionInfo()?.isDevelopment && ' (Development)'}
{versionInfo()?.isDocker && ' - Docker'}
</footer>
</div>
</div>
<ToastContainer />
</WebSocketContext.Provider>
</ErrorBoundary>
);
}
export default App;

View file

@ -1,105 +0,0 @@
import type { Alert } from '@/types/api';
import type { AlertConfig } from '@/types/alerts';
export class AlertsAPI {
private static baseUrl = '/api/alerts';
static async getActive(): Promise<Alert[]> {
const response = await fetch(`${this.baseUrl}/active`);
if (!response.ok) {
throw new Error('Failed to fetch active alerts');
}
return response.json();
}
static async getHistory(params?: {
limit?: number;
offset?: number;
startTime?: string;
endTime?: string;
severity?: 'warning' | 'critical' | 'all';
resourceId?: string;
}): Promise<Alert[]> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
queryParams.append(key, value.toString());
}
});
}
const response = await fetch(`${this.baseUrl}/history?${queryParams}`);
if (!response.ok) {
throw new Error('Failed to fetch alert history');
}
return response.json();
}
// Removed unused config methods - not implemented in backend
static async acknowledge(alertId: string, user?: string): Promise<{ success: boolean }> {
const response = await fetch(`${this.baseUrl}/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user }),
});
if (!response.ok) {
throw new Error('Failed to acknowledge alert');
}
return response.json();
}
// Alert configuration methods
static async getConfig(): Promise<AlertConfig> {
const response = await fetch(`${this.baseUrl}/config`);
if (!response.ok) {
throw new Error('Failed to fetch alert configuration');
}
return response.json();
}
static async updateConfig(config: AlertConfig): Promise<{ success: boolean }> {
const response = await fetch(`${this.baseUrl}/config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
if (!response.ok) {
throw new Error('Failed to update alert configuration');
}
return response.json();
}
static async clearAlert(alertId: string): Promise<{ success: boolean }> {
const response = await fetch(`${this.baseUrl}/${alertId}/clear`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to clear alert');
}
return response.json();
}
static async clearHistory(): Promise<{ success: boolean }> {
const response = await fetch(`${this.baseUrl}/history`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to clear alert history');
}
return response.json();
}
}

View file

@ -1,37 +0,0 @@
import type { State, Performance, Stats } from '@/types/api';
export class MonitoringAPI {
private static baseUrl = '/api';
static async getState(): Promise<State> {
const response = await fetch(`${this.baseUrl}/state`);
if (!response.ok) {
throw new Error('Failed to fetch monitoring state');
}
return response.json();
}
static async getPerformance(): Promise<Performance> {
const response = await fetch(`${this.baseUrl}/performance`);
if (!response.ok) {
throw new Error('Failed to fetch performance metrics');
}
return response.json();
}
static async getStats(): Promise<Stats> {
const response = await fetch(`${this.baseUrl}/stats`);
if (!response.ok) {
throw new Error('Failed to fetch system stats');
}
return response.json();
}
static async exportDiagnostics(): Promise<Blob> {
const response = await fetch(`${this.baseUrl}/diagnostics/export`);
if (!response.ok) {
throw new Error('Failed to export diagnostics');
}
return response.blob();
}
}

View file

@ -1,103 +0,0 @@
import { NodeConfig } from '../types/nodes';
export class NodesAPI {
private static readonly baseUrl = '/api/config/nodes';
static async getNodes(): Promise<NodeConfig[]> {
const response = await fetch(this.baseUrl);
if (!response.ok) {
throw new Error('Failed to fetch nodes');
}
// The API returns an array of nodes directly
const nodes: NodeConfig[] = await response.json();
return nodes;
}
static async addNode(node: NodeConfig): Promise<{ success: boolean; message?: string }> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to add node: ${error}`);
}
return response.json();
}
static async updateNode(nodeId: string, node: NodeConfig): Promise<{ success: boolean; message?: string }> {
const response = await fetch(`${this.baseUrl}/${nodeId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to update node: ${error}`);
}
return response.json();
}
static async deleteNode(nodeId: string): Promise<{ success: boolean; message?: string }> {
const response = await fetch(`${this.baseUrl}/${nodeId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to delete node: ${error}`);
}
return response.json();
}
static async testConnection(node: NodeConfig): Promise<{
status: string;
message?: string;
isCluster?: boolean;
nodeCount?: number;
clusterNodeCount?: number;
datastoreCount?: number;
}> {
const response = await fetch(`${this.baseUrl}/test-connection`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
return response.json();
}
static async testExistingNode(nodeId: string): Promise<{
status: string;
message?: string;
latency?: number;
}> {
const response = await fetch(`${this.baseUrl}/${nodeId}/test`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
return response.json();
}
}

View file

@ -1,228 +0,0 @@
export interface EmailProvider {
id?: string;
name: string;
smtpHost: string;
smtpPort: number;
tls: boolean;
startTLS: boolean;
authRequired: boolean;
instructions: string;
server?: string;
port?: number;
security?: 'none' | 'tls' | 'starttls';
}
export interface WebhookTemplate {
id?: string;
service: string;
name: string;
urlPattern: string;
method: string;
headers: Record<string, string>;
payloadTemplate: string;
instructions: string;
description?: string;
template?: {
url?: string;
method?: string;
headers?: Record<string, string>;
body?: string;
};
}
export interface EmailConfig {
enabled: boolean;
provider: string;
server: string;
port: number;
username: string;
password?: string;
from: string;
to: string[];
tls: boolean;
starttls: boolean;
}
export interface Webhook {
id: string;
name: string;
url: string;
method: string;
headers: Record<string, string>;
template?: string;
enabled: boolean;
}
export interface NotificationTestRequest {
type: 'email' | 'webhook';
config?: any; // Backend expects different format than frontend types
webhookId?: string;
}
export class NotificationsAPI {
private static baseUrl = '/api/notifications';
// Email configuration
static async getEmailConfig(): Promise<EmailConfig> {
const response = await fetch(`${this.baseUrl}/email`);
if (!response.ok) {
throw new Error('Failed to fetch email configuration');
}
const backendConfig = await response.json();
// Convert backend field names to frontend field names
return {
enabled: backendConfig.enabled || false,
provider: backendConfig.provider || '',
server: backendConfig.smtpHost || '',
port: backendConfig.smtpPort || 587,
username: backendConfig.username || '',
password: backendConfig.password || '',
from: backendConfig.from || '',
to: backendConfig.to || [],
tls: backendConfig.tls || false,
starttls: backendConfig.tls || false // Backend only has TLS field
};
}
static async updateEmailConfig(config: EmailConfig): Promise<{ success: boolean }> {
// Convert frontend field names to backend field names
const backendConfig = {
enabled: config.enabled,
smtpHost: config.server,
smtpPort: config.port,
username: config.username,
password: config.password,
from: config.from,
to: config.to,
tls: config.tls || config.starttls // Backend only has TLS field
};
const response = await fetch(`${this.baseUrl}/email`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(backendConfig),
});
if (!response.ok) {
throw new Error('Failed to update email configuration');
}
return response.json();
}
// Webhook management
static async getWebhooks(): Promise<Webhook[]> {
const response = await fetch(`${this.baseUrl}/webhooks`);
if (!response.ok) {
throw new Error('Failed to fetch webhooks');
}
return response.json();
}
static async createWebhook(webhook: Omit<Webhook, 'id'>): Promise<Webhook> {
const response = await fetch(`${this.baseUrl}/webhooks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(webhook),
});
if (!response.ok) {
throw new Error('Failed to create webhook');
}
return response.json();
}
static async updateWebhook(id: string, webhook: Partial<Webhook>): Promise<Webhook> {
const response = await fetch(`${this.baseUrl}/webhooks/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(webhook),
});
if (!response.ok) {
throw new Error('Failed to update webhook');
}
return response.json();
}
static async deleteWebhook(id: string): Promise<{ success: boolean }> {
const response = await fetch(`${this.baseUrl}/webhooks/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete webhook');
}
return response.json();
}
// Templates and providers
static async getEmailProviders(): Promise<EmailProvider[]> {
const response = await fetch(`${this.baseUrl}/email-providers`);
if (!response.ok) {
throw new Error('Failed to fetch email providers');
}
return response.json();
}
static async getWebhookTemplates(): Promise<WebhookTemplate[]> {
const response = await fetch(`${this.baseUrl}/webhook-templates`);
if (!response.ok) {
throw new Error('Failed to fetch webhook templates');
}
return response.json();
}
// Testing
static async testNotification(request: NotificationTestRequest): Promise<{ success: boolean; message?: string }> {
const body: { method: string; config?: any } = { method: request.type };
// Include config if provided for testing without saving
if (request.config) {
body.config = request.config;
}
const response = await fetch(`${this.baseUrl}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to test notification');
}
return response.json();
}
static async testWebhook(webhook: Webhook): Promise<{ success: boolean; message?: string }> {
const response = await fetch(`${this.baseUrl}/webhooks/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(webhook),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to test webhook');
}
return response.json();
}
}

View file

@ -1,91 +0,0 @@
import type {
SettingsResponse,
SettingsUpdateRequest
} from '@/types/settings';
// System settings type matching Go backend
export interface SystemSettingsUpdate {
pollingInterval: number; // in seconds
backendPort?: number;
frontendPort?: number;
allowedOrigins?: string;
connectionTimeout?: number; // in seconds
updateChannel?: string;
autoUpdateEnabled?: boolean;
autoUpdateCheckInterval?: number; // in hours
autoUpdateTime?: string; // HH:MM format
}
// Response types
export interface ApiResponse<T = unknown> {
success?: boolean;
status?: string;
message?: string;
data?: T;
}
export class SettingsAPI {
private static baseUrl = '/api';
static async getSettings(): Promise<SettingsResponse> {
const response = await fetch(`${this.baseUrl}/settings`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to fetch settings');
}
return response.json() as Promise<SettingsResponse>;
}
// Full settings update (legacy - avoid using)
static async updateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
const response = await fetch(`${this.baseUrl}/settings/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error('Failed to update settings');
}
return response.json() as Promise<ApiResponse>;
}
// System settings update (preferred)
static async updateSystemSettings(settings: SystemSettingsUpdate): Promise<ApiResponse> {
const response = await fetch(`${this.baseUrl}/config/system`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to update system settings');
}
return response.json() as Promise<ApiResponse>;
}
static async validateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
const response = await fetch(`${this.baseUrl}/settings/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error('Failed to validate settings');
}
return response.json() as Promise<ApiResponse>;
}
}

View file

@ -1,68 +0,0 @@
// Remove apiRequest import - use fetch directly
export interface UpdateInfo {
available: boolean;
currentVersion: string;
latestVersion: string;
releaseNotes: string;
releaseDate: string;
downloadUrl: string;
isPrerelease: boolean;
}
export interface UpdateStatus {
status: string;
progress: number;
message: string;
error?: string;
updatedAt: string;
}
export interface VersionInfo {
version: string;
build: string;
runtime: string;
channel?: string;
isDocker: boolean;
isDevelopment: boolean;
}
export class UpdatesAPI {
static async checkForUpdates(): Promise<UpdateInfo> {
const response = await fetch('/api/updates/check');
if (!response.ok) {
throw new Error('Failed to check for updates');
}
return response.json();
}
static async applyUpdate(downloadUrl: string): Promise<{ status: string; message: string }> {
const response = await fetch('/api/updates/apply', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ downloadUrl }),
});
if (!response.ok) {
throw new Error('Failed to apply update');
}
return response.json();
}
static async getUpdateStatus(): Promise<UpdateStatus> {
const response = await fetch('/api/updates/status');
if (!response.ok) {
throw new Error('Failed to get update status');
}
return response.json();
}
static async getVersion(): Promise<VersionInfo> {
const response = await fetch('/api/version');
if (!response.ok) {
throw new Error('Failed to get version');
}
return response.json();
}
}

View file

@ -1,178 +0,0 @@
import { For, Show } from 'solid-js';
import type { CustomAlertRule } from '@/types/alerts';
interface CustomRulesTabProps {
rules: CustomAlertRule[];
onUpdateRules: (rules: CustomAlertRule[]) => void;
onHasChanges: (hasChanges: boolean) => void;
}
export function CustomRulesTab(props: CustomRulesTabProps) {
const deleteRule = (ruleId: string) => {
const updatedRules = props.rules.filter(r => r.id !== ruleId);
props.onUpdateRules(updatedRules);
props.onHasChanges(true);
};
const toggleRule = (ruleId: string) => {
const updatedRules = props.rules.map(r =>
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
);
props.onUpdateRules(updatedRules);
props.onHasChanges(true);
};
const getFilterDescription = (rule: CustomAlertRule): string => {
return rule.filterConditions.filters.map(filter => {
if (filter.type === 'metric' && filter.field && filter.operator && filter.value !== undefined) {
return `${filter.field} ${filter.operator} ${filter.value}%`;
} else if (filter.type === 'text' && filter.field && filter.value) {
return `${filter.field}: ${filter.value}`;
} else {
return filter.rawText || '';
}
}).join(` ${rule.filterConditions.logicalOperator} `);
};
const getThresholdsSummary = (rule: CustomAlertRule): string => {
const parts: string[] = [];
if (rule.thresholds.cpu !== undefined) parts.push(`CPU: ${rule.thresholds.cpu}%`);
if (rule.thresholds.memory !== undefined) parts.push(`Memory: ${rule.thresholds.memory}%`);
if (rule.thresholds.disk !== undefined) parts.push(`Disk: ${rule.thresholds.disk}%`);
return parts.length > 0 ? parts.join(', ') : 'Using global defaults';
};
return (
<div class="space-y-4">
{/* Header */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">Custom Alert Rules</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Custom rules apply specific thresholds to guests matching filter conditions.
Rules are evaluated in priority order (higher number = higher priority).
</p>
</div>
{/* Priority Order Explanation */}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Alert Priority Order:</p>
<ol class="list-decimal list-inside space-y-0.5 text-xs">
<li>Guest-specific overrides (highest priority)</li>
<li>Custom rules (evaluated by priority number)</li>
<li>Global default thresholds (lowest priority)</li>
</ol>
</div>
</div>
</div>
{/* Rules List */}
<Show when={props.rules.length > 0} fallback={
<div class="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<p class="text-sm text-gray-600 dark:text-gray-400">
No custom alert rules defined yet. Create rules from the Dashboard by applying filters and clicking "Create Alert".
</p>
</div>
}>
<div class="space-y-3">
<For each={props.rules.sort((a, b) => b.priority - a.priority)}>
{(rule) => (
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div class="p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-sm font-medium text-gray-800 dark:text-gray-200">{rule.name}</h4>
<span class={`px-2 py-0.5 text-xs font-medium rounded-full ${
rule.enabled
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{rule.enabled ? 'Active' : 'Disabled'}
</span>
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Priority: {rule.priority}
</span>
</div>
<Show when={rule.description}>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">{rule.description}</p>
</Show>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => toggleRule(rule.id)}
class="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
title={rule.enabled ? "Disable rule" : "Enable rule"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<Show when={rule.enabled} fallback={
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
}>
<path d="M18.364 5.636a9 9 0 010 12.728m0 0a9 9 0 01-12.728 0m12.728 0L5.636 5.636m12.728 0L5.636 18.364" />
</Show>
</svg>
</button>
<button
onClick={() => deleteRule(rule.id)}
class="p-1.5 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Delete rule"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-20">Filters:</span>
<div class="flex-1">
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{getFilterDescription(rule)}
</code>
</div>
</div>
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-20">Thresholds:</span>
<span class="text-xs text-gray-700 dark:text-gray-300">
{getThresholdsSummary(rule)}
</span>
</div>
<Show when={rule.notifications.email?.enabled || rule.notifications.webhook?.enabled}>
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-20">Notify:</span>
<div class="flex gap-2">
<Show when={rule.notifications.email?.enabled}>
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
Email
</span>
</Show>
<Show when={rule.notifications.webhook?.enabled}>
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
Webhook
</span>
</Show>
</div>
</div>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
);
}

View file

@ -1,324 +0,0 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
interface EmailProvider {
name: string;
smtpHost: string;
smtpPort: number;
tls: boolean;
startTLS: boolean;
authRequired: boolean;
instructions: string;
}
interface EmailConfig {
enabled: boolean;
provider: string;
smtpHost: string;
smtpPort: number;
from: string;
username: string;
password: string;
to: string[];
tls: boolean;
startTLS: boolean;
replyTo: string;
maxRetries: number;
retryDelay: number;
rateLimit: number;
}
interface EmailProviderSelectProps {
config: EmailConfig;
onChange: (config: EmailConfig) => void;
onTest: () => void;
testing?: boolean;
}
export function EmailProviderSelect(props: EmailProviderSelectProps) {
const [providers, setProviders] = createSignal<EmailProvider[]>([]);
const [showProviders, setShowProviders] = createSignal(false);
const [showAdvanced, setShowAdvanced] = createSignal(false);
// Load email providers
createEffect(async () => {
try {
const data = await NotificationsAPI.getEmailProviders();
setProviders(data);
} catch (err) {
console.error('Failed to load email providers:', err);
}
});
const selectProvider = (provider: EmailProvider) => {
props.onChange({
...props.config,
provider: provider.name,
smtpHost: provider.smtpHost,
smtpPort: provider.smtpPort,
tls: provider.tls,
startTLS: provider.startTLS,
username: provider.name === 'SendGrid' ? 'apikey' : props.config.username,
});
setShowProviders(false);
};
const currentProvider = () => providers().find(p => p.name === props.config.provider);
return (
<div class="space-y-6">
{/* Provider Selection */}
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Email Provider
</label>
<button
onClick={() => setShowProviders(!showProviders())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{props.config.provider || 'Select Provider'}
</button>
</div>
<Show when={showProviders()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<For each={providers()}>
{(provider) => (
<button
onClick={() => selectProvider(provider)}
class={`p-3 text-left rounded-lg border transition-all ${
props.config.provider === provider.name
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
{provider.name}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{provider.smtpHost}:{provider.smtpPort}
</div>
</button>
)}
</For>
</div>
</Show>
<Show when={currentProvider()}>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Setup Instructions
</h4>
<pre class="text-xs text-blue-800 dark:text-blue-200 whitespace-pre-wrap">
{currentProvider()!.instructions}
</pre>
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SMTP Server
</label>
<input
type="text"
value={props.config.smtpHost}
onInput={(e) => props.onChange({ ...props.config, smtpHost: e.currentTarget.value })}
placeholder="smtp.example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SMTP Port
</label>
<input
type="number"
value={props.config.smtpPort}
onInput={(e) => props.onChange({ ...props.config, smtpPort: parseInt(e.currentTarget.value) || 587 })}
placeholder="587"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
From Address
</label>
<input
type="email"
value={props.config.from}
onInput={(e) => props.onChange({ ...props.config, from: e.currentTarget.value })}
placeholder="noreply@example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reply-To Address
</label>
<input
type="email"
value={props.config.replyTo || ''}
onInput={(e) => props.onChange({ ...props.config, replyTo: e.currentTarget.value })}
placeholder="admin@example.com (optional)"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<input
type="text"
value={props.config.username}
onInput={(e) => props.onChange({ ...props.config, username: e.currentTarget.value })}
placeholder={props.config.provider === 'SendGrid' ? 'apikey' : 'username@example.com'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password / API Key
</label>
<input
type="password"
value={props.config.password}
onInput={(e) => props.onChange({ ...props.config, password: e.currentTarget.value })}
placeholder="••••••••"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
{/* Recipients */}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Recipients (one per line)
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">
Leave empty to send to the From address
</span>
</label>
<textarea
value={props.config.to.join('\n')}
onInput={(e) => {
const recipients = e.currentTarget.value
.split('\n')
.map(r => r.trim())
.filter(r => r.length > 0);
props.onChange({ ...props.config, to: recipients });
}}
placeholder={`Leave empty to use ${props.config.from || 'From address'}\nOr add additional recipients:\nadmin@company.com\nops-team@company.com`}
rows="3"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
{/* Advanced Settings */}
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 flex items-center gap-1"
>
<svg class={`w-4 h-4 transition-transform ${showAdvanced() ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Advanced Settings
</button>
<Show when={showAdvanced()}>
<div class="mt-4 space-y-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.tls}
onChange={(e) => props.onChange({ ...props.config, tls: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use TLS</span>
</label>
</div>
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.startTLS}
onChange={(e) => props.onChange({ ...props.config, startTLS: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rate Limit
</label>
<div class="flex items-center gap-2">
<input
type="number"
value={props.config.rateLimit || 60}
onInput={(e) => props.onChange({ ...props.config, rateLimit: parseInt(e.currentTarget.value) })}
class="w-20 px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
/>
<span class="text-sm text-gray-600 dark:text-gray-400">/min</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Retries
</label>
<input
type="number"
value={props.config.maxRetries || 3}
min="0"
max="5"
onInput={(e) => props.onChange({ ...props.config, maxRetries: parseInt(e.currentTarget.value) })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Retry Delay (seconds)
</label>
<input
type="number"
value={props.config.retryDelay || 5}
min="1"
max="60"
onInput={(e) => props.onChange({ ...props.config, retryDelay: parseInt(e.currentTarget.value) })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
</div>
</Show>
</div>
{/* Test Button */}
<div class="flex justify-end">
<button
onClick={props.onTest}
disabled={props.testing || !props.config.enabled}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{props.testing ? 'Sending Test Email...' : 'Send Test Email'}
</button>
</div>
</div>
);
}

View file

@ -1,340 +0,0 @@
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
interface Override {
id?: string; // Full guest ID (e.g. "Main-node1-105")
guestName: string;
vmid: number;
type: string;
node: string;
instance?: string;
thresholds: {
cpu?: number;
memory?: number;
disk?: number;
diskRead?: number;
diskWrite?: number;
networkIn?: number;
networkOut?: number;
};
}
interface OverrideModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (override: Override) => void;
existingOverride?: Override;
guests: Array<{ id: string; name: string; vmid: number; type: string; node: string; instance: string }>;
}
export function OverrideModal(props: OverrideModalProps) {
// Initialize state only when modal opens, not on every render
const [selectedGuest, setSelectedGuest] = createSignal<string>('');
// Store the select element ref
let selectRef: HTMLSelectElement | undefined;
const [thresholds, setThresholds] = createSignal({
cpu: 80,
memory: 80,
disk: 80,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0
});
const [enabledMetrics, setEnabledMetrics] = createSignal({
cpu: false,
memory: false,
disk: false,
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false
});
// Maintain select value when guests change
createEffect(() => {
if (selectRef && selectedGuest()) {
const currentValue = selectedGuest();
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
if (selectRef) {
selectRef.value = currentValue;
}
});
}
});
// Reset state when modal opens
createEffect(() => {
if (props.isOpen) {
if (props.existingOverride) {
setSelectedGuest(`${props.existingOverride.vmid}`);
setThresholds({
cpu: props.existingOverride.thresholds.cpu || 80,
memory: props.existingOverride.thresholds.memory || 80,
disk: props.existingOverride.thresholds.disk || 80,
diskRead: props.existingOverride.thresholds.diskRead || 0,
diskWrite: props.existingOverride.thresholds.diskWrite || 0,
networkIn: props.existingOverride.thresholds.networkIn || 0,
networkOut: props.existingOverride.thresholds.networkOut || 0
});
setEnabledMetrics({
cpu: props.existingOverride.thresholds.cpu !== undefined,
memory: props.existingOverride.thresholds.memory !== undefined,
disk: props.existingOverride.thresholds.disk !== undefined,
diskRead: props.existingOverride.thresholds.diskRead !== undefined,
diskWrite: props.existingOverride.thresholds.diskWrite !== undefined,
networkIn: props.existingOverride.thresholds.networkIn !== undefined,
networkOut: props.existingOverride.thresholds.networkOut !== undefined
});
} else {
// Reset to defaults for new override
setSelectedGuest('');
setThresholds({
cpu: 80,
memory: 80,
disk: 80,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0
});
setEnabledMetrics({
cpu: false,
memory: false,
disk: false,
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false
});
}
}
});
const handleSave = () => {
const guest = props.guests.find(g => g.vmid.toString() === selectedGuest());
if (!guest) return;
const enabledThresholds: Override['thresholds'] = {};
const enabled = enabledMetrics();
const thresh = thresholds();
if (enabled.cpu && thresh.cpu !== undefined) enabledThresholds.cpu = thresh.cpu;
if (enabled.memory && thresh.memory !== undefined) enabledThresholds.memory = thresh.memory;
if (enabled.disk && thresh.disk !== undefined) enabledThresholds.disk = thresh.disk;
if (enabled.diskRead && thresh.diskRead) enabledThresholds.diskRead = thresh.diskRead;
if (enabled.diskWrite && thresh.diskWrite) enabledThresholds.diskWrite = thresh.diskWrite;
if (enabled.networkIn && thresh.networkIn) enabledThresholds.networkIn = thresh.networkIn;
if (enabled.networkOut && thresh.networkOut) enabledThresholds.networkOut = thresh.networkOut;
props.onSave({
id: guest.id, // Pass the full guest ID
guestName: guest.name,
vmid: guest.vmid,
type: guest.type,
node: guest.node,
instance: guest.instance,
thresholds: enabledThresholds
});
};
return (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
{props.existingOverride ? 'Edit Guest Override' : 'Add Guest Override'}
</h2>
</div>
{/* Content */}
<div class="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-8rem)]">
{/* Guest Selection */}
<Show when={!props.existingOverride}>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Guest
</label>
<select
class="w-full px-3 py-2 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
onChange={(e) => {
const value = e.currentTarget.value;
setSelectedGuest(value);
}}
ref={(el) => {
selectRef = el;
}}
>
<option value="">Choose a guest...</option>
<For each={props.guests}>
{(guest) => (
<option value={guest.vmid.toString()}>
{guest.name} ({guest.vmid}) - {guest.type} on {guest.node}
</option>
)}
</For>
</select>
</div>
</Show>
{/* Threshold Overrides */}
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Threshold Overrides
</h3>
{/* CPU */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().cpu}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), cpu: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">CPU Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().cpu || 80}
onChange={(v) => setThresholds({...thresholds(), cpu: v})}
type="cpu"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().cpu || 80}%
</span>
</div>
</div>
</div>
{/* Memory */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().memory}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), memory: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Memory Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().memory || 85}
onChange={(v) => setThresholds({...thresholds(), memory: v})}
type="memory"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().memory || 85}%
</span>
</div>
</div>
</div>
{/* Disk */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().disk}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), disk: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().disk || 90}
onChange={(v) => setThresholds({...thresholds(), disk: v})}
type="disk"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().disk || 90}%
</span>
</div>
</div>
</div>
{/* I/O Metrics */}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().diskRead}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), diskRead: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Read</label>
<select
value={thresholds().diskRead}
onChange={(e) => setThresholds({...thresholds(), diskRead: parseInt(e.currentTarget.value)})}
class="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="0">Off</option>
<option value="10">10 MB/s</option>
<option value="50">50 MB/s</option>
<option value="100">100 MB/s</option>
<option value="500">500 MB/s</option>
</select>
</div>
</div>
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().diskWrite}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), diskWrite: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Write</label>
<select
value={thresholds().diskWrite}
onChange={(e) => setThresholds({...thresholds(), diskWrite: parseInt(e.currentTarget.value)})}
class="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="0">Off</option>
<option value="10">10 MB/s</option>
<option value="50">50 MB/s</option>
<option value="100">100 MB/s</option>
<option value="500">500 MB/s</option>
</select>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button
onClick={props.onClose}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!selectedGuest() && !props.existingOverride}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Save Override
</button>
</div>
</div>
</div>
</Portal>
</Show>
);
}

View file

@ -1,326 +0,0 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
interface Webhook {
id?: string;
name: string;
url: string;
method: string;
service: string;
headers: Record<string, string>;
enabled: boolean;
}
interface WebhookTemplate {
service: string;
name: string;
urlPattern: string;
method: string;
headers: Record<string, string>;
payloadTemplate: string;
instructions: string;
}
interface WebhookConfigProps {
webhooks: Webhook[];
onAdd: (webhook: Webhook) => void;
onUpdate: (webhook: Webhook) => void;
onDelete: (id: string) => void;
onTest: (id: string) => void;
testing?: string | null;
}
export function WebhookConfig(props: WebhookConfigProps) {
const [adding, setAdding] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<Webhook>({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
});
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
// Load webhook templates
createEffect(async () => {
try {
const data = await NotificationsAPI.getWebhookTemplates();
setTemplates(data);
} catch (err) {
console.error('Failed to load webhook templates:', err);
}
});
const saveWebhook = () => {
const data = formData();
if (!data.name || !data.url) return;
if (editingId()) {
props.onUpdate({ ...data, id: editingId()! });
setEditingId(null);
setAdding(false);
} else {
props.onAdd(data);
// Reset form but keep adding state true
setFormData({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
});
}
};
const cancelForm = () => {
setAdding(false);
setEditingId(null);
setFormData({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true
});
};
const editWebhook = (webhook: Webhook) => {
setEditingId(webhook.id!);
setFormData(webhook);
setAdding(true);
};
const selectService = (service: string) => {
const template = templates().find(t => t.service === service);
if (template) {
setFormData({
...formData(),
service: template.service,
method: template.method,
headers: { ...template.headers },
name: formData().name || template.name
});
}
setShowServiceDropdown(false);
};
const currentTemplate = () => templates().find(t => t.service === formData().service);
const serviceName = (service: string) => {
const names: Record<string, string> = {
generic: 'Generic',
discord: 'Discord',
slack: 'Slack',
teams: 'Microsoft Teams',
'teams-adaptive': 'Teams (Adaptive)',
pagerduty: 'PagerDuty'
};
return names[service] || service;
};
return (
<div class="space-y-6">
{/* Existing Webhooks List */}
<Show when={props.webhooks.length > 0}>
<div class="space-y-3">
<For each={props.webhooks}>
{(webhook) => (
<div class="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<span class="font-medium text-sm text-gray-800 dark:text-gray-200">
{webhook.name}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{serviceName(webhook.service)}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{webhook.method}
</span>
{!webhook.enabled && (
<span class="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
Disabled
</span>
)}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
{webhook.url}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<button
onClick={() => props.onTest(webhook.id!)}
disabled={props.testing === webhook.id}
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
>
{props.testing === webhook.id ? 'Testing...' : 'Test'}
</button>
<button
onClick={() => editWebhook(webhook)}
class="px-3 py-1 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
onClick={() => props.onDelete(webhook.id!)}
class="px-3 py-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
{/* Add/Edit Form */}
<Show when={adding()}>
<div class="space-y-4">
{/* Service Selection */}
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Service Type
</label>
<button
onClick={() => setShowServiceDropdown(!showServiceDropdown())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{serviceName(formData().service)}
</button>
</div>
<Show when={showServiceDropdown()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg mb-4">
<For each={['generic', 'discord', 'slack', 'teams', 'teams-adaptive', 'pagerduty']}>
{(service) => (
<button
onClick={() => selectService(service)}
class={`p-3 text-left rounded-lg border transition-all ${
formData().service === service
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
{serviceName(service)}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{service === 'generic' ? 'Custom webhook endpoint' :
service === 'discord' ? 'Discord server webhook' :
service === 'slack' ? 'Slack incoming webhook' :
service === 'teams' ? 'Microsoft Teams webhook' :
service === 'teams-adaptive' ? 'Teams with Adaptive Cards' :
'PagerDuty Events API v2'}
</div>
</button>
)}
</For>
</div>
</Show>
<Show when={currentTemplate()?.instructions}>
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Setup Instructions
</h4>
<pre class="text-xs text-blue-800 dark:text-blue-200 whitespace-pre-wrap">
{currentTemplate()!.instructions}
</pre>
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name
</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
placeholder={currentTemplate()?.name || 'My Webhook'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTTP Method
</label>
<select
value={formData().method}
onChange={(e) => setFormData({ ...formData(), method: e.currentTarget.value })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webhook URL
</label>
<input
type="url"
value={formData().url}
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
placeholder={currentTemplate()?.urlPattern || 'https://example.com/webhook'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono"
/>
</div>
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={formData().enabled}
onChange={(e) => setFormData({ ...formData(), enabled: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Enable this webhook</span>
</label>
</div>
<div class="flex justify-end gap-2">
<button
onClick={cancelForm}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={saveWebhook}
disabled={!formData().name || !formData().url}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId() ? 'Update' : 'Add'} Webhook
</button>
</div>
</div>
</Show>
{/* Add Webhook Button */}
<Show when={!adding()}>
<button
onClick={() => setAdding(true)}
class="w-full py-2 text-sm border border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400"
>
+ Add Webhook
</button>
</Show>
</div>
);
}

View file

@ -1,46 +0,0 @@
import { Component, Show } from 'solid-js';
import { useWebSocket } from '@/App';
import UnifiedBackups from './UnifiedBackups';
const Backups: Component = () => {
const { state, connected } = useWebSocket();
return (
<div>
{/* Loading State */}
<Show when={connected() && !state.pveBackups && !state.pbs}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<div class="inline-flex items-center justify-center w-12 h-12 mb-4">
<svg class="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading backup information...</p>
</div>
</div>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-600 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-2">Connection Lost</h3>
<p class="text-xs text-red-700 dark:text-red-300">Unable to connect to the backend server. Attempting to reconnect...</p>
</div>
</div>
</Show>
{/* Main Content - Unified Backups View */}
<Show when={connected() && (state.pveBackups || state.pbs)}>
<UnifiedBackups />
</Show>
</div>
);
};
export default Backups;

View file

@ -1,719 +0,0 @@
import { createSignal, createMemo, createEffect, For, Show } from 'solid-js';
import type { VM, Container, Node } from '@/types/api';
import { GuestRow } from './GuestRow';
import NodeCard from './NodeCard';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { createTooltipSystem, showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
interface DashboardProps {
vms: VM[];
containers: Container[];
nodes: Node[];
}
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'stopped';
export function Dashboard(props: DashboardProps) {
const { connected, activeAlerts, initialDataReceived } = useWebSocket();
const [search, setSearch] = createSignal('');
// Initialize from localStorage with proper type checking
const storedViewMode = localStorage.getItem('dashboardViewMode');
const [viewMode, setViewMode] = createSignal<ViewMode>(
(storedViewMode === 'all' || storedViewMode === 'vm' || storedViewMode === 'lxc') ? storedViewMode : 'all'
);
const storedStatusMode = localStorage.getItem('dashboardStatusMode');
const [statusMode, setStatusMode] = createSignal<StatusMode>(
(storedStatusMode === 'all' || storedStatusMode === 'running' || storedStatusMode === 'stopped') ? storedStatusMode : 'all'
);
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('dashboardShowFilters') !== null
? localStorage.getItem('dashboardShowFilters') === 'true'
: false // Default to collapsed
);
// Sorting state - default to VMID ascending (matches Proxmox order)
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Create tooltip system
const TooltipComponent = createTooltipSystem();
// Persist filter states to localStorage
createEffect(() => {
localStorage.setItem('dashboardViewMode', viewMode());
});
createEffect(() => {
localStorage.setItem('dashboardStatusMode', statusMode());
});
createEffect(() => {
localStorage.setItem('dashboardShowFilters', showFilters().toString());
});
// Sort handler
const handleSort = (key: keyof (VM | Container)) => {
if (sortKey() === key) {
// Toggle direction for the same column
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
// New column - set key and default direction
setSortKey(key);
// Set default sort direction based on column type
if (key === 'cpu' || key === 'memory' || key === 'disk' || key === 'diskRead' ||
key === 'diskWrite' || key === 'networkIn' || key === 'networkOut' || key === 'uptime') {
setSortDirection('desc');
} else {
setSortDirection('asc');
}
}
};
// Handle keyboard shortcuts
let searchInputRef: HTMLInputElement | undefined;
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear
if (search().trim() || sortKey() !== 'vmid' || sortDirection() !== 'asc') {
// Clear search and reset filters
setSearch('');
setSortKey('vmid');
setSortDirection('asc');
// Blur the search input if it's focused
if (searchInputRef && document.activeElement === searchInputRef) {
searchInputRef.blur();
}
} else if (showFilters()) {
// No search/filters active, so collapse the filters section
setShowFilters(false);
}
// If filters are already collapsed, do nothing
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
}
// Focus the search input and let the character be typed
if (searchInputRef) {
searchInputRef.focus();
// Don't prevent default - let the character be typed
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Combine VMs and containers into a single list
const allGuests = createMemo(() => {
const vms = props.vms || [];
const containers = props.containers || [];
const guests: (VM | Container)[] = [...vms, ...containers];
return guests;
});
// Filter guests based on current settings
const filteredGuests = createMemo(() => {
let guests = allGuests();
// Filter by type
if (viewMode() === 'vm') {
guests = guests.filter(g => g.type === 'qemu');
} else if (viewMode() === 'lxc') {
guests = guests.filter(g => g.type === 'lxc');
}
// Filter by status
if (statusMode() === 'running') {
guests = guests.filter(g => g.status === 'running');
} else if (statusMode() === 'stopped') {
guests = guests.filter(g => g.status !== 'running');
}
// Apply search/filter
const searchTerm = search().trim();
if (searchTerm) {
// Split by commas first
const searchParts = searchTerm.split(',').map(t => t.trim()).filter(t => t);
// Separate filters from text searches
const filters: string[] = [];
const textSearches: string[] = [];
searchParts.forEach(part => {
if (part.includes('>') || part.includes('<') || part.includes(':')) {
filters.push(part);
} else {
textSearches.push(part.toLowerCase());
}
});
// Apply filters if any
if (filters.length > 0) {
// Join filters with AND operator
const filterString = filters.join(' AND ');
const stack = parseFilterStack(filterString);
if (stack.filters.length > 0) {
guests = guests.filter(g => evaluateFilterStack(g, stack));
}
}
// Apply text search if any
if (textSearches.length > 0) {
guests = guests.filter(g =>
textSearches.some(term =>
g.name.toLowerCase().includes(term) ||
g.vmid.toString().includes(term) ||
g.node.toLowerCase().includes(term) ||
g.status.toLowerCase().includes(term)
)
);
}
}
// Don't filter by thresholds anymore - dimming is handled in GuestRow component
return guests;
});
// Group by node
const groupedGuests = createMemo(() => {
const guests = filteredGuests();
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach(guest => {
if (!groups[guest.node]) {
groups[guest.node] = [];
}
groups[guest.node].push(guest);
});
// Sort within each node group
const key = sortKey();
const dir = sortDirection();
if (key) {
Object.keys(groups).forEach(node => {
groups[node] = groups[node].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[key] as string | number | boolean | null | undefined;
let bVal: string | number | boolean | null | undefined = b[key] as string | number | boolean | null | undefined;
// Special handling for percentage-based columns
if (key === 'cpu') {
// CPU is displayed as percentage
aVal = a.cpu * 100;
bVal = b.cpu * 100;
} else if (key === 'memory') {
// Memory is displayed as percentage (use pre-calculated usage)
aVal = a.memory ? (a.memory.usage || 0) : 0;
bVal = b.memory ? (b.memory.usage || 0) : 0;
} else if (key === 'disk') {
// Disk is displayed as percentage
aVal = a.disk.total > 0 ? (a.disk.used / a.disk.total) * 100 : 0;
bVal = b.disk.total > 0 ? (b.disk.used / b.disk.total) * 100 : 0;
}
// Handle null/undefined/empty values - put at end for both asc and desc
const aIsEmpty = aVal === null || aVal === undefined || aVal === '';
const bIsEmpty = bVal === null || bVal === undefined || bVal === '';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1;
if (bIsEmpty) return -1;
// Type-specific value preparation
if (typeof aVal === 'number' && typeof bVal === 'number') {
// Numeric comparison
const comparison = aVal < bVal ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
} else {
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return 0;
const comparison = aStr < bStr ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
}
});
});
}
return groups;
});
const totalStats = createMemo(() => {
const guests = filteredGuests();
const running = guests.filter(g => g.status === 'running').length;
const vms = guests.filter(g => g.type === 'qemu').length;
const containers = guests.filter(g => g.type === 'lxc').length;
return {
total: guests.length,
running,
stopped: guests.length - running,
vms,
containers
};
});
return (
<div>
{/* Node Summary Cards */}
<div id="node-summary-cards-container" class="mb-3">
<Show when={props.nodes.length > 0} fallback={
<p class="text-sm text-gray-500 dark:text-gray-400">Loading node summary...</p>
}>
<div class="flex flex-wrap gap-2">
<For each={props.nodes}>
{(node) => (
<div class="flex-1 min-w-[250px]">
<ComponentErrorBoundary name="NodeCard">
<NodeCard node={node} />
</ComponentErrorBoundary>
</div>
)}
</For>
</div>
</Show>
</div>
{/* Dashboard Filter */}
<div class="dashboard-filter mb-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
{/* Filter toggle - now visible on all screen sizes */}
<button
onClick={() => setShowFilters(!showFilters())}
class="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded-lg transition-colors"
>
<span class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
Filters & Search
<Show when={search() || viewMode() !== 'all' || statusMode() !== 'all'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
</Show>
</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class={`transform transition-transform ${showFilters() ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class={`filter-controls-wrapper ${showFilters() ? 'block' : 'hidden'} p-3 lg:p-4 border-t border-gray-200 dark:border-gray-700`}>
<div class="flex flex-col gap-3">
{/* Search Bar Row */}
<div class="flex gap-2">
<div class="relative flex-1">
<input
ref={searchInputRef}
type="text"
placeholder="Search: name, jellyfin,plex, or cpu>80"
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search guests or use filters like cpu>80"
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<button
class="absolute right-3 top-2.5 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltipContent = `
<div class="space-y-2 p-1">
<div class="font-semibold mb-2">Search Examples:</div>
<div class="space-y-1">
<div><span class="font-mono bg-gray-700 px-1 rounded">jellyfin</span> - Find guests with "jellyfin" in name</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">plex,media</span> - Find guests with "plex" OR "media"</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">cpu>80</span> - Guests using >80% CPU</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">memory<20</span> - Guests using <20% memory</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">disk>90</span> - Guests using >90% disk</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">node:pve1</span> - Guests on specific node</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">vmid:104</span> - Find specific VM/container</div>
</div>
<div class="mt-2 pt-2 border-t border-gray-600">
<div class="font-semibold mb-1">Combine searches:</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">media,cpu>50</span> - "media" in name AND >50% CPU</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">plex,jellyfin,disk>80</span> - Multiple names AND disk filter</div>
</div>
</div>
`;
showTooltip(tooltipContent, rect.left, rect.top);
}}
onMouseLeave={() => hideTooltip()}
onClick={(e) => e.preventDefault()}
type="button"
aria-label="Search help"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
{/* Reset Button */}
<button
onClick={() => {
setSearch('');
setSortKey('vmid');
setSortDirection('asc');
setViewMode('all');
setStatusMode('all');
}}
title="Reset all filters (Esc)"
class="flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
<span class="ml-1.5 hidden sm:inline">Reset</span>
</button>
</div>
{/* Filters Row */}
<div class="flex flex-col sm:flex-row gap-2">
{/* Type Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setViewMode('all')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'all'
? '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'
}`}
>
All
</button>
<button
onClick={() => setViewMode('vm')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
VMs
</button>
<button
onClick={() => setViewMode('lxc')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
LXCs
</button>
</div>
<div class="h-6 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setStatusMode('all')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
statusMode() === 'all'
? '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'
}`}
>
All
</button>
<button
onClick={() => setStatusMode('running')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
statusMode() === 'running'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Running
</button>
<button
onClick={() => setStatusMode('stopped')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
statusMode() === 'stopped'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Stopped
</button>
</div>
</div>
</div>
</div>
</div>
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="animate-spin mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Loading dashboard data...</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Connecting to monitoring service</p>
</div>
</div>
</Show>
{/* Empty State - No PVE Nodes Configured */}
<Show when={connected() && initialDataReceived() && props.nodes.filter(n => n.type === 'pve').length === 0 && props.vms.length === 0 && props.containers.length === 0}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No Proxmox VE nodes configured</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">Add a Proxmox VE node in the Settings tab to start monitoring your infrastructure.</p>
<button
onClick={() => {
const settingsTab = document.querySelector('[role="tab"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
</div>
</div>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-600 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-2">Connection Lost</h3>
<p class="text-xs text-red-700 dark:text-red-300">Unable to connect to the backend server. Attempting to reconnect...</p>
</div>
</div>
</Show>
{/* Table View */}
<Show when={connected() && initialDataReceived() && (props.nodes.length > 0 || props.vms.length > 0 || props.containers.length > 0)}>
<ComponentErrorBoundary name="Guest Table">
<ScrollableTable
class="mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden"
minWidth="900px"
>
<table class="w-full min-w-[900px] text-xs sm:text-sm table-fixed">
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[200px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
role="button"
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[60px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[70px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskRead')}
>
Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskWrite')}
>
Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkOut')}
>
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={Object.entries(groupedGuests()).sort(([a], [b]) => a.localeCompare(b))} fallback={<></>}>
{([node, guests]) => (
<>
<Show when={node}>
<tr class="node-header bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[200px]">
<a
href={`https://${node}:8006`}
target="_blank"
rel="noopener noreferrer"
class="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${node} web interface`}
>
{node}
</a>
</td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[60px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[70px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[100px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[140px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[140px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[140px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
</tr>
</Show>
<For each={guests} fallback={<></>}>
{(guest) => (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
showNode={false}
alertStyles={getAlertStyles(guest.id || `${guest.instance}-${guest.name}-${guest.vmid}`, activeAlerts)}
/>
</ComponentErrorBoundary>
)}
</For>
</>
)}
</For>
</tbody>
</table>
</ScrollableTable>
</ComponentErrorBoundary>
</Show>
<Show when={connected() && initialDataReceived() && filteredGuests().length === 0 && props.nodes.filter(n => n.type === 'pve').length > 0}>
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2">No guests found matching your filters</p>
</div>
</Show>
{/* Stats */}
<Show when={connected() && initialDataReceived()}>
<div class="mb-4">
<div class="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700 rounded">
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-green-500 rounded-full"></span>
{totalStats().running} running
</span>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-gray-400 rounded-full"></span>
{totalStats().stopped} stopped
</span>
</div>
</div>
</Show>
{/* Tooltip System */}
<TooltipComponent />
</div>
);
}

View file

@ -1,174 +0,0 @@
import { Show, createMemo } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { formatBytes, formatUptime } from '@/utils/format';
import { MetricBar } from './MetricBar';
import { IOMetric } from './IOMetric';
import { getResourceAlerts } from '@/utils/alerts';
import { useWebSocket } from '@/App';
type Guest = VM | Container;
// Type guard for VM vs Container
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
interface GuestRowProps {
guest: Guest;
showNode?: boolean;
alertStyles?: {
rowClass: string;
indicatorClass: string;
badgeClass: string;
hasAlert: boolean;
alertCount: number;
severity: 'critical' | 'warning' | null;
};
}
export function GuestRow(props: GuestRowProps) {
const { activeAlerts } = useWebSocket();
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
const memPercent = createMemo(() => {
if (!props.guest.memory) return 0;
// Use the pre-calculated usage percentage from the backend
return props.guest.memory.usage || 0;
});
const diskPercent = createMemo(() => {
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
return (props.guest.disk.used / props.guest.disk.total) * 100;
});
const isRunning = createMemo(() => props.guest.status === 'running');
// Get alerts for this guest
const guestAlerts = createMemo(() => {
const guestId = props.guest.id || `${props.guest.instance}-${props.guest.name}-${props.guest.vmid}`;
return getResourceAlerts(guestId, activeAlerts);
});
// Get row styling - include alert styles if present
const rowClass = createMemo(() => {
const base = 'transition-all duration-200';
const hover = 'hover:shadow-sm';
const alertClass = props.alertStyles?.rowClass || '';
const defaultHover = alertClass ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700';
return `${base} ${hover} ${defaultHover} ${alertClass}`;
});
return (
<tr class={rowClass()}>
{/* Name - Sticky column */}
<td class="p-1 px-2 whitespace-nowrap">
<div class="flex items-center gap-2">
{/* Status indicator */}
<span class={`h-2 w-2 rounded-full flex-shrink-0 ${
isRunning() ? 'bg-green-500' : 'bg-gray-400'
}`} title={props.guest.status}></span>
{/* Name */}
<span class="font-medium text-gray-900 dark:text-gray-100 truncate" title={props.guest.name}>
{props.guest.name}
</span>
{/* Alert indicators */}
<Show when={props.alertStyles?.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={props.alertStyles!.severity} alerts={guestAlerts()} />
<Show when={props.alertStyles!.alertCount > 1}>
<AlertCountBadge count={props.alertStyles!.alertCount} severity={props.alertStyles!.severity!} alerts={guestAlerts()} />
</Show>
</div>
</Show>
</div>
</td>
{/* Type */}
<td class="p-1 px-2 whitespace-nowrap">
<span class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}>
{isVM(props.guest) ? 'VM' : 'LXC'}
</span>
</td>
{/* VMID */}
<td class="p-1 px-2 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{props.guest.vmid}
</td>
{/* Node (optional) */}
<Show when={props.showNode}>
<td class="p-1 px-2 text-sm text-gray-600 dark:text-gray-400">
{props.guest.node}
</td>
</Show>
{/* Uptime */}
<td class="p-1 px-2 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
<Show when={isRunning()} fallback="-">
{formatUptime(props.guest.uptime)}
</Show>
</td>
{/* CPU */}
<td class="p-1 px-2 w-[140px]">
<MetricBar
value={cpuPercent()}
label={`${cpuPercent().toFixed(0)}%`}
sublabel={props.guest.cpus ? `${(props.guest.cpu * props.guest.cpus).toFixed(1)}/${props.guest.cpus} cores` : undefined}
type="cpu"
/>
</td>
{/* Memory */}
<td class="p-1 px-2 w-[140px]">
<MetricBar
value={memPercent()}
label={`${memPercent().toFixed(0)}%`}
sublabel={props.guest.memory ? `${formatBytes(props.guest.memory.used)}/${formatBytes(props.guest.memory.total)}` : undefined}
type="memory"
/>
</td>
{/* Disk */}
<td class="p-1 px-2 w-[140px]">
<Show
when={props.guest.disk && props.guest.disk.total > 0}
fallback={<span class="text-gray-400 text-sm">-</span>}
>
<MetricBar
value={diskPercent()}
label={`${diskPercent().toFixed(0)}%`}
sublabel={props.guest.disk ? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}` : undefined}
type="disk"
/>
</Show>
</td>
{/* Disk I/O */}
<td class="p-1 px-2">
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
</td>
<td class="p-1 px-2">
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
</td>
{/* Network I/O */}
<td class="p-1 px-2">
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
</td>
<td class="p-1 px-2">
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
</td>
</tr>
);
}

View file

@ -1,30 +0,0 @@
import { createMemo, Show } from 'solid-js';
import { formatSpeed } from '@/utils/format';
interface IOMetricProps {
value: number;
disabled?: boolean;
}
export function IOMetric(props: IOMetricProps) {
const formatted = createMemo(() => formatSpeed(props.value, 0));
// Color based on speed (MB/s) - matching current dashboard
const colorClass = createMemo(() => {
if (props.disabled) return 'text-gray-400 dark:text-gray-500';
const mbps = props.value / (1024 * 1024);
if (mbps < 1) return 'text-gray-300 dark:text-gray-400';
if (mbps < 10) return 'text-green-600 dark:text-green-400';
if (mbps < 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
});
return (
<Show when={!props.disabled} fallback={<span class="text-sm text-gray-400">-</span>}>
<span class={`text-sm font-mono ${colorClass()}`}>
{formatted()}
</span>
</Show>
);
}

View file

@ -1,68 +0,0 @@
import { createMemo } from 'solid-js';
interface MetricBarProps {
value: number;
label: string;
sublabel?: string;
type?: 'cpu' | 'memory' | 'disk' | 'generic';
}
export function MetricBar(props: MetricBarProps) {
const width = createMemo(() => Math.min(props.value, 100));
// Get color based on percentage and metric type (matching original)
const getColor = createMemo(() => {
const percentage = props.value;
const metric = props.type || 'generic';
if (metric === 'cpu') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else if (metric === 'memory') {
if (percentage >= 85) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
} else if (metric === 'disk') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else {
if (percentage >= 90) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
}
});
// Map color to CSS classes
const progressColorClass = createMemo(() => {
const colorMap = {
'red': 'bg-red-500/60 dark:bg-red-500/50',
'yellow': 'bg-yellow-500/60 dark:bg-yellow-500/50',
'green': 'bg-green-500/60 dark:bg-green-500/50'
};
return colorMap[getColor()] || 'bg-gray-500/60 dark:bg-gray-500/50';
});
// Combine label and sublabel for display text
const displayText = createMemo(() => {
if (props.sublabel) {
return `${props.label} (${props.sublabel})`;
}
return props.label;
});
return (
<div class="metric-text">
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${progressColorClass()}`}
style={{ width: `${width()}%` }}
/>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">{displayText()}</span>
</span>
</div>
</div>
);
}

View file

@ -1,164 +0,0 @@
import { Component, Show, createMemo } from 'solid-js';
import type { Node } from '@/types/api';
import { formatUptime, formatBytes } from '@/utils/format';
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { useWebSocket } from '@/App';
interface NodeCardProps {
node: Node;
}
const NodeCard: Component<NodeCardProps> = (props) => {
const { activeAlerts } = useWebSocket();
// Early return if node data is incomplete
if (!props.node || !props.node.memory || !props.node.disk) {
return (
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-2 border border-gray-200 dark:border-gray-700 flex flex-col gap-1 min-w-[250px]">
<div class="text-sm text-gray-500">Loading node data...</div>
</div>
);
}
const isOnline = () => props.node.status === 'online' && props.node.uptime > 0 && props.node.connectionHealth !== 'error';
const cpuPercent = () => Math.round(props.node.cpu * 100);
const memPercent = () => {
if (!props.node.memory) return 0;
// Use the pre-calculated usage percentage from the backend
return Math.round(props.node.memory.usage || 0);
};
const diskPercent = () => {
if (!props.node.disk || props.node.disk.total === 0) return 0;
return Math.round((props.node.disk.used / props.node.disk.total) * 100);
};
// Calculate normalized load (load average / cpu count)
const normalizedLoad = () => {
if (props.node.loadAverage && props.node.loadAverage.length > 0) {
const load1m = props.node.loadAverage[0];
if (typeof load1m === 'number' && !isNaN(load1m)) {
// Use CPU cores from cpuInfo if available, otherwise assume 4
const cpuCount = props.node.cpuInfo?.cores || 4;
return (load1m / cpuCount).toFixed(2);
}
}
return 'N/A';
};
// Helper function to create progress bar with text overlay (matching original)
const createProgressBar = (percentage: number, text: string, colorClass: string) => {
const bgColorClass = 'bg-gray-200 dark:bg-gray-600';
const progressColorClass = {
'red': 'bg-red-500/60 dark:bg-red-500/50',
'yellow': 'bg-yellow-500/60 dark:bg-yellow-500/50',
'green': 'bg-green-500/60 dark:bg-green-500/50'
}[colorClass] || 'bg-gray-500/60 dark:bg-gray-500/50';
return (
<div class={`relative w-full h-3.5 rounded overflow-hidden ${bgColorClass}`}>
<div class={`absolute top-0 left-0 h-full ${progressColorClass}`} style={{ width: `${percentage}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">{text}</span>
</span>
</div>
);
};
// Get color based on percentage and metric type
const getColor = (percentage: number, metric: 'cpu' | 'memory' | 'disk') => {
if (metric === 'cpu') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else if (metric === 'memory') {
if (percentage >= 85) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
} else if (metric === 'disk') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
}
return 'green';
};
// Format CPU text with cores info
const cpuText = () => {
const cores = props.node.cpuInfo?.cores;
const cpuUsed = cores ? (props.node.cpu * cores).toFixed(1) : '0';
return cores && cores > 0
? `${cpuPercent()}% (${cpuUsed}/${cores} cores)`
: `${cpuPercent()}%`;
};
// Format memory text with size info
const memoryText = () => {
if (!props.node.memory) return '0%';
return `${memPercent()}% (${formatBytes(props.node.memory.used)}/${formatBytes(props.node.memory.total)})`;
};
// Format disk text with size info
const diskText = () => {
if (!props.node.disk) return '0%';
return `${diskPercent()}% (${formatBytes(props.node.disk.used)}/${formatBytes(props.node.disk.total)})`;
};
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
const nodeAlerts = createMemo(() => getResourceAlerts(props.node.id || props.node.name, activeAlerts));
const borderClass = alertStyles.hasAlert
? (alertStyles.severity === 'critical' ? 'border-red-500 border-2' : 'border-orange-500 border-2')
: 'border-gray-200 dark:border-gray-700';
return (
<div class={`bg-white dark:bg-gray-800 shadow-md rounded-lg p-2 border flex flex-col gap-1 min-w-[250px] ${borderClass} ${alertStyles.rowClass}`}>
{/* Header */}
<div class="flex justify-between items-center">
<h3 class="text-sm font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-2">
<span>{props.node.name}</span>
<Show when={alertStyles.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={alertStyles.severity} alerts={nodeAlerts()} />
<Show when={alertStyles.alertCount > 1}>
<AlertCountBadge count={alertStyles.alertCount} severity={alertStyles.severity!} alerts={nodeAlerts()} />
</Show>
</div>
</Show>
</h3>
<div class="flex items-center">
<span class={`h-2.5 w-2.5 rounded-full mr-1.5 flex-shrink-0 ${
isOnline() ? 'bg-green-500' : 'bg-red-500'
}`} />
<span class="text-xs capitalize text-gray-600 dark:text-gray-400">
{isOnline() ? 'online' : props.node.status || 'unknown'}
</span>
</div>
</div>
{/* CPU */}
<div class="text-[11px] text-gray-600 dark:text-gray-400">
<span class="font-medium">CPU:</span>
{createProgressBar(cpuPercent(), cpuText(), getColor(cpuPercent(), 'cpu'))}
</div>
{/* Memory */}
<div class="text-[11px] text-gray-600 dark:text-gray-400">
<span class="font-medium">Mem:</span>
{createProgressBar(memPercent(), memoryText(), getColor(memPercent(), 'memory'))}
</div>
{/* Disk */}
<div class="text-[11px] text-gray-600 dark:text-gray-400">
<span class="font-medium">Disk:</span>
{createProgressBar(diskPercent(), diskText(), getColor(diskPercent(), 'disk'))}
</div>
{/* Footer Info */}
<div class="flex justify-between text-[11px] text-gray-500 dark:text-gray-400 pt-0.5">
<span>Uptime: {formatUptime(props.node.uptime)}</span>
<span>Load: {normalizedLoad()}</span>
</div>
</div>
);
};
export default NodeCard;

View file

@ -1,116 +0,0 @@
import { createSignal, createEffect, onMount } from 'solid-js';
interface ThresholdSliderProps {
value: number;
onChange: (value: number) => void;
type: 'cpu' | 'memory' | 'disk';
min?: number;
max?: number;
}
export function ThresholdSlider(props: ThresholdSliderProps) {
let sliderRef: HTMLInputElement | undefined;
let thumbRef: HTMLDivElement | undefined;
const [thumbPosition, setThumbPosition] = createSignal(0);
const [isDragging, setIsDragging] = createSignal(false);
// Color mapping
const colorMap = {
cpu: 'text-blue-500',
memory: 'text-green-500',
disk: 'text-amber-500'
};
// Calculate visual position (8-92% range) while keeping actual value (0-100%)
const calculateVisualPosition = (value: number) => {
const min = props.min || 0;
const max = props.max || 100;
const percent = ((value - min) / (max - min)) * 100;
// Map 0-100% to 8-92% to prevent edge clipping
return 8 + (percent * 0.84);
};
// Update thumb position when value changes
createEffect(() => {
if (sliderRef) {
setThumbPosition(calculateVisualPosition(props.value));
}
});
onMount(() => {
// Initialize thumb position
setThumbPosition(calculateVisualPosition(props.value));
});
// Prevent scrolling while dragging
const handleMouseDown = () => {
setIsDragging(true);
// Store the current scroll position
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const handleScroll = () => {
window.scrollTo(scrollX, scrollY);
};
const handleMouseUp = () => {
setIsDragging(false);
window.removeEventListener('scroll', handleScroll, { capture: true });
document.removeEventListener('mouseup', handleMouseUp);
};
// Lock scroll position while dragging
window.addEventListener('scroll', handleScroll, { capture: true });
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
class="relative w-full h-3.5 overflow-visible"
onWheel={(e) => isDragging() && e.preventDefault()}
style={{ "touch-action": isDragging() ? "none" : "auto" }}
>
{/* Track background */}
<div class="absolute inset-0 h-3.5 rounded bg-gray-200 dark:bg-gray-600"></div>
{/* Colored fill */}
<div
class={`absolute left-0 h-3.5 rounded ${
props.type === 'cpu' ? 'bg-blue-500/30' :
props.type === 'memory' ? 'bg-green-500/30' :
'bg-amber-500/30'
}`}
style={{ width: `${calculateVisualPosition(props.value)}%` }}
></div>
{/* Native range input (invisible but functional) */}
<input
ref={sliderRef}
type="range"
min={props.min || 0}
max={props.max || 100}
value={props.value}
onInput={(e) => props.onChange(parseInt(e.currentTarget.value))}
onMouseDown={handleMouseDown}
onWheel={(e) => e.preventDefault()}
class="absolute inset-0 w-full h-3.5 opacity-0 cursor-pointer z-20"
style={{ "touch-action": "none" }}
title={`${props.type.toUpperCase()}: ${props.value}%`}
/>
{/* Custom thumb with value */}
<div
ref={thumbRef}
class={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 pointer-events-none z-10 ${colorMap[props.type]}`}
style={{ left: `${thumbPosition()}%` }}
>
<div class="relative">
<div class="w-9 h-4 bg-white dark:bg-gray-800 rounded-full shadow-md border-2 border-current flex items-center justify-center">
<span class="text-[9px] font-semibold">{props.value}%</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,132 +0,0 @@
import { Component, JSX, createSignal, ErrorBoundary as SolidErrorBoundary } from 'solid-js';
import { logError } from '@/utils/logger';
interface ErrorBoundaryProps {
children: JSX.Element;
fallback?: (error: Error, reset: () => void) => JSX.Element;
onError?: (error: Error) => void;
}
const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (props) => {
const [details, setDetails] = createSignal(false);
return (
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
<div class="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div class="flex items-center mb-4">
<svg class="w-12 h-12 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Something went wrong
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
An unexpected error occurred
</p>
</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3 mb-4">
<p class="text-sm text-red-800 dark:text-red-200 font-mono">
{props.error.message}
</p>
</div>
<div class="flex gap-2">
<button
onClick={props.reset}
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
class="flex-1 px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
Reload Page
</button>
</div>
<button
onClick={() => setDetails(!details())}
class="mt-4 text-sm text-gray-500 dark:text-gray-400 underline hover:text-gray-700 dark:hover:text-gray-300"
>
{details() ? 'Hide' : 'Show'} error details
</button>
{details() && (
<div class="mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded overflow-x-auto">
<pre class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{props.error.stack}
</pre>
</div>
)}
</div>
</div>
);
};
export const ErrorBoundary: Component<ErrorBoundaryProps> = (props) => {
return (
<SolidErrorBoundary
fallback={(error, reset) => {
// Log the error
logError('Error boundary caught error', error);
// Call custom error handler if provided
if (props.onError) {
props.onError(error);
}
// Render custom or default fallback
if (props.fallback) {
return props.fallback(error, reset);
}
return <DefaultErrorFallback error={error} reset={reset} />;
}}
>
{props.children}
</SolidErrorBoundary>
);
};
// Component-specific error boundary with more context
export const ComponentErrorBoundary: Component<{
name: string;
children: JSX.Element;
}> = (props) => {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error in {props.name}
</h3>
</div>
<p class="text-xs text-red-700 dark:text-red-300 mb-2">
{error.message}
</p>
<button
onClick={reset}
class="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
onError={(error) => {
logError(`Error in component ${props.name}`, error);
}}
>
{props.children}
</ErrorBoundary>
);
};

View file

@ -1,675 +0,0 @@
import { Component, Show, createSignal, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { NodeConfig } from '@/types/nodes';
import { copyToClipboard } from '@/utils/clipboard';
import { showSuccess } from '@/utils/toast';
import { NodesAPI } from '@/api/nodes';
interface NodeModalProps {
isOpen: boolean;
onClose: () => void;
nodeType: 'pve' | 'pbs';
editingNode?: NodeConfig;
onSave: (nodeData: Partial<NodeConfig>) => void;
}
export const NodeModal: Component<NodeModalProps> = (props) => {
const [testResult, setTestResult] = createSignal<{ status: string; message: string; isCluster?: boolean } | null>(null);
const [isTesting, setIsTesting] = createSignal(false);
const [formData, setFormData] = createSignal({
name: '',
host: '',
authType: 'token' as 'password' | 'token',
user: '',
password: '',
tokenName: '',
tokenValue: '',
fingerprint: '',
verifySSL: true,
// PVE specific
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
// PBS specific
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: false
});
// Update form when editing node changes
createEffect(() => {
if (props.editingNode) {
const node = props.editingNode;
setFormData({
name: node.name || '',
host: node.host || '',
authType: node.user ? 'password' : 'token',
user: node.user || '',
password: '', // Don't show existing password
tokenName: node.tokenName || '',
tokenValue: '', // Don't show existing token
fingerprint: ('fingerprint' in node ? node.fingerprint : '') || '',
verifySSL: node.verifySSL ?? true,
monitorVMs: (node.type === 'pve' && 'monitorVMs' in node ? node.monitorVMs : true) ?? true,
monitorContainers: (node.type === 'pve' && 'monitorContainers' in node ? node.monitorContainers : true) ?? true,
monitorStorage: (node.type === 'pve' && 'monitorStorage' in node ? node.monitorStorage : true) ?? true,
monitorBackups: (node.type === 'pve' && 'monitorBackups' in node ? node.monitorBackups : true) ?? true,
monitorDatastores: (node.type === 'pbs' && 'monitorDatastores' in node ? node.monitorDatastores : true) ?? true,
monitorSyncJobs: (node.type === 'pbs' && 'monitorSyncJobs' in node ? node.monitorSyncJobs : true) ?? true,
monitorVerifyJobs: (node.type === 'pbs' && 'monitorVerifyJobs' in node ? node.monitorVerifyJobs : true) ?? true,
monitorPruneJobs: (node.type === 'pbs' && 'monitorPruneJobs' in node ? node.monitorPruneJobs : true) ?? true,
monitorGarbageJobs: (node.type === 'pbs' && 'monitorGarbageJobs' in node ? node.monitorGarbageJobs : false) ?? false
});
} else {
// Reset form for new node
setFormData({
name: '',
host: '',
authType: 'password',
user: '',
password: '',
tokenName: '',
tokenValue: '',
fingerprint: '',
verifySSL: true,
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: false
});
}
});
const handleSubmit = (e: Event) => {
e.preventDefault();
const data = formData();
// Prepare data based on auth type
const nodeData: Partial<NodeConfig> = {
type: props.nodeType,
name: data.name || '', // Will be auto-generated by backend if empty
host: data.host,
fingerprint: data.fingerprint,
verifySSL: data.verifySSL
};
if (data.authType === 'password') {
nodeData.user = data.user;
if (data.password) {
nodeData.password = data.password;
}
} else {
// For token auth, tokenName contains the full token ID (user@realm!tokenname)
nodeData.tokenName = data.tokenName;
if (data.tokenValue) {
nodeData.tokenValue = data.tokenValue;
}
}
// Add monitor settings based on type
if (props.nodeType === 'pve') {
Object.assign(nodeData, {
monitorVMs: data.monitorVMs,
monitorContainers: data.monitorContainers,
monitorStorage: data.monitorStorage,
monitorBackups: data.monitorBackups
});
} else {
Object.assign(nodeData, {
monitorDatastores: data.monitorDatastores,
monitorSyncJobs: data.monitorSyncJobs,
monitorVerifyJobs: data.monitorVerifyJobs,
monitorPruneJobs: data.monitorPruneJobs,
monitorGarbageJobs: data.monitorGarbageJobs
});
}
props.onSave(nodeData);
};
const updateField = (field: string, value: string | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleTestConnection = async () => {
const data = formData();
// Validate required fields
if (!data.host) {
setTestResult({ status: 'error', message: 'Host is required' });
return;
}
if (data.authType === 'password' && (!data.user || !data.password)) {
setTestResult({ status: 'error', message: 'Username and password are required' });
return;
}
if (data.authType === 'token' && (!data.tokenName || !data.tokenValue)) {
setTestResult({ status: 'error', message: 'Token ID and token value are required' });
return;
}
// Prepare test data
const testData: Partial<NodeConfig> = {
type: props.nodeType,
name: data.name || '', // Will be auto-generated by backend if empty
host: data.host,
fingerprint: data.fingerprint,
verifySSL: data.verifySSL
};
if (data.authType === 'password') {
testData.user = data.user;
testData.password = data.password;
} else {
// For token auth, tokenName contains the full token ID
testData.tokenName = data.tokenName;
testData.tokenValue = data.tokenValue;
}
setIsTesting(true);
setTestResult(null);
try {
const result = await NodesAPI.testConnection(testData as NodeConfig);
setTestResult({
status: 'success',
message: result.message || 'Connection successful',
isCluster: result.isCluster
});
} catch (error) {
setTestResult({
status: 'error',
message: error instanceof Error ? error.message : 'Connection failed'
});
} finally {
setIsTesting(false);
}
};
return (
<Portal>
<Show when={props.isOpen}>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
{/* Backdrop */}
<div
class="fixed inset-0 bg-black/50 transition-opacity"
onClick={props.onClose}
/>
{/* Modal */}
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
<form onSubmit={handleSubmit}>
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{props.editingNode ? 'Edit' : 'Add'} {props.nodeType === 'pve' ? 'Proxmox VE' : 'Proxmox Backup Server'} Node
</h3>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/* Body */}
<div class="p-6 space-y-6">
{/* Basic Information */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Basic Information</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Node Name <span class="text-gray-400">(optional)</span>
</label>
<input
type="text"
value={formData().name}
onInput={(e) => updateField('name', e.currentTarget.value)}
placeholder="Will auto-detect from hostname"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Host URL <span class="text-red-500">*</span>
</label>
<input
type="text"
value={formData().host}
onInput={(e) => updateField('host', e.currentTarget.value)}
placeholder="https://proxmox.example.com:8006"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Authentication */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Authentication</h4>
{/* Auth Type Selector */}
<div class="mb-4">
<div class="flex gap-4">
<label class="flex items-center">
<input
type="radio"
name="authType"
value="password"
checked={formData().authType === 'password'}
onChange={() => updateField('authType', 'password')}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Username & Password</span>
</label>
<label class="flex items-center">
<input
type="radio"
name="authType"
value="token"
checked={formData().authType === 'token'}
onChange={() => updateField('authType', 'token')}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">API Token</span>
</label>
</div>
</div>
{/* Password Auth Fields */}
<Show when={formData().authType === 'password'}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username <span class="text-red-500">*</span>
</label>
<input
type="text"
value={formData().user}
onInput={(e) => updateField('user', e.currentTarget.value)}
placeholder={props.nodeType === 'pve' ? "root@pam" : "admin@pbs"}
required={formData().authType === 'password'}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<Show when={props.nodeType === 'pbs'}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Must include realm (e.g., admin@pbs)</p>
</Show>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password {!props.editingNode && <span class="text-red-500">*</span>}
</label>
<input
type="password"
value={formData().password}
onInput={(e) => updateField('password', e.currentTarget.value)}
placeholder={props.editingNode ? 'Leave blank to keep existing' : 'Password'}
required={formData().authType === 'password' && !props.editingNode}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</Show>
{/* Token Auth Fields */}
<Show when={formData().authType === 'token'}>
<div class="space-y-4">
{/* Token Creation Guide */}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h5 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-3 flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
Quick Token Setup
</h5>
<Show when={props.nodeType === 'pve'}>
<div class="space-y-3 text-xs">
<p class="text-blue-800 dark:text-blue-200">Run these commands in your Proxmox VE shell:</p>
<div class="relative bg-white dark:bg-gray-800 rounded-md p-3 font-mono text-gray-800 dark:text-gray-200">
<button
type="button"
onClick={async () => {
const commands = `# Create user (skip if using root@pam)
pveum user add pulse-monitor@pam --comment "Pulse monitoring"
# Create API token
pveum user token add pulse-monitor@pam pulse-token --privsep 0
# Add permissions (required for monitoring and cluster detection)
pveum aclmod / -user pulse-monitor@pam -role PVEAuditor
pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin`;
if (await copyToClipboard(commands)) {
showSuccess('Commands copied to clipboard!');
}
}}
class="absolute top-2 right-2 p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-md transition-colors"
title="Copy all commands"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
</button>
<div class="mb-2"># 1. Create user (skip if using root@pam)</div>
<div class="text-green-600 dark:text-green-400">pveum user add pulse-monitor@pam --comment "Pulse monitoring"</div>
<div class="mt-3 mb-2"># 2. Create API token</div>
<div class="text-green-600 dark:text-green-400">pveum user token add pulse-monitor@pam pulse-token --privsep 0</div>
<div class="mt-3 mb-2"># 3. Add permissions (required for monitoring and cluster detection)</div>
<div class="text-green-600 dark:text-green-400">pveum aclmod / -user pulse-monitor@pam -role PVEAuditor</div>
<div class="text-green-600 dark:text-green-400">pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin</div>
</div>
<div class="space-y-1">
<p class="text-blue-700 dark:text-blue-200 text-xs">
<strong>Note:</strong> Copy the token value immediately after step 2 - it's only shown once!
</p>
<p class="text-gray-600 dark:text-gray-400 text-xs">
<strong>Permissions explained:</strong>
<br /> PVEAuditor on / - Required for cluster detection and viewing VMs/containers
<br /> PVEDatastoreAdmin on /storage - Required for viewing backup information
<br /> Token uses --privsep 0 to inherit all user permissions
</p>
</div>
</div>
</Show>
<Show when={props.nodeType === 'pbs'}>
<div class="space-y-3 text-xs">
<p class="text-blue-800 dark:text-blue-200">Run these commands in your Proxmox Backup Server shell:</p>
<div class="relative bg-white dark:bg-gray-800 rounded-md p-3 font-mono text-gray-800 dark:text-gray-200">
<button
type="button"
onClick={async () => {
const commands = `# Create user
proxmox-backup-manager user create pulse-monitor@pbs
# Create API token
proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token
# Add permissions
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'`;
if (await copyToClipboard(commands)) {
showSuccess('Commands copied to clipboard!');
}
}}
class="absolute top-2 right-2 p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-md transition-colors"
title="Copy all commands"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
</button>
<div class="mb-2"># 1. Create user</div>
<div class="text-green-600 dark:text-green-400">proxmox-backup-manager user create pulse-monitor@pbs</div>
<div class="mt-3 mb-2"># 2. Create API token</div>
<div class="text-green-600 dark:text-green-400">proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token</div>
<div class="mt-3 mb-2"># 3. Add permissions</div>
<div class="text-green-600 dark:text-green-400">proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs</div>
<div class="text-green-600 dark:text-green-400">proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'</div>
</div>
<p class="text-blue-700 dark:text-blue-200 text-xs">
<strong>Note:</strong> Copy the token value immediately after step 2 - it's only shown once!
</p>
</div>
</Show>
</div>
{/* Token Input Fields */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Token ID <span class="text-red-500">*</span>
</label>
<input
type="text"
value={formData().tokenName}
onInput={(e) => updateField('tokenName', e.currentTarget.value)}
placeholder={props.nodeType === 'pve' ? 'pulse-monitor@pam!pulse-token' : 'pulse-monitor@pbs!pulse-token'}
required={formData().authType === 'token'}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full token ID from Proxmox (user@realm!tokenname)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Token Value {!props.editingNode && <span class="text-red-500">*</span>}
</label>
<input
type="password"
value={formData().tokenValue}
onInput={(e) => updateField('tokenValue', e.currentTarget.value)}
placeholder={props.editingNode ? 'Leave blank to keep existing' : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'}
required={formData().authType === 'token' && !props.editingNode}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The secret value shown when creating the token</p>
</div>
</div>
</div>
</Show>
</div>
{/* SSL Settings */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">SSL Settings</h4>
<div class="space-y-3">
<div class="flex items-center">
<input
type="checkbox"
id="verifySSL"
checked={formData().verifySSL}
onChange={(e) => updateField('verifySSL', e.currentTarget.checked)}
class="mr-2"
/>
<label for="verifySSL" class="text-sm text-gray-700 dark:text-gray-300">
Verify SSL Certificate
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SSL Fingerprint (Optional)
</label>
<input
type="text"
value={formData().fingerprint}
onInput={(e) => updateField('fingerprint', e.currentTarget.value)}
placeholder="AA:BB:CC:DD:EE:FF:..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
/>
</div>
</div>
</div>
{/* Monitoring Options */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Monitoring Options</h4>
<div class="space-y-2">
{props.nodeType === 'pve' ? (
<>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorVMs}
onChange={(e) => updateField('monitorVMs', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Virtual Machines</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorContainers}
onChange={(e) => updateField('monitorContainers', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Containers</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorStorage}
onChange={(e) => updateField('monitorStorage', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Storage</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorBackups}
onChange={(e) => updateField('monitorBackups', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Backups</span>
</label>
</>
) : (
<>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorDatastores}
onChange={(e) => updateField('monitorDatastores', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Datastores</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorSyncJobs}
onChange={(e) => updateField('monitorSyncJobs', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Sync Jobs</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorVerifyJobs}
onChange={(e) => updateField('monitorVerifyJobs', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Verify Jobs</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorPruneJobs}
onChange={(e) => updateField('monitorPruneJobs', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Prune Jobs</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
checked={formData().monitorGarbageJobs}
onChange={(e) => updateField('monitorGarbageJobs', e.currentTarget.checked)}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Monitor Garbage Collection Jobs</span>
</label>
</>
)}
</div>
</div>
</div>
{/* Test Result */}
<Show when={testResult()}>
<div class={`mx-6 p-3 rounded-lg text-sm ${
testResult()?.status === 'success'
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
}`}>
<div class="flex items-start gap-2">
<Show when={testResult()?.status === 'success'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0 mt-0.5">
<path d="M9 12l2 2 4-4"></path>
<circle cx="12" cy="12" r="10"></circle>
</svg>
</Show>
<Show when={testResult()?.status === 'error'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0 mt-0.5">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</Show>
<div>
<p>{testResult()?.message}</p>
<Show when={testResult()?.isCluster}>
<p class="mt-1 text-xs opacity-80"> Cluster detected! All cluster nodes will be automatically added.</p>
</Show>
</div>
</div>
</div>
</Show>
{/* Footer */}
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={handleTestConnection}
disabled={isTesting()}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTesting() ? 'Testing...' : 'Test Connection'}
</button>
<div class="flex items-center gap-3">
<button
type="button"
onClick={props.onClose}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{props.editingNode ? 'Update' : 'Add'} Node
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</Show>
</Portal>
);
};

View file

@ -1,449 +0,0 @@
import { Component, For, Show, createSignal, createMemo, createEffect } from 'solid-js';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { formatBytes } from '@/utils/format';
import { createTooltipSystem } from '@/components/shared/Tooltip';
import type { Storage as StorageType } from '@/types/api';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
const Storage: Component = () => {
const { state, connected, activeAlerts, initialDataReceived } = useWebSocket();
const [viewMode, setViewMode] = createSignal<'node' | 'storage'>('node');
const [searchTerm, setSearchTerm] = createSignal('');
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('storageShowFilters') !== null
? localStorage.getItem('storageShowFilters') === 'true'
: false // Default to collapsed
);
// Create tooltip system
const TooltipComponent = createTooltipSystem();
// Load preferences from localStorage
createEffect(() => {
const savedViewMode = localStorage.getItem('storageViewMode');
if (savedViewMode === 'storage') setViewMode('storage');
});
// Save preferences to localStorage
createEffect(() => {
localStorage.setItem('storageViewMode', viewMode());
});
createEffect(() => {
localStorage.setItem('storageShowFilters', showFilters().toString());
});
// Filter storage - in storage view, filter out 0 capacity
const filteredStorage = createMemo(() => {
const storage = state.storage || [];
if (viewMode() === 'storage') {
return storage.filter((s) => s.total > 0);
}
return storage;
});
// Sort and filter storage
const sortedStorage = createMemo(() => {
let storage = [...filteredStorage()];
// Apply search filter
const search = searchTerm().toLowerCase();
if (search) {
storage = storage.filter(s =>
s.name.toLowerCase().includes(search) ||
s.node.toLowerCase().includes(search) ||
s.type.toLowerCase().includes(search) ||
s.content?.toLowerCase().includes(search) ||
(s.status && s.status.toLowerCase().includes(search))
);
}
// Always sort by name alphabetically for consistent order
return storage.sort((a, b) => a.name.localeCompare(b.name));
});
// Group storage by node or storage
const groupedStorage = createMemo(() => {
const storage = sortedStorage();
const mode = viewMode();
if (mode === 'node') {
const groups: Record<string, StorageType[]> = {};
storage.forEach(s => {
if (!groups[s.node]) groups[s.node] = [];
groups[s.node].push(s);
});
return groups;
} else {
// Group by storage name - show all storage as-is for maximum compatibility
const groups: Record<string, StorageType[]> = {};
storage.forEach(s => {
if (!groups[s.name]) groups[s.name] = [];
groups[s.name].push(s);
});
return groups;
}
});
const getProgressBarColor = (usage: number) => {
// Match MetricBar component styling - use the same disk/generic logic
if (usage >= 90) return 'bg-red-500/60 dark:bg-red-500/50';
if (usage >= 80) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
if (usage >= 70) return 'bg-amber-500/60 dark:bg-amber-500/50';
if (usage >= 60) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
return 'bg-emerald-500/60 dark:bg-emerald-500/50';
};
const resetFilters = () => {
setSearchTerm('');
setViewMode('node');
};
const getTotalByNode = (storages: StorageType[]) => {
const totals = { used: 0, total: 0, free: 0 };
storages.forEach(s => {
totals.used += s.used || 0;
totals.total += s.total || 0;
totals.free += s.free || 0;
});
return totals;
};
const calculateOverallUsage = (storages: StorageType[]) => {
const totals = getTotalByNode(storages);
return totals.total > 0 ? (totals.used / totals.total * 100) : 0;
};
// Handle keyboard shortcuts
let searchInputRef: HTMLInputElement | undefined;
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input
const target = e.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear
if (searchTerm().trim() || viewMode() !== 'node') {
// Clear search and reset filters
resetFilters();
// Blur the search input if it's focused
if (searchInputRef && document.activeElement === searchInputRef) {
searchInputRef.blur();
}
} else if (showFilters()) {
// No search/filters active, so collapse the filters section
setShowFilters(false);
}
// If filters are already collapsed, do nothing
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
}
// Focus the search input and let the character be typed
if (searchInputRef) {
searchInputRef.focus();
// Don't prevent default - let the character be typed
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
return (
<div>
{/* Filters and Search */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg mb-4 overflow-hidden">
<button
onClick={() => setShowFilters(!showFilters())}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
>
<span class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
Filters & Search
<Show when={searchTerm() || viewMode() !== 'node'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
</Show>
</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class={`transform transition-transform ${showFilters() ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class={`filter-controls-wrapper ${showFilters() ? 'block' : 'hidden'} p-3 lg:p-4 border-t border-gray-200 dark:border-gray-700`}>
<div class="flex flex-col gap-3">
{/* Search Bar Row */}
<div class="flex gap-2">
<div class="relative flex-1">
<input
ref={searchInputRef}
type="text"
placeholder="Search by name, node, type, content, or status..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Reset Button */}
<button
onClick={resetFilters}
title="Reset all filters"
class="flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
<span class="ml-1.5 hidden sm:inline">Reset</span>
</button>
</div>
{/* Filters Row */}
<div class="flex flex-col sm:flex-row gap-2">
{/* View Mode Toggle */}
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Group by:</span>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setViewMode('node')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'node'
? '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'
}`}
>
Node
</button>
<button
onClick={() => setViewMode('storage')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'storage'
? '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'
}`}
>
Storage
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="animate-spin mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Loading storage data...</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Connecting to monitoring service</p>
</div>
</div>
</Show>
{/* Empty State - No Storage Configured */}
<Show when={connected() && initialDataReceived() && sortedStorage().length === 0}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No storage found</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">No storage repositories are configured or visible.</p>
</div>
</div>
</Show>
{/* Storage Table */}
<Show when={connected() && initialDataReceived() && sortedStorage().length > 0}>
<ComponentErrorBoundary name="Storage Table">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Storage</th>
<Show when={viewMode() === 'node'}>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">Node</th>
</Show>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden md:table-cell">Type</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden lg:table-cell">Content</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">Status</th>
<Show when={viewMode() === 'node'}>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden lg:table-cell">Shared</th>
</Show>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider min-w-[100px] sm:min-w-[150px] md:min-w-[200px]">Usage</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">Free</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Total</th>
</tr>
</thead>
<tbody>
<For each={Object.entries(groupedStorage()).sort(([a], [b]) => a.localeCompare(b))}>
{([groupName, storages]) => (
<>
{/* Group Header */}
<Show when={viewMode() === 'node'}>
<tr class="bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
<a
href={`https://${groupName}:8006`}
target="_blank"
rel="noopener noreferrer"
class="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${groupName} web interface`}
>
{groupName}
</a>
</td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400" colspan="7">
<span class="text-[10px]">
{getTotalByNode(storages).total > 0 && (
<span>
{formatBytes(getTotalByNode(storages).used)} / {formatBytes(getTotalByNode(storages).total)} ({calculateOverallUsage(storages).toFixed(1)}%)
</span>
)}
</span>
</td>
</tr>
</Show>
{/* Storage Rows */}
<For each={storages} fallback={<></>}>
{(storage) => {
const usagePercent = storage.total > 0 ? (storage.used / storage.total * 100) : 0;
const isDisabled = storage.status !== 'available';
const alertStyles = getAlertStyles(storage.id || `${storage.instance}-${storage.name}`, activeAlerts);
const rowClass = `${isDisabled ? 'opacity-60' : ''} ${alertStyles.rowClass} hover:shadow-sm transition-all duration-200`;
return (
<tr class={rowClass}>
<td class="p-1 px-2">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-gray-100">
{storage.name}
</span>
<Show when={alertStyles.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={alertStyles.severity} alerts={[]} />
<Show when={alertStyles.alertCount > 1}>
<AlertCountBadge count={alertStyles.alertCount} severity={alertStyles.severity!} alerts={[]} />
</Show>
</div>
</Show>
</div>
</td>
<Show when={viewMode() === 'node'}>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400 hidden sm:table-cell">{storage.node}</td>
</Show>
<td class="p-1 px-2 hidden md:table-cell">
<span class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${
storage.type === 'dir' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' :
storage.type === 'pbs' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' :
'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
}`}>
{storage.type}
</span>
</td>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400 hidden lg:table-cell">
{storage.content || '-'}
</td>
<td class="p-1 px-2 text-xs hidden sm:table-cell">
<span class={`${
storage.status === 'available' ? 'text-green-600 dark:text-green-400' :
'text-red-600 dark:text-red-400'
}`}>
{storage.status || 'unknown'}
</span>
</td>
<Show when={viewMode() === 'node'}>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400 hidden lg:table-cell">
{storage.shared ? '✓' : '-'}
</td>
</Show>
<td class="p-1 px-2">
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${getProgressBarColor(usagePercent)}`}
style={{ width: `${usagePercent}%` }}
/>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">
<span class="sm:hidden">{usagePercent.toFixed(0)}%</span>
<span class="hidden sm:inline">{formatBytes(storage.used || 0)} / {formatBytes(storage.total || 0)} ({usagePercent.toFixed(1)}%)</span>
</span>
</span>
</div>
</td>
<td class="p-1 px-2 text-xs hidden sm:table-cell">{formatBytes(storage.free || 0)}</td>
<td class="p-1 px-2 text-xs">{formatBytes(storage.total || 0)}</td>
</tr>
);
}}
</For>
</>
)}
</For>
</tbody>
</table>
</div>
</ComponentErrorBoundary>
</Show>
{/* Tooltip System */}
<TooltipComponent />
</div>
);
};
export default Storage;

View file

@ -1,128 +0,0 @@
import { Component, createSignal, For, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { POLLING_INTERVALS, ANIMATIONS } from '@/constants';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastMessage {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
}
interface ToastProps {
toast: ToastMessage;
onRemove: (id: string) => void;
}
const Toast: Component<ToastProps> = (props) => {
const [show, setShow] = createSignal(true);
const icons = {
success: (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
),
warning: (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
};
const colors = {
success: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
error: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border-red-200 dark:border-red-800',
warning: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800',
info: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800'
};
const iconColors = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500'
};
// Auto-remove after duration
window.setTimeout(() => {
setShow(false);
window.setTimeout(() => props.onRemove(props.toast.id), ANIMATIONS.TOAST_SLIDE);
}, props.toast.duration || POLLING_INTERVALS.TOAST_DURATION);
return (
<div
class={`transform transition-[transform,opacity] duration-300 ${
show() ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
}`}
>
<div class={`flex items-start gap-3 p-4 border rounded-lg shadow-lg ${colors[props.toast.type]}`}>
<div class={`flex-shrink-0 ${iconColors[props.toast.type]}`}>
{icons[props.toast.type]}
</div>
<div class="flex-1">
<h3 class="text-sm font-medium">{props.toast.title}</h3>
<Show when={props.toast.message}>
<p class="mt-1 text-xs opacity-90">{props.toast.message}</p>
</Show>
</div>
<button
onClick={() => {
setShow(false);
window.setTimeout(() => props.onRemove(props.toast.id), ANIMATIONS.TOAST_SLIDE);
}}
class="flex-shrink-0 ml-2 hover:opacity-70 transition-opacity"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
};
// Toast Container Component
// Declare global interface extension
declare global {
interface Window {
showToast: (type: ToastType, title: string, message?: string, duration?: number) => void;
}
}
export const ToastContainer: Component = () => {
const [toasts, setToasts] = createSignal<ToastMessage[]>([]);
const removeToast = (id: string) => {
setToasts(toasts().filter(t => t.id !== id));
};
// Expose global toast function
window.showToast = (type: ToastType, title: string, message?: string, duration?: number) => {
const id = Date.now().toString();
setToasts([...toasts(), { id, type, title, message, duration }]);
};
return (
<Portal>
<div class="fixed top-4 right-4 z-50 space-y-2 max-w-sm">
<For each={toasts()}>
{(toast) => <Toast toast={toast} onRemove={removeToast} />}
</For>
</div>
</Portal>
);
};

View file

@ -1,115 +0,0 @@
import { Component, Show, createSignal } from 'solid-js';
import type { Alert } from '@/types/api';
import { Portal } from 'solid-js/web';
interface AlertIndicatorProps {
severity: 'critical' | 'warning' | null;
alerts?: Alert[];
}
export const AlertIndicator: Component<AlertIndicatorProps> = (props) => {
if (!props.severity) return null;
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPosition, setTooltipPosition] = createSignal({ x: 0, y: 0 });
const dotClass = props.severity === 'critical'
? 'bg-red-500 animate-pulse'
: 'bg-orange-500';
const handleMouseEnter = (e: MouseEvent) => {
if (!props.alerts || props.alerts.length === 0) return;
const rect = (e.target as HTMLElement).getBoundingClientRect();
setTooltipPosition({ x: rect.left + rect.width / 2, y: rect.top - 5 });
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<>
<span
class={`inline-block w-2 h-2 rounded-full ${dotClass}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
<Show when={showTooltip() && props.alerts && props.alerts.length > 0}>
<Portal>
<div
class="fixed z-50 bg-gray-900 text-white text-xs rounded px-2 py-1 pointer-events-none transform -translate-x-1/2 -translate-y-full"
style={{
left: `${tooltipPosition().x}px`,
top: `${tooltipPosition().y}px`,
}}
>
{props.alerts!.map((alert, i) => (
<div class={i > 0 ? 'mt-1' : ''}>
{alert.type}: {alert.value.toFixed(1)}% (threshold: {alert.threshold}%)
</div>
))}
<div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900" />
</div>
</Portal>
</Show>
</>
);
};
interface AlertCountBadgeProps {
count: number;
severity: 'critical' | 'warning';
alerts?: Alert[];
}
export const AlertCountBadge: Component<AlertCountBadgeProps> = (props) => {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPosition, setTooltipPosition] = createSignal({ x: 0, y: 0 });
const badgeClass = props.severity === 'critical'
? 'bg-red-500 text-white'
: 'bg-orange-500 text-white';
const handleMouseEnter = (e: MouseEvent) => {
if (!props.alerts || props.alerts.length === 0) return;
const rect = (e.target as HTMLElement).getBoundingClientRect();
setTooltipPosition({ x: rect.left + rect.width / 2, y: rect.top - 5 });
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<>
<span
class={`inline-flex items-center justify-center min-w-[20px] h-5 px-1 text-xs font-medium rounded-full ${badgeClass}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{props.count}
</span>
<Show when={showTooltip() && props.alerts && props.alerts.length > 0}>
<Portal>
<div
class="fixed z-50 bg-gray-900 text-white text-xs rounded px-2 py-1 pointer-events-none transform -translate-x-1/2 -translate-y-full max-w-xs"
style={{
left: `${tooltipPosition().x}px`,
top: `${tooltipPosition().y}px`,
}}
>
<div class="font-semibold mb-1">{props.count} Active Alerts:</div>
{props.alerts!.map((alert, i) => (
<div class={i > 0 ? 'mt-1' : ''}>
{i + 1}. {alert.type}: {alert.value.toFixed(1)}% (threshold: {alert.threshold}%)
</div>
))}
<div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900" />
</div>
</Portal>
</Show>
</>
);
};

View file

@ -1,58 +0,0 @@
import { Component, JSX, createSignal, createEffect, onMount } from 'solid-js';
import { Show } from 'solid-js';
interface ScrollableTableProps {
children: JSX.Element;
class?: string;
minWidth?: string;
}
export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
const [showLeftFade, setShowLeftFade] = createSignal(false);
const [showRightFade, setShowRightFade] = createSignal(false);
let scrollContainer: HTMLDivElement | undefined;
const checkScroll = () => {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
setShowLeftFade(scrollLeft > 0);
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
};
onMount(() => {
checkScroll();
window.addEventListener('resize', checkScroll);
});
createEffect(() => {
if (scrollContainer) {
scrollContainer.addEventListener('scroll', checkScroll);
return () => scrollContainer?.removeEventListener('scroll', checkScroll);
}
});
return (
<div class={`relative ${props.class || ''}`}>
{/* Left fade */}
<Show when={showLeftFade()}>
<div class="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white dark:from-gray-800 to-transparent z-10 pointer-events-none" />
</Show>
{/* Scrollable container */}
<div
ref={scrollContainer}
class="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-400 dark:scrollbar-thumb-gray-600"
>
<div style={{ "min-width": props.minWidth || 'auto' }}>
{props.children}
</div>
</div>
{/* Right fade */}
<Show when={showRightFade()}>
<div class="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white dark:from-gray-800 to-transparent z-10 pointer-events-none" />
</Show>
</div>
);
};

View file

@ -1,109 +0,0 @@
import { Component, createSignal, createEffect, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
interface TooltipProps {
content: string;
x: number;
y: number;
visible: boolean;
}
const Tooltip: Component<TooltipProps> = (props) => {
let tooltipRef: HTMLDivElement | undefined;
const [position, setPosition] = createSignal({ x: 0, y: 0 });
createEffect(() => {
if (!props.visible) {
setPosition({ x: props.x, y: props.y });
return;
}
// Use requestAnimationFrame to ensure DOM is updated
requestAnimationFrame(() => {
if (!tooltipRef) return;
// Calculate position to keep tooltip on screen
const rect = tooltipRef.getBoundingClientRect();
const padding = 10;
let x = props.x + padding;
let y = props.y - rect.height - padding;
// Keep within viewport
if (x + rect.width > window.innerWidth) {
x = props.x - rect.width - padding;
}
if (y < 0) {
y = props.y + padding;
}
// Ensure x and y are not negative
x = Math.max(0, x);
y = Math.max(0, y);
setPosition({ x, y });
});
});
return (
<Show when={props.visible}>
<Portal mount={document.body}>
<div
ref={tooltipRef}
class="fixed z-50 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-800 rounded shadow-lg pointer-events-none whitespace-nowrap"
style={{
left: '0',
top: '0',
transform: `translate(${position().x}px, ${position().y}px)`,
opacity: props.visible ? '1' : '0',
transition: 'opacity 200ms ease-out'
}}
innerHTML={props.content}
/>
</Portal>
</Show>
);
};
// Global tooltip singleton
let tooltipInstance: {
show: (content: string, x: number, y: number) => void;
hide: () => void;
} | null = null;
export function createTooltipSystem() {
const [visible, setVisible] = createSignal(false);
const [content, setContent] = createSignal('');
const [position, setPosition] = createSignal({ x: 0, y: 0 });
tooltipInstance = {
show: (content: string, x: number, y: number) => {
setContent(content);
setPosition({ x, y });
setVisible(true);
},
hide: () => {
setVisible(false);
}
};
return () => (
<Tooltip
content={content()}
x={position().x}
y={position().y}
visible={visible()}
/>
);
}
export function showTooltip(content: string, x: number, y: number) {
tooltipInstance?.show(content, x, y);
}
export function hideTooltip() {
tooltipInstance?.hide();
}
export default Tooltip;

View file

@ -1,69 +0,0 @@
// Constants used throughout the application
// Polling and update intervals (in milliseconds)
export const POLLING_INTERVALS = {
DEFAULT: 5000, // 5 seconds - default polling interval
RECONNECT_BASE: 1000, // 1 second - base reconnect delay
RECONNECT_MAX: 30000, // 30 seconds - max reconnect delay
DATA_FLASH: 1000, // 1 second - data update indicator flash duration
TOAST_DURATION: 5000, // 5 seconds - default toast notification duration
} as const;
// Display thresholds (percentages)
export const THRESHOLDS = {
WARNING: 60, // Yellow warning threshold
CRITICAL: 80, // Orange critical threshold
DANGER: 90, // Red danger threshold
} as const;
// Network and I/O metrics thresholds (MB/s)
export const IO_THRESHOLDS = {
LOW: 1,
MEDIUM: 10,
HIGH: 50,
VERY_HIGH: 100,
} as const;
// Animation durations (in milliseconds)
export const ANIMATIONS = {
TOAST_SLIDE: 300, // Toast slide in/out animation
} as const;
// UI configuration
export const UI = {
DEBOUNCE_DELAY: 300, // 300ms - input debounce delay
} as const;
// WebSocket configuration
export const WEBSOCKET = {
PING_INTERVAL: 25000, // 25 seconds - WebSocket ping interval
MESSAGE_TYPES: {
INITIAL_STATE: 'initialState',
RAW_DATA: 'rawData',
ERROR: 'error',
} as const,
} as const;
// Storage keys for localStorage
export const STORAGE_KEYS = {
DARK_MODE: 'darkMode',
VIEW_MODE: 'viewMode',
DISPLAY_MODE: 'displayMode',
SORT_KEY: 'sortKey',
SORT_DIRECTION: 'sortDirection',
ALERT_THRESHOLDS: 'alertThresholds',
} as const;
// File size units
export const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const;
// Log levels for the logger
export const LOG_LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
} as const;
export type LogLevel = keyof typeof LOG_LEVELS;

View file

@ -1,45 +0,0 @@
import { onMount, onCleanup, createSignal } from 'solid-js';
interface UseIntersectionObserverOptions {
threshold?: number;
rootMargin?: string;
root?: Element | null;
}
export function useIntersectionObserver(
ref: () => HTMLElement | undefined,
options: UseIntersectionObserverOptions = {}
) {
const [isIntersecting, setIsIntersecting] = createSignal(false);
let observer: IntersectionObserver | undefined;
onMount(() => {
const element = ref();
if (!element || typeof window === 'undefined' || !window.IntersectionObserver) {
// If IntersectionObserver is not supported, assume element is visible
setIsIntersecting(true);
return;
}
observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
},
{
threshold: options.threshold || 0,
rootMargin: options.rootMargin || '50px',
root: options.root || null
}
);
observer.observe(element);
});
onCleanup(() => {
if (observer) {
observer.disconnect();
}
});
return isIntersecting;
}

View file

@ -1,299 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles */
@layer base {
body {
@apply bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
/* Ensure body scrollbar follows our styling */
overflow-y: auto;
overflow-x: hidden;
}
/* Hide scrollbars during transitions to prevent flicker */
html {
overflow-y: scroll;
scroll-behavior: smooth;
}
/* Threshold slider styles */
.threshold-slider-container {
@apply relative w-full;
}
.threshold-slider {
@apply w-full h-3.5 appearance-none bg-transparent relative z-10;
}
/* Slider track styles - match progress bar height */
.threshold-slider::-webkit-slider-runnable-track {
@apply h-3.5 rounded bg-gray-200 dark:bg-gray-600;
}
.threshold-slider::-moz-range-track {
@apply h-3.5 rounded bg-gray-200 dark:bg-gray-600;
}
/* Slider thumb styles with value display */
.threshold-slider::-webkit-slider-thumb {
@apply appearance-none w-12 h-5 rounded-full cursor-pointer relative;
margin-top: -2.5px; /* Center thumb on track */
background: white;
border: 2px solid currentColor;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.threshold-slider::-moz-range-thumb {
@apply appearance-none w-12 h-5 rounded-full cursor-pointer relative border-0;
background: white;
border: 2px solid currentColor;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
/* Color coding for different metrics */
.threshold-slider.slider-cpu {
color: #3b82f6; /* blue-500 */
}
.threshold-slider.slider-memory {
color: #10b981; /* green-500 */
}
.threshold-slider.slider-disk {
color: #f59e0b; /* amber-500 */
}
/* Value display inside thumb */
.threshold-value {
@apply absolute inset-0 flex items-center justify-center text-[10px] font-semibold pointer-events-none;
color: currentColor;
}
}
@layer components {
/* Card styles */
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
}
/* Table styles */
.table-header {
@apply text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider;
}
.table-row {
@apply border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750;
}
/* Status indicators */
.status-running {
@apply text-green-600 dark:text-green-400;
}
.status-stopped {
@apply text-gray-600 dark:text-gray-400;
}
.status-error {
@apply text-red-600 dark:text-red-400;
}
/* Progress bars */
.progress-bar {
@apply bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden;
}
.progress-fill {
@apply h-full;
transition: width 300ms ease-out;
}
/* Stable chart containers */
.chart-stable-container {
contain: layout style;
will-change: contents;
}
/* Prevent chart SVG flicker */
.sparkline {
backface-visibility: hidden;
transform: translateZ(0);
}
/* Disable animations in charts mode to prevent blinking */
.charts-mode .sparkline path {
transition: none !important;
}
/* Custom scrollbar - subtle styling */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: theme('colors.gray.300') theme('colors.gray.100');
}
.dark .custom-scrollbar {
scrollbar-color: theme('colors.gray.700') theme('colors.gray.800');
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-800;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-600;
}
}
/* Global scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; /* gray-400 with transparency */
}
.dark * {
scrollbar-color: rgba(55, 65, 81, 0.5) transparent; /* gray-700 with transparency */
}
/* Make scrollbars even more subtle on hover */
*:hover {
scrollbar-color: rgba(156, 163, 175, 0.7) rgba(243, 244, 246, 0.5); /* Slightly more visible on hover */
}
.dark *:hover {
scrollbar-color: rgba(75, 85, 99, 0.7) rgba(31, 41, 55, 0.3); /* dark mode hover */
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 10px;
}
.dark *::-webkit-scrollbar-thumb {
background-color: rgba(55, 65, 81, 0.5);
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.8);
}
.dark *::-webkit-scrollbar-thumb:hover {
background-color: rgba(75, 85, 99, 0.8);
}
/* Dark mode transitions - Only apply to elements that actually change colors */
body,
.card,
.table-row,
.table-header,
.bg-white,
.bg-gray-50,
.bg-gray-100,
.bg-gray-200,
.bg-gray-300,
.bg-gray-700,
.bg-gray-800,
.bg-gray-900,
.text-gray-500,
.text-gray-600,
.text-gray-700,
.text-gray-800,
.text-gray-900,
.border-gray-200,
.border-gray-300,
.border-gray-600,
.border-gray-700,
.dark\:bg-gray-700,
.dark\:bg-gray-800,
.dark\:bg-gray-900,
.dark\:text-gray-100,
.dark\:text-gray-200,
.dark\:text-gray-300,
.dark\:text-gray-400,
.dark\:border-gray-600,
.dark\:border-gray-700 {
transition: background-color 150ms ease-in-out, color 150ms ease-in-out, border-color 150ms ease-in-out;
}
/* Pulse logo animation - subtle ripple effect */
@keyframes pulse-logo {
0% {
transform: scale(1);
}
25% {
transform: scale(1.05);
}
50% {
transform: scale(1);
}
}
@keyframes pulse-ring {
0%, 100% {
opacity: 0.92;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
.animate-pulse-logo {
animation: pulse-logo 0.8s cubic-bezier(0.4, 0, 0.6, 1);
transform-origin: center;
}
.animate-pulse-logo .pulse-ring {
animation: pulse-ring 0.8s cubic-bezier(0.4, 0, 0.6, 1);
transform-origin: center;
transform-box: fill-box;
}
/* Stable table layout */
.table-fixed {
table-layout: fixed;
}
/* Prevent text from affecting column widths */
.table-fixed td,
.table-fixed th {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep name column slightly flexible while maintaining stability */
.table-fixed td:first-child {
white-space: normal;
word-break: break-word;
}
/* Hide scrollbars but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View file

@ -1,19 +0,0 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
import App from './App';
import { logger } from './utils/logger';
const root = document.getElementById('root');
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?',
);
}
// Initialize app with logging
logger.info('Pulse monitoring dashboard starting');
render(() => <App />, root!);

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
import { createWebSocketStore } from './websocket';
// Store the instance on window to survive hot reloads
declare global {
interface Window {
__pulseWsStore?: ReturnType<typeof createWebSocketStore>;
}
}
export function getGlobalWebSocketStore() {
if (!window.__pulseWsStore) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Use relative URL that works behind proxies
const wsUrl = `${protocol}//${window.location.host}/ws`;
window.__pulseWsStore = createWebSocketStore(wsUrl);
}
return window.__pulseWsStore;
}

View file

@ -1,258 +0,0 @@
import { createSignal, onCleanup } from 'solid-js';
import { createStore } from 'solid-js/store';
import type { State, WSMessage, Alert, ResolvedAlert, PVEBackups } from '@/types/api';
import { logger } from '@/utils/logger';
import { POLLING_INTERVALS, WEBSOCKET } from '@/constants';
// Type-safe WebSocket store
export function createWebSocketStore(url: string) {
const [connected, setConnected] = createSignal(false);
const [reconnecting, setReconnecting] = createSignal(false);
const [initialDataReceived, setInitialDataReceived] = createSignal(false);
const [state, setState] = createStore<State>({
nodes: [],
vms: [],
containers: [],
storage: [],
pbs: [],
metrics: [],
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: []
} as PVEBackups,
pbsBackups: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0
},
connectionHealth: {},
stats: {
startTime: new Date().toISOString(),
uptime: 0,
pollingCycles: 0,
webSocketClients: 0,
version: '2.0.0'
},
activeAlerts: [],
recentlyResolved: [],
lastUpdate: ''
});
const [activeAlerts, setActiveAlerts] = createStore<Record<string, Alert>>({});
const [recentlyResolved, setRecentlyResolved] = createStore<Record<string, ResolvedAlert>>({});
const [updateProgress, setUpdateProgress] = createSignal<any>(null);
let ws: WebSocket | null = null;
let reconnectTimeout: number;
let reconnectAttempt = 0;
let isReconnecting = false;
const maxReconnectDelay = POLLING_INTERVALS.RECONNECT_MAX;
const initialReconnectDelay = POLLING_INTERVALS.RECONNECT_BASE;
const connect = () => {
try {
// Close existing connection if any
if (ws && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
ws = new WebSocket(url);
ws.onopen = () => {
logger.debug('connect');
setConnected(true);
setReconnecting(false); // Clear reconnecting state
reconnectAttempt = 0; // Reset reconnect attempts on successful connection
// Alerts will come with the initial state broadcast
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const message: WSMessage = data;
if (message.type === WEBSOCKET.MESSAGE_TYPES.INITIAL_STATE || message.type === WEBSOCKET.MESSAGE_TYPES.RAW_DATA) {
// Update state properties individually to ensure reactivity
if (message.data) {
// Mark that we've received initial data
if (message.type === WEBSOCKET.MESSAGE_TYPES.INITIAL_STATE) {
setInitialDataReceived(true);
}
// Only update if we have actual data, don't overwrite with empty arrays
if (message.data.nodes !== undefined) setState('nodes', message.data.nodes);
if (message.data.vms !== undefined) setState('vms', message.data.vms);
if (message.data.containers !== undefined) setState('containers', message.data.containers);
if (message.data.storage !== undefined) setState('storage', message.data.storage);
if (message.data.pbs !== undefined) setState('pbs', message.data.pbs);
if (message.data.pbsBackups !== undefined) setState('pbsBackups', message.data.pbsBackups);
if (message.data.metrics !== undefined) setState('metrics', message.data.metrics);
if (message.data.pveBackups !== undefined) setState('pveBackups', message.data.pveBackups);
if (message.data.performance !== undefined) setState('performance', message.data.performance);
if (message.data.connectionHealth !== undefined) setState('connectionHealth', message.data.connectionHealth);
if (message.data.stats !== undefined) setState('stats', message.data.stats);
// Sync active alerts from state
if (message.data.activeAlerts !== undefined) {
console.log('[WebSocket] Received activeAlerts:', message.data.activeAlerts);
// First, remove all existing alerts
const currentAlertIds = Object.keys(activeAlerts);
currentAlertIds.forEach(id => {
setActiveAlerts(id, undefined!);
});
// Then add the new alerts
message.data.activeAlerts.forEach((alert: Alert) => {
setActiveAlerts(alert.id, alert);
});
console.log('[WebSocket] Updated activeAlerts to:', activeAlerts);
}
// Sync recently resolved alerts
if (message.data.recentlyResolved !== undefined) {
console.log('[WebSocket] Received recentlyResolved:', message.data.recentlyResolved);
// First, remove all existing resolved alerts
const currentResolvedIds = Object.keys(recentlyResolved);
currentResolvedIds.forEach(id => {
setRecentlyResolved(id, undefined!);
});
// Then add the new resolved alerts
message.data.recentlyResolved.forEach((alert: ResolvedAlert) => {
setRecentlyResolved(alert.id, alert);
});
console.log('[WebSocket] Updated recentlyResolved to:', recentlyResolved);
}
setState('lastUpdate', message.data.lastUpdate || new Date().toISOString());
}
logger.debug('message', {
type: message.type,
hasData: !!message.data,
nodeCount: message.data?.nodes?.length || 0,
vmCount: message.data?.vms?.length || 0,
containerCount: message.data?.containers?.length || 0
});
} else if (message.type === WEBSOCKET.MESSAGE_TYPES.ERROR) {
logger.debug('error', message.error);
} else if (message.type === 'ping') {
// Respond to ping with pong
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'pong', data: { timestamp: Date.now() } }));
}
} else if (message.type === 'pong') {
// Server acknowledged our ping
logger.debug('Received pong from server');
} else if (message.type === 'welcome') {
// Welcome message from server
logger.info('WebSocket connection established');
} else if (message.type === 'alert') {
// Individual alerts now handled via state sync
logger.warn('New alert received (will sync with next state update)', message.data);
} else if (message.type === 'alertResolved') {
// Individual alert resolution now handled via state sync
logger.info('Alert resolved (will sync with next state update)', { alertId: message.data.alertId });
} else if (message.type === 'update:progress') {
// Update progress event
setUpdateProgress(message.data);
logger.info('Update progress:', message.data);
}
} catch (err) {
logger.error('Failed to parse WebSocket message', err);
}
};
ws.onclose = (event) => {
logger.debug('disconnect', { code: event.code, reason: event.reason });
setConnected(false);
setInitialDataReceived(false);
// Don't reconnect if we're already trying
if (isReconnecting) {
return;
}
isReconnecting = true;
setReconnecting(true);
// Calculate exponential backoff delay
const delay = Math.min(
initialReconnectDelay * Math.pow(2, reconnectAttempt),
maxReconnectDelay
);
logger.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`);
reconnectAttempt++;
reconnectTimeout = window.setTimeout(() => {
isReconnecting = false;
setReconnecting(false);
connect();
}, delay);
};
ws.onerror = (error) => {
// Don't log connection errors if we're already connected
// Browser may show errors for initial connection attempts even after success
if (!connected()) {
logger.debug('error', error);
}
};
} catch (err) {
logger.error('Failed to connect', err);
setConnected(false);
// Don't reconnect if we're already trying
if (isReconnecting) return;
isReconnecting = true;
setReconnecting(true);
// Use exponential backoff for connection errors too
const delay = Math.min(
initialReconnectDelay * Math.pow(2, reconnectAttempt),
maxReconnectDelay
);
reconnectAttempt++;
reconnectTimeout = window.setTimeout(() => {
isReconnecting = false;
setReconnecting(false);
connect();
}, delay);
}
};
// Connect immediately
connect();
// Cleanup on unmount
onCleanup(() => {
window.clearTimeout(reconnectTimeout);
ws?.close();
});
return {
state,
activeAlerts,
recentlyResolved,
connected,
reconnecting,
initialDataReceived,
updateProgress,
reconnect: () => {
ws?.close();
window.clearTimeout(reconnectTimeout);
reconnectAttempt = 0; // Reset attempts for manual reconnect
connect();
}
};
}

View file

@ -1,93 +0,0 @@
import type { FilterStack } from '@/utils/searchQuery';
export interface HysteresisThreshold {
trigger: number;
clear: number;
}
export interface AlertThresholds {
cpu?: HysteresisThreshold;
memory?: HysteresisThreshold;
disk?: HysteresisThreshold;
diskRead?: HysteresisThreshold;
diskWrite?: HysteresisThreshold;
networkIn?: HysteresisThreshold;
networkOut?: HysteresisThreshold;
// Legacy support for backward compatibility
cpuLegacy?: number;
memoryLegacy?: number;
diskLegacy?: number;
diskReadLegacy?: number;
diskWriteLegacy?: number;
networkInLegacy?: number;
networkOutLegacy?: number;
// Allow indexing with string
[key: string]: HysteresisThreshold | number | undefined;
}
export interface CustomAlertRule {
id: string;
name: string;
description?: string;
filterConditions: FilterStack;
thresholds: AlertThresholds;
priority: number;
enabled: boolean;
notifications: {
email?: {
enabled: boolean;
recipients: string[];
};
webhook?: {
enabled: boolean;
url: string;
};
};
createdAt: string;
updatedAt: string;
}
export interface AlertConfig {
enabled: boolean;
guestDefaults: AlertThresholds;
nodeDefaults: AlertThresholds;
storageDefault: HysteresisThreshold;
customRules?: CustomAlertRule[];
overrides: Record<string, AlertThresholds>; // key: resource ID
minimumDelta?: number;
suppressionWindow?: number;
hysteresisMargin?: number;
notifications?: {
email?: {
server: string;
port: number;
username: string;
password: string;
from: string;
tls: boolean;
};
webhooks?: Array<{
id: string;
name: string;
url: string;
enabled: boolean;
}>;
};
schedule?: {
enabled?: boolean;
quietHours?: {
enabled: boolean;
start: string;
end: string;
days: number[] | Record<string, boolean>;
};
cooldown?: number;
groupingWindow?: number;
};
}
// Priority levels:
// 0: Global defaults
// 1-99: Reserved for system rules
// 100+: Custom user rules
// 1000+: Guest-specific overrides

View file

@ -1,332 +0,0 @@
// Properly typed TypeScript interfaces for Pulse API
export interface State {
nodes: Node[];
vms: VM[];
containers: Container[];
storage: Storage[];
pbs: PBSInstance[];
pbsBackups: PBSBackup[];
metrics: Metric[];
pveBackups: PVEBackups;
performance: Performance;
connectionHealth: Record<string, boolean>;
stats: Stats;
activeAlerts: Alert[];
recentlyResolved: ResolvedAlert[];
lastUpdate: string;
}
export interface Node {
id: string;
name: string;
instance: string;
status: string;
type: string;
cpu: number;
memory: Memory;
disk: Disk;
uptime: number;
loadAverage: number[];
kernelVersion: string;
pveVersion: string;
cpuInfo: CPUInfo;
lastSeen: string;
connectionHealth: string;
}
export interface VM {
id: string;
vmid: number;
name: string;
node: string;
instance: string;
status: string;
type: string;
cpu: number;
cpus: number;
memory: Memory;
disk: Disk;
networkIn: number;
networkOut: number;
diskRead: number;
diskWrite: number;
uptime: number;
template: boolean;
lastBackup: string;
tags: string[];
lock: string;
lastSeen: string;
}
export interface Container {
id: string;
vmid: number;
name: string;
node: string;
instance: string;
status: string;
type: string;
cpu: number;
cpus: number;
memory: Memory;
disk: Disk;
networkIn: number;
networkOut: number;
diskRead: number;
diskWrite: number;
uptime: number;
template: boolean;
lastBackup: string;
tags: string[];
lock: string;
lastSeen: string;
}
export interface Storage {
id: string;
name: string;
node: string;
instance: string;
type: string;
status: string;
total: number;
used: number;
free: number;
usage: number;
content: string;
shared: boolean;
enabled: boolean;
active: boolean;
}
export interface PBSInstance {
id: string;
name: string;
host: string;
status: string;
version: string;
datastores: PBSDatastore[];
backupJobs: PBSBackupJob[];
syncJobs: PBSSyncJob[];
verifyJobs: PBSVerifyJob[];
pruneJobs: PBSPruneJob[];
garbageJobs: PBSGarbageJob[];
connectionHealth: string;
lastSeen: string;
}
export interface PBSDatastore {
name: string;
total: number;
used: number;
free: number;
usage: number;
status: string;
error: string;
namespaces: PBSNamespace[];
}
export interface PBSNamespace {
path: string;
parent: string;
depth: number;
}
export interface PBSBackup {
id: string;
instance: string;
datastore: string;
namespace: string;
backupType: string;
vmid: string;
backupTime: string;
size: number;
protected: boolean;
verified: boolean;
comment: string;
files: string[];
}
export interface PBSBackupJob {
id: string;
store: string;
type: string;
vmid: string;
lastBackup: string;
nextRun: string;
status: string;
error: string;
}
export interface PBSSyncJob {
id: string;
store: string;
remote: string;
status: string;
lastSync: string;
nextRun: string;
error: string;
}
export interface PBSVerifyJob {
id: string;
store: string;
status: string;
lastVerify: string;
nextRun: string;
error: string;
}
export interface PBSPruneJob {
id: string;
store: string;
status: string;
lastPrune: string;
nextRun: string;
error: string;
}
export interface PBSGarbageJob {
id: string;
store: string;
status: string;
lastGarbage: string;
nextRun: string;
removedBytes: number;
error: string;
}
export interface Memory {
total: number;
used: number;
free: number;
usage: number;
}
export interface Disk {
total: number;
used: number;
free: number;
usage: number;
}
export interface CPUInfo {
model: string;
cores: number;
sockets: number;
mhz: string;
}
export interface Metric {
timestamp: string;
type: string;
id: string;
values: Record<string, number | string | boolean>;
}
export interface BackupTask {
id: string;
node: string;
type: string;
vmid: number;
status: string;
startTime: string;
endTime?: string;
size?: number;
error?: string;
}
export interface StorageBackup {
id: string;
storage: string;
node: string;
type: string;
vmid: number;
time: string;
ctime: number;
size: number;
format: string;
notes?: string;
protected: boolean;
volid: string;
isPBS: boolean;
verified: boolean;
verification?: string;
}
export interface PVEBackups {
backupTasks: BackupTask[];
storageBackups: StorageBackup[];
guestSnapshots: GuestSnapshot[];
}
export interface GuestSnapshot {
id: string;
name: string;
node: string;
type: string;
vmid: number;
time: string;
description: string;
parent: string;
vmstate: boolean;
}
export interface Performance {
apiCallDuration: Record<string, number>;
lastPollDuration: number;
pollingStartTime: string;
totalApiCalls: number;
failedApiCalls: number;
cacheHits?: number;
cacheMisses?: number;
}
export interface Stats {
startTime: string;
uptime: number;
pollingCycles: number;
webSocketClients: number;
version: string;
}
// Alert types
export interface Alert {
id: string;
type: string;
level: 'warning' | 'critical';
resourceId: string;
resourceName: string;
node: string;
instance: string;
message: string;
value: number;
threshold: number;
startTime: string;
lastSeen?: string;
acknowledged: boolean;
ackTime?: string;
ackUser?: string;
metadata?: Record<string, unknown>;
}
export interface ResolvedAlert extends Alert {
resolvedTime: string;
}
// WebSocket message types
export type WSMessage =
| { type: 'initialState'; data: State }
| { type: 'rawData'; data: State }
| { type: 'error'; error: string }
| { type: 'ping'; data?: unknown }
| { type: 'pong'; data?: unknown }
| { type: 'welcome'; data?: unknown }
| { type: 'alert'; data: Alert }
| { type: 'alertResolved'; data: { alertId: string } }
| { type: 'update:progress'; data: any };
// Utility types
export type Status = 'running' | 'stopped' | 'paused' | 'unknown';
export type GuestType = 'qemu' | 'lxc';

View file

@ -1,94 +0,0 @@
// Unified backup types for the backup view
export interface UnifiedBackup {
// Common fields
backupType: 'backup' | 'snapshot' | 'pbs';
vmid: number;
name: string;
type: 'VM' | 'LXC' | 'CT';
node: string;
backupTime: number; // Unix timestamp in seconds
backupName: string;
description: string;
status: string;
size: number | null;
storage: string | null;
// PBS specific
datastore: string | null;
namespace: string | null;
verified: boolean | null;
// Common flags
protected: boolean;
// UI specific
instance?: string;
isPBS?: boolean;
}
// PBS-specific backup file info
export interface PBSBackupFile {
filename: string;
size: number;
crypt?: string;
}
// Extended PBS backup with file details
export interface PBSBackupWithFiles {
id: string;
instance: string;
datastore: string;
namespace?: string;
backupType: string;
vmid: number;
backupTime: string;
size: number;
protected: boolean;
verified: boolean;
comment?: string;
files: PBSBackupFile[];
}
// PBS datastore with snapshots
export interface PBSDatastoreSnapshot {
id: string;
backupTime: string;
size: number;
owner?: string;
verified?: boolean;
protected?: boolean;
files?: PBSBackupFile[];
}
export interface PBSDatastore {
name: string;
total: number;
used: number;
free: number;
snapshots: PBSDatastoreSnapshot[];
}
export interface PBSInstanceData {
id: string;
name: string;
host: string;
backups: PBSBackupWithFiles[];
datastores: PBSDatastore[];
}
// Filter options for the backup view
export interface BackupFilters {
instance: string;
type: 'all' | 'VM' | 'LXC';
node: string;
storage: string;
protected: 'all' | 'protected' | 'unprotected';
verified: 'all' | 'verified' | 'unverified';
}
// Sorting options
export interface BackupSort {
key: keyof UnifiedBackup;
order: 'asc' | 'desc';
}

View file

@ -1,141 +0,0 @@
// Monitoring data types
export interface CPUInfo {
usage: number;
cores: number;
sockets: number;
model: string;
}
export interface MemoryInfo {
used: number;
total: number;
free: number;
usage: number;
}
export interface DiskInfo {
used: number;
total: number;
free: number;
usage: number;
device?: string;
filesystem?: string;
}
export interface NetworkInfo {
in: number;
out: number;
device?: string;
}
export interface Node {
id: string;
name: string;
type: string;
status: 'online' | 'offline' | 'unknown';
instance: string;
uptime: number;
cpu: CPUInfo;
memory: MemoryInfo;
disk?: DiskInfo;
network?: NetworkInfo;
loadAverage?: [number, number, number];
kernelVersion?: string;
}
export interface Guest {
id: string;
vmid: number;
name: string;
node: string;
type: 'qemu' | 'lxc';
status: 'running' | 'stopped' | 'paused' | 'suspended';
instance: string;
uptime?: number;
cpu: {
usage: number;
cores: number;
limit?: number;
};
memory: {
used: number;
total: number;
usage: number;
};
disk: {
read: number;
write: number;
used?: number;
total?: number;
};
network: {
in: number;
out: number;
};
template?: boolean;
haState?: string;
protection?: boolean;
tags?: string[];
}
export type VM = Guest & { type: 'qemu' };
export type Container = Guest & { type: 'lxc' };
export interface Storage {
id: string;
name: string;
type: string;
status: 'online' | 'offline' | 'unknown';
node: string;
instance: string;
used: number;
total: number;
available: number;
usage: number;
content: string[];
shared?: boolean;
enabled?: boolean;
}
export interface Backup {
id: string;
vmid: number;
name: string;
type: 'VM' | 'CT';
size: number;
backupTime: number;
node: string;
storage?: string;
instance: string;
notes?: string;
protected?: boolean;
encrypted?: boolean;
verified?: boolean;
format?: string;
}
export interface PBSBackup extends Backup {
datastore: string;
snapshotTime: number;
owner?: string;
fingerprint?: string;
}
export interface Alert {
id: string;
resourceId: string;
resourceName: string;
node: string;
instance: string;
type: string;
level: 'warning' | 'critical';
message: string;
value: number;
threshold: number;
startTime: string;
lastSeen?: string;
acknowledged: boolean;
acknowledgedBy?: string;
acknowledgedAt?: string;
}

View file

@ -1,69 +0,0 @@
// Node configuration types
export interface ClusterEndpoint {
NodeID: string;
NodeName: string;
Host: string;
IP: string;
Online: boolean;
LastSeen: string;
}
export interface PVENodeConfig {
id: string;
name: string;
host: string;
user: string;
hasPassword?: boolean;
hasToken?: boolean;
tokenName?: string;
tokenValue?: string;
password?: string;
verifySSL: boolean;
monitorVMs: boolean;
monitorContainers: boolean;
monitorStorage: boolean;
monitorBackups: boolean;
// Cluster information
isCluster?: boolean;
clusterName?: string;
clusterEndpoints?: ClusterEndpoint[];
}
export interface PBSNodeConfig {
id: string;
name: string;
host: string;
user: string;
hasPassword?: boolean;
hasToken?: boolean;
tokenName?: string;
tokenValue?: string;
password?: string;
fingerprint?: string;
verifySSL: boolean;
monitorDatastores: boolean;
monitorSyncJobs: boolean;
monitorVerifyJobs: boolean;
monitorPruneJobs: boolean;
monitorGarbageJobs: boolean;
}
export type NodeConfig = (PVENodeConfig | PBSNodeConfig) & {
type: 'pve' | 'pbs';
status?: 'connected' | 'disconnected' | 'error';
};
export interface NodesResponse {
pve_instances: PVENodeConfig[];
pbs_instances: PBSNodeConfig[];
}
export interface NodeUpdateRequest {
node: NodeConfig;
}
export interface NodeDeleteResponse {
success: boolean;
message: string;
}

View file

@ -1,67 +0,0 @@
// Settings API Types - Keep in sync with Go backend
export interface ServerSettings {
backend: {
port: number;
host: string;
};
frontend: {
port: number;
host: string;
};
}
export interface MonitoringSettings {
pollingInterval: number;
concurrentPolling: boolean;
backupPollingCycles: number;
metricsRetentionDays: number;
}
export interface LoggingSettings {
level: string;
file: string;
maxSize: number;
maxBackups: number;
maxAge: number;
compress: boolean;
}
export interface SecuritySettings {
apiToken: string;
allowedOrigins: string[];
iframeEmbedding: string;
enableAuthentication: boolean;
}
export interface Settings {
server: ServerSettings;
monitoring: MonitoringSettings;
logging: LoggingSettings;
security: SecuritySettings;
}
export interface SettingsCapabilities {
canRestart: boolean;
canValidatePorts: boolean;
requiresRestart: boolean;
}
export interface SettingsResponse {
current: Settings;
defaults: Settings;
capabilities: SettingsCapabilities;
}
export interface SettingsUpdateRequest {
server?: Partial<ServerSettings>;
monitoring?: Partial<MonitoringSettings>;
logging?: Partial<LoggingSettings>;
security?: Partial<SecuritySettings>;
}
export interface SettingsUpdateResponse {
success: boolean;
requiresRestart: boolean;
message?: string;
}

View file

@ -1,60 +0,0 @@
import type { Alert } from '@/types/api';
// Get alert highlighting styles based on active alerts for a resource
export const getAlertStyles = (
resourceId: string,
activeAlerts: Record<string, Alert>
) => {
// Find the highest severity alert for this resource
let highestSeverity: 'critical' | 'warning' | null = null;
let alertCount = 0;
Object.values(activeAlerts).forEach(alert => {
if (alert.resourceId === resourceId) {
alertCount++;
if (alert.level === 'critical' || (alert.level === 'warning' && highestSeverity !== 'critical')) {
highestSeverity = alert.level;
}
}
});
// Return appropriate styling based on alert severity
if (highestSeverity === 'critical') {
return {
rowClass: 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500',
indicatorClass: 'bg-red-500',
badgeClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
hasAlert: true,
alertCount,
severity: 'critical' as const
};
}
if (highestSeverity === 'warning') {
return {
rowClass: 'bg-orange-50 dark:bg-orange-900/20 border-l-4 border-orange-500',
indicatorClass: 'bg-orange-500',
badgeClass: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
hasAlert: true,
alertCount,
severity: 'warning' as const
};
}
return {
rowClass: '',
indicatorClass: '',
badgeClass: '',
hasAlert: false,
alertCount: 0,
severity: null
};
};
// Get alert messages for a specific resource
export const getResourceAlerts = (
resourceId: string,
activeAlerts: Record<string, Alert>
): Alert[] => {
return Object.values(activeAlerts).filter(alert => alert.resourceId === resourceId);
};

View file

@ -1,24 +0,0 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
}
} catch (err) {
console.error('Failed to copy to clipboard:', err);
return false;
}
}

View file

@ -1,75 +0,0 @@
// Type-safe formatting utilities
export function formatBytes(bytes: number, decimals = 0): string {
if (!bytes || bytes < 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`;
}
export function formatSpeed(bytesPerSecond: number, decimals = 0): string {
if (!bytesPerSecond || bytesPerSecond < 0) return '0 B/s';
return `${formatBytes(bytesPerSecond, decimals)}/s`;
}
export function formatUptime(seconds: number): string {
if (!seconds || seconds < 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
export function formatAbsoluteTime(timestamp: number): string {
if (!timestamp) return '';
const date = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getMonth()];
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day} ${month} ${hours}:${minutes}`;
}
export function formatRelativeTime(timestamp: number): string {
if (!timestamp) return '';
const now = Date.now();
const diffMs = now - timestamp;
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffSeconds < 60) {
return diffSeconds <= 1 ? 'just now' : `${diffSeconds}s ago`;
} else if (diffMinutes < 60) {
return diffMinutes === 1 ? '1 min ago' : `${diffMinutes} mins ago`;
} else if (diffHours < 24) {
return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`;
} else if (diffDays < 30) {
return diffDays === 1 ? '1 day ago' : `${diffDays} days ago`;
} else if (diffMonths < 12) {
return diffMonths === 1 ? '1 month ago' : `${diffMonths} months ago`;
} else {
return diffYears === 1 ? '1 year ago' : `${diffYears} years ago`;
}
}

View file

@ -1,22 +0,0 @@
// Simple logger - just console.log with timestamps
const isDev = import.meta.env.DEV;
export const logger = {
debug: (message: string, data?: unknown) => {
if (isDev) console.log(`[DEBUG] ${message}`, data || '');
},
info: (message: string, data?: unknown) => {
console.log(`[INFO] ${message}`, data || '');
},
warn: (message: string, data?: unknown) => {
console.warn(`[WARN] ${message}`, data || '');
},
error: (message: string, error?: unknown) => {
console.error(`[ERROR] ${message}`, error || '');
}
};
export const logError = logger.error;

View file

@ -1,311 +0,0 @@
import type { VM, Container } from '@/types/api';
export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
export type LogicalOperator = 'AND' | 'OR';
export interface MetricCondition {
field: 'cpu' | 'memory' | 'disk' | 'diskRead' | 'diskWrite' | 'networkIn' | 'networkOut';
operator: ComparisonOperator;
value: number;
}
export interface TextCondition {
field: 'name' | 'node' | 'vmid';
value: string;
}
export type Condition = MetricCondition | TextCondition;
export interface ParsedQuery {
conditions: Condition[];
logicalOperator: LogicalOperator;
rawText?: string; // Fallback for simple text search
}
// New interfaces for stackable filters
export interface ParsedFilter {
type: 'metric' | 'text' | 'raw';
rawText?: string;
field?: string;
operator?: ComparisonOperator;
value?: string | number;
}
export interface FilterStack {
filters: ParsedFilter[];
operators: LogicalOperator[]; // Operators between filters (length = filters.length - 1)
logicalOperator?: LogicalOperator; // Deprecated, kept for compatibility
}
// Parse a single filter from a search term
export function parseFilter(term: string): ParsedFilter {
term = term.trim();
// Try to parse metric condition (e.g., "cpu>80")
const metricMatch = term.match(/^(cpu|memory|disk|diskRead|diskWrite|networkIn|networkOut)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i);
if (metricMatch) {
const [, field, operator, value] = metricMatch;
return {
type: 'metric',
field: field.toLowerCase(),
operator: operator as ComparisonOperator,
value: parseFloat(value)
};
}
// Try to parse text condition (e.g., "name:prod")
const textMatch = term.match(/^(name|node|vmid)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
return {
type: 'text',
field: field.toLowerCase(),
value: value.trim()
};
}
// Otherwise treat as raw text search
return {
type: 'raw',
rawText: term
};
}
// Parse multiple filters from a search string
export function parseFilterStack(searchString: string): FilterStack {
const trimmed = searchString.trim();
if (!trimmed) {
return { filters: [], operators: [] };
}
// Split by AND/OR operators while preserving them
const regex = /\s+(AND|OR)\s+/gi;
const parts = trimmed.split(regex);
const filters: ParsedFilter[] = [];
const operators: LogicalOperator[] = [];
// Process the parts
for (let i = 0; i < parts.length; i++) {
const part = parts[i].trim();
if (!part) continue;
if (i % 2 === 0) {
// Even indices are filter expressions
const filter = parseFilter(part);
filters.push(filter);
} else {
// Odd indices are operators
operators.push(part.toUpperCase() as LogicalOperator);
}
}
// For backward compatibility, include logicalOperator as the first operator
const logicalOperator = operators.length > 0 ? operators[0] : 'AND';
return { filters, operators, logicalOperator };
}
function parseCondition(conditionStr: string): Condition | null {
// Try to parse metric condition (e.g., "cpu>80")
const metricMatch = conditionStr.match(/^(cpu|memory|disk|diskRead|diskWrite|networkIn|networkOut)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i);
if (metricMatch) {
const [, field, operator, value] = metricMatch;
return {
field: field.toLowerCase() as MetricCondition['field'],
operator: operator as ComparisonOperator,
value: parseFloat(value)
} as MetricCondition;
}
// Try to parse text condition (e.g., "name:prod")
const textMatch = conditionStr.match(/^(name|node|vmid)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
return {
field: field.toLowerCase() as 'name' | 'node' | 'vmid',
value: value.trim()
} as TextCondition;
}
return null;
}
export function parseSearchQuery(query: string): ParsedQuery {
query = query.trim();
// Check for logical operators
const hasAnd = /\bAND\b/i.test(query);
const hasOr = /\bOR\b/i.test(query);
// If no operators or invalid query, treat as simple text search
if (!hasAnd && !hasOr && !query.match(/[><=:]/)) {
return {
conditions: [],
logicalOperator: 'AND',
rawText: query
};
}
// Split by logical operator
const logicalOperator: LogicalOperator = hasAnd ? 'AND' : 'OR';
const parts = query.split(hasAnd ? /\bAND\b/i : /\bOR\b/i);
const conditions: Condition[] = [];
for (const part of parts) {
const condition = parseCondition(part.trim());
if (condition) {
conditions.push(condition);
}
}
// If no valid conditions parsed, fall back to text search
if (conditions.length === 0) {
return {
conditions: [],
logicalOperator: 'AND',
rawText: query
};
}
return {
conditions,
logicalOperator
};
}
function evaluateMetricCondition(guest: VM | Container, condition: MetricCondition): boolean {
let value: number;
switch (condition.field) {
case 'cpu':
// CPU is stored as decimal (0-1), convert to percentage
value = (guest.cpu || 0) * 100;
break;
case 'memory':
value = guest.memory ? guest.memory.usage : 0;
break;
case 'disk':
value = guest.disk ? guest.disk.usage : 0;
break;
default:
return false;
}
switch (condition.operator) {
case '>':
return value > condition.value;
case '<':
return value < condition.value;
case '>=':
return value >= condition.value;
case '<=':
return value <= condition.value;
case '=':
case '==':
return Math.abs(value - condition.value) < 0.01;
default:
return false;
}
}
function evaluateTextCondition(guest: VM | Container, condition: TextCondition): boolean {
const searchValue = condition.value.toLowerCase();
switch (condition.field) {
case 'name':
return guest.name.toLowerCase().includes(searchValue);
case 'node':
return guest.node.toLowerCase().includes(searchValue);
case 'vmid':
return guest.vmid.toString().includes(searchValue);
default:
return false;
}
}
export function evaluateSearchQuery(guest: VM | Container, query: ParsedQuery): boolean {
// If it's a simple text search
if (query.rawText) {
const searchTerms = query.rawText.toLowerCase().split(',').map(term => term.trim()).filter(term => term.length > 0);
return searchTerms.some(term =>
guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term)
);
}
// If no conditions, match all
if (query.conditions.length === 0) {
return true;
}
// Evaluate conditions
const results = query.conditions.map(condition => {
if ('operator' in condition) {
return evaluateMetricCondition(guest, condition);
} else {
return evaluateTextCondition(guest, condition);
}
});
// Apply logical operator
if (query.logicalOperator === 'AND') {
return results.every(result => result);
} else {
return results.some(result => result);
}
}
// Evaluate a filter stack against a guest
export function evaluateFilterStack(guest: VM | Container, stack: FilterStack): boolean {
if (stack.filters.length === 0) {
return true;
}
const results = stack.filters.map(filter => {
if (filter.type === 'metric' && filter.field && filter.operator && filter.value !== undefined) {
const condition: MetricCondition = {
field: filter.field as MetricCondition['field'],
operator: filter.operator,
value: filter.value as number
};
return evaluateMetricCondition(guest, condition);
} else if (filter.type === 'text' && filter.field && filter.value) {
const condition: TextCondition = {
field: filter.field as TextCondition['field'],
value: filter.value as string
};
return evaluateTextCondition(guest, condition);
} else if (filter.type === 'raw' && filter.rawText) {
const term = filter.rawText.toLowerCase();
return guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term);
}
return true;
});
// If only one filter, return its result
if (results.length === 1) {
return results[0];
}
// Apply operators between filters
let result = results[0];
for (let i = 0; i < stack.operators.length && i < results.length - 1; i++) {
const operator = stack.operators[i];
const nextResult = results[i + 1];
if (operator === 'AND') {
result = result && nextResult;
} else {
result = result || nextResult;
}
}
return result;
}

View file

@ -1,18 +0,0 @@
import type { ToastType } from '@/components/Toast/Toast';
// Global declaration is in Toast.tsx
export const showToast = (type: ToastType, title: string, message?: string, duration?: number) => {
// Use the global toast function exposed by ToastContainer
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(type, title, message, duration);
} else {
// Fallback to console if toast system not ready
console.log(`[${type.toUpperCase()}] ${title}${message ? ': ' + message : ''}`);
}
};
// Convenience functions - only export what's used
export const showSuccess = (title: string, message?: string) => showToast('success', title, message);
export const showError = (title: string, message?: string) => showToast('error', title, message);
export const showInfo = (title: string, message?: string) => showToast('info', title, message);

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,20 +0,0 @@
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
gray: {
750: '#2d3748',
}
},
animation: {
'spin-slow': 'spin 2s linear infinite',
}
},
},
plugins: [],
}

View file

@ -1,30 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -1,35 +0,0 @@
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
import path from 'path';
export default defineConfig({
plugins: [solid()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 7655,
host: '0.0.0.0', // Listen on all interfaces for remote access
proxy: {
'/ws': {
target: 'ws://127.0.0.1:3000',
ws: true,
changeOrigin: true,
},
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
build: {
target: 'esnext',
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});