mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-09 10:57:04 +00:00
chore: remove pulse-test-staging directory
- Clean up temporary test staging files - Remove accidentally committed staging directory
This commit is contained in:
parent
8a40db959c
commit
994d5eeeaa
68 changed files with 0 additions and 18755 deletions
|
|
@ -1 +0,0 @@
|
|||
4.0.0-rc.1-test
|
||||
32
pulse-test-staging/frontend-modern/.gitignore
vendored
32
pulse-test-staging/frontend-modern/.gitignore
vendored
|
|
@ -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*
|
||||
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
2949
pulse-test-staging/frontend-modern/package-lock.json
generated
2949
pulse-test-staging/frontend-modern/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
|
|
@ -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" }]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -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'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue