Compare commits

...

1 Commits

Author SHA1 Message Date
Eli Bosley
9fede1b3f5 feat: initial CPU stats monitor 2025-09-12 16:45:17 -04:00
17 changed files with 465 additions and 407 deletions

View File

@@ -1,5 +1,5 @@
{
"version": "4.19.1",
"version": "4.22.0",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -29,16 +29,16 @@ export class CpuService {
return {
id: 'info/cpu-load',
percentTotal: loadData.currentLoad,
percentTotal: Math.floor(loadData.currentLoad),
cpus: loadData.cpus.map((cpu) => ({
percentTotal: cpu.load,
percentUser: cpu.loadUser,
percentSystem: cpu.loadSystem,
percentNice: cpu.loadNice,
percentIdle: cpu.loadIdle,
percentIrq: cpu.loadIrq,
percentGuest: cpu.loadGuest || 0,
percentSteal: cpu.loadSteal || 0,
percentTotal: Math.floor(cpu.load),
percentUser: Math.floor(cpu.loadUser),
percentSystem: Math.floor(cpu.loadSystem),
percentNice: Math.floor(cpu.loadNice),
percentIdle: Math.floor(cpu.loadIdle),
percentIrq: Math.floor(cpu.loadIrq),
percentGuest: Math.floor(cpu.loadGuest || 0),
percentSteal: Math.floor(cpu.loadSteal || 0),
})),
};
}

38
pnpm-lock.yaml generated
View File

@@ -1103,6 +1103,9 @@ importers:
ansi_up:
specifier: 6.0.6
version: 6.0.6
chart.js:
specifier: ^4.5.0
version: 4.5.0
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -1157,6 +1160,9 @@ importers:
tailwind-merge:
specifier: 2.6.0
version: 2.6.0
vue-chartjs:
specifier: ^5.3.2
version: 5.3.2(chart.js@4.5.0)(vue@3.5.20(typescript@5.9.2))
vue-i18n:
specifier: 11.1.11
version: 11.1.11(vue@3.5.20(typescript@5.9.2))
@@ -3309,6 +3315,9 @@ packages:
'@keyv/serialize@1.1.0':
resolution: {integrity: sha512-RlDgexML7Z63Q8BSaqhXdCYNBy/JQnqYIwxofUrNLGCblOMHp+xux2Q8nLMLlPpgHQPoU0Do8Z6btCpRBEqZ8g==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
@@ -6301,6 +6310,10 @@ packages:
charm@0.1.2:
resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==}
chart.js@4.5.0:
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
engines: {pnpm: '>=8'}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@@ -12449,6 +12462,12 @@ packages:
vue-bundle-renderer@2.1.2:
resolution: {integrity: sha512-M4WRBO/O/7G9phGaGH9AOwOnYtY9ZpPoDVpBpRzR2jO5rFL9mgIlQIgums2ljCTC2HL1jDXFQc//CzWcAQHgAw==}
vue-chartjs@5.3.2:
resolution: {integrity: sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==}
peerDependencies:
chart.js: ^4.1.1
vue: ^3.0.0-0 || ^2.7.0
vue-component-meta@2.2.8:
resolution: {integrity: sha512-fgcP61P45AA1DacW+/532mivO5j48EEpmI7To8PK3gCVgL023QuEAPzfTA9hB6lW2hgdqiMf4gLON972pYC2+g==}
peerDependencies:
@@ -15172,6 +15191,8 @@ snapshots:
'@keyv/serialize@1.1.0': {}
'@kurkle/color@0.3.4': {}
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.1(supports-color@5.5.0)
@@ -15655,14 +15676,14 @@ snapshots:
jiti: 2.5.1
klona: 2.0.6
knitwork: 1.2.0
mlly: 1.7.4
mlly: 1.8.0
ohash: 2.0.11
pathe: 2.0.3
pkg-types: 2.3.0
scule: 1.3.0
semver: 7.7.2
std-env: 3.9.0
tinyglobby: 0.2.14
tinyglobby: 0.2.15
ufo: 1.6.1
unctx: 2.4.1
unimport: 5.2.0
@@ -18637,6 +18658,10 @@ snapshots:
charm@0.1.2: {}
chart.js@4.5.0:
dependencies:
'@kurkle/color': 0.3.4
check-error@2.1.1: {}
chokidar@3.6.0:
@@ -21693,7 +21718,7 @@ snapshots:
local-pkg@1.1.1:
dependencies:
mlly: 1.7.4
mlly: 1.8.0
pkg-types: 2.3.0
quansync: 0.2.10
@@ -25284,7 +25309,7 @@ snapshots:
picomatch: 4.0.3
strip-ansi: 7.1.0
tiny-invariant: 1.3.3
tinyglobby: 0.2.14
tinyglobby: 0.2.15
vite: 7.1.5(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
vscode-uri: 3.1.0
optionalDependencies:
@@ -25515,6 +25540,11 @@ snapshots:
dependencies:
ufo: 1.6.1
vue-chartjs@5.3.2(chart.js@4.5.0)(vue@3.5.20(typescript@5.9.2)):
dependencies:
chart.js: 4.5.0
vue: 3.5.20(typescript@5.9.2)
vue-component-meta@2.2.8(typescript@5.9.2):
dependencies:
'@volar/typescript': 2.4.22

View File

@@ -1,5 +1,5 @@
import { createAjv } from '@jsonforms/core';
import type Ajv from 'ajv';
import type { Ajv } from 'ajv';
import addErrors from 'ajv-errors';
export interface JsonFormsConfig {

1
web/components.d.ts vendored
View File

@@ -34,6 +34,7 @@ declare module 'vue' {
ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default']
'ConnectSettings.standalone': typeof import('./src/components/ConnectSettings/ConnectSettings.standalone.vue')['default']
Console: typeof import('./src/components/Docker/Console.vue')['default']
'CpuStats.standalone': typeof import('./src/components/CpuStats/CpuStats.standalone.vue')['default']
Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default']
DetailContentHeader: typeof import('./src/components/LayoutViews/Detail/DetailContentHeader.vue')['default']
DetailLeftNavigation: typeof import('./src/components/LayoutViews/Detail/DetailLeftNavigation.vue')['default']

View File

@@ -105,6 +105,7 @@
"@vueuse/integrations": "13.8.0",
"ajv": "8.17.1",
"ansi_up": "6.0.6",
"chart.js": "^4.5.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"crypto-js": "4.2.0",
@@ -123,6 +124,7 @@
"postcss-import": "16.1.1",
"semver": "7.7.2",
"tailwind-merge": "2.6.0",
"vue-chartjs": "^5.3.2",
"vue-i18n": "11.1.11",
"vue-router": "4.5.1",
"vue-web-component-wrapper": "1.7.7",

View File

@@ -103,6 +103,13 @@
</div>
</div>
<div class="card">
<h2>CPU Statistics</h2>
<div class="component-mount" data-component="unraid-cpu-stats">
<unraid-cpu-stats></unraid-cpu-stats>
</div>
</div>
<div class="card">
<h2>Theme Settings</h2>
<div class="component-mount" data-component="unraid-theme-switcher">

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import { useQuery, useSubscription } from '@vue/apollo-composable';
import { GET_CPU_INFO, CPU_METRICS_SUBSCRIPTION } from './cpu-stats.query';
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
type ChartOptions,
type ChartData
} from 'chart.js';
import { Button, Select } from '@unraid/ui';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
const showDetails = ref(true);
const cpuHistory = ref<number[]>([]);
// History duration options
type HistoryDuration = '10s' | '30s' | '1m' | '2m' | '5m';
const historyDuration = ref<HistoryDuration>('30s');
const historyConfigs: Record<HistoryDuration, { points: number; interval: number }> = {
'10s': { points: 60, interval: 167 }, // ~6 fps
'30s': { points: 60, interval: 500 }, // 2 fps
'1m': { points: 60, interval: 1000 }, // 1 fps
'2m': { points: 60, interval: 2000 }, // 0.5 fps
'5m': { points: 60, interval: 5000 }, // 0.2 fps
};
const historyOptions = [
{ value: '10s', label: '10 seconds' },
{ value: '30s', label: '30 seconds' },
{ value: '1m', label: '1 minute' },
{ value: '2m', label: '2 minutes' },
{ value: '5m', label: '5 minutes' },
];
const currentHistoryConfig = computed(() =>
historyConfigs[historyDuration.value] || historyConfigs['30s']
);
const { result: cpuInfoResult } = useQuery(GET_CPU_INFO);
const { result: cpuMetricsResult } = useSubscription(CPU_METRICS_SUBSCRIPTION);
const cpuInfo = computed(() => cpuInfoResult.value?.info?.cpu);
const cpuMetrics = computed(() => cpuMetricsResult.value?.systemMetricsCpu);
const cpuBrand = computed(() => {
if (!cpuInfo.value) return 'Loading...';
const brand = cpuInfo.value.brand || cpuInfo.value.model || 'Unknown CPU';
return brand;
});
const overallLoad = computed(() => {
if (!cpuMetrics.value) return 0;
return Math.floor(cpuMetrics.value.percentTotal);
});
const cpuCores = computed(() => {
if (!cpuMetrics.value?.cpus) return [];
return cpuMetrics.value.cpus.map((cpu, index) => ({
index: index * 2, // Assuming HT, so multiply by 2
htIndex: index * 2 + 1,
percent: Math.floor(cpu.percentTotal),
percentUser: Math.floor(cpu.percentUser),
percentSystem: Math.floor(cpu.percentSystem),
}));
});
// Keep chart data simple - just the last 60 data points
const chartDataRef = shallowRef<ChartData<'line'>>({
labels: [],
datasets: [{
label: 'CPU Usage %',
data: [],
borderColor: '#ff8c2e',
backgroundColor: 'rgba(255, 140, 46, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 3,
}]
});
// Update chart data without triggering computed re-evaluation
const updateChartData = () => {
// Create simple numeric labels (no timestamps, just indices)
const labels = Array.from({ length: cpuHistory.value.length }, (_, i) => '');
// Update the ref value directly
chartDataRef.value = {
labels,
datasets: [{
label: 'CPU Usage %',
data: [...cpuHistory.value], // Clone to prevent reactivity issues
borderColor: '#ff8c2e',
backgroundColor: 'rgba(255, 140, 46, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 0, // Disable hover points for performance
}]
};
};
const chartData = computed(() => chartDataRef.value);
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 0 // Disable all animations for performance
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false // Disable tooltips for performance
}
},
scales: {
x: {
grid: {
display: false
},
display: false // Hide x-axis completely for performance
},
y: {
min: 0,
max: 100,
grid: {
color: 'rgb(229, 231, 235)'
},
ticks: {
stepSize: 25,
color: 'rgb(107, 114, 128)',
font: {
size: 11
},
callback: (value) => `${value}%`
}
}
}
}));
let updateInterval: NodeJS.Timeout | null = null;
let tickInterval: NodeJS.Timeout | null = null;
let lastKnownValue = 0;
// Update with actual data from subscription
const updateFromMetrics = () => {
if (cpuMetrics.value) {
lastKnownValue = Math.floor(cpuMetrics.value.percentTotal);
}
};
// Tick the chart forward with the last known value
const tick = () => {
// Always push a value (either new or repeated last known)
cpuHistory.value.push(lastKnownValue);
// Keep only the configured number of data points
if (cpuHistory.value.length > currentHistoryConfig.value.points) {
cpuHistory.value.shift();
}
// Update chart data
updateChartData();
};
// Watch for actual metric changes
watch(cpuMetrics, updateFromMetrics, { immediate: true });
// Restart ticker when duration changes
const restartTicker = () => {
if (tickInterval) {
clearInterval(tickInterval);
}
// Clear history when changing duration for clean transition
cpuHistory.value = [];
// Start new ticker with appropriate interval
tickInterval = setInterval(tick, currentHistoryConfig.value.interval);
};
watch(historyDuration, restartTicker);
onMounted(() => {
// Start ticker with initial interval
tickInterval = setInterval(tick, currentHistoryConfig.value.interval);
tick(); // Initial tick
});
onUnmounted(() => {
if (updateInterval) {
clearInterval(updateInterval);
}
if (tickInterval) {
clearInterval(tickInterval);
}
});
const toggleDetails = () => {
showDetails.value = !showDetails.value;
};
</script>
<template>
<div class="bg-background rounded-md border-2 border-muted shadow-md p-4">
<div class="space-y-4">
<!-- Header Section -->
<div>
<h3 class="text-lg font-semibold text-foreground">Processor</h3>
<div class="text-sm text-muted-foreground mt-1">{{ cpuBrand }}</div>
<div class="flex items-center justify-between mt-2">
<div class="text-sm">
<span class="text-foreground">Overall Load: </span>
<span class="font-semibold" style="color: var(--color-orange, #ff8c2f)">{{ overallLoad }}%</span>
</div>
<Button
@click="toggleDetails"
variant="outline"
size="sm"
>
{{ showDetails ? 'Hide details' : 'Show details' }}
</Button>
</div>
</div>
<!-- CPU Cores Details -->
<Transition name="slide-fade">
<div v-if="showDetails" class="bg-muted/30 rounded-md p-4">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div
v-for="core in cpuCores"
:key="core.index"
class="bg-background rounded border border-border p-2"
>
<div class="text-xs text-muted-foreground">
CPU {{ core.index }} - HT {{ core.htIndex }}
</div>
<div class="text-sm font-semibold mt-1" style="color: var(--color-orange, #ff8c2f)">{{ core.percent }}%</div>
</div>
</div>
</div>
</Transition>
<!-- Chart Section -->
<div class="border-t border-border pt-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-foreground">CPU Usage</h4>
<Select
v-model="historyDuration"
:items="historyOptions"
placeholder="Duration"
class="w-32"
/>
</div>
<div v-if="chartData.labels && chartData.labels.length > 0" class="h-40">
<Line :data="chartData" :options="chartOptions" />
</div>
<div v-else class="h-40 flex items-center justify-center text-muted-foreground text-sm">
Collecting data...
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Smooth transition for details panel */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,30 @@
import { graphql } from '~/composables/gql/gql';
export const GET_CPU_INFO = graphql(/* GraphQL */ `
query GetCpuInfo {
info {
cpu {
id
manufacturer
brand
vendor
family
model
}
}
}
`);
export const CPU_METRICS_SUBSCRIPTION = graphql(/* GraphQL */ `
subscription CpuMetrics {
systemMetricsCpu {
id
percentTotal
cpus {
percentTotal
percentUser
percentSystem
}
}
}
`);

View File

@@ -3,7 +3,21 @@
import { provideApolloClient } from '@vue/apollo-composable';
import { ensureTeleportContainer } from '@unraid/ui';
// Copy the ensureTeleportContainer function to avoid importing from @unraid/ui
// which causes ESM/CommonJS issues with ajv-errors
function ensureTeleportContainer(): HTMLElement {
const containerId = 'unraid-teleport-container';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.style.position = 'relative';
container.classList.add('unapi');
container.style.zIndex = '999999';
document.body.appendChild(container);
}
return container;
}
import {
autoMountAllComponents,
autoMountComponent,

View File

@@ -153,4 +153,9 @@ export const componentMappings: ComponentMapping[] = [
selector: 'unraid-test-theme-switcher',
appId: 'test-theme-switcher',
},
{
loader: () => import('../CpuStats/CpuStats.standalone.vue'),
selector: 'unraid-cpu-stats',
appId: 'cpu-stats',
},
];

View File

@@ -28,6 +28,8 @@ type Documents = {
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": typeof types.GetPermissionsForRolesDocument,
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": typeof types.UnifiedDocument,
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
"\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n": typeof types.GetCpuInfoDocument,
"\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n": typeof types.CpuMetricsDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument,
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": typeof types.LogFileSubscriptionDocument,
@@ -73,6 +75,8 @@ const documents: Documents = {
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": types.GetPermissionsForRolesDocument,
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": types.UnifiedDocument,
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": types.UpdateConnectSettingsDocument,
"\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n": types.GetCpuInfoDocument,
"\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n": types.CpuMetricsDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument,
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": types.LogFileSubscriptionDocument,
@@ -174,6 +178,14 @@ export function graphql(source: "\n query Unified {\n settings {\n unif
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n"): (typeof documents)["\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n"): (typeof documents)["\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n"): (typeof documents)["\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -241,6 +241,8 @@ export type ArrayDisk = Node & {
id: Scalars['PrefixedID']['output'];
/** Array slot number. Parity1 is always 0 and Parity2 is always 29. Array slots will be 1 - 28. Cache slots are 30 - 53. Flash is 54. */
idx: Scalars['Int']['output'];
/** Whether the disk is currently spinning */
isSpinning?: Maybe<Scalars['Boolean']['output']>;
name?: Maybe<Scalars['String']['output']>;
/** Number of unrecoverable errors reported by the device I/O drivers. Missing data due to unrecoverable array read errors is filled in on-the-fly using parity reconstruct (and we attempt to write this data back to the sector(s) which failed). Any unrecoverable write error results in disabling the disk. */
numErrors?: Maybe<Scalars['BigInt']['output']>;
@@ -607,6 +609,8 @@ export type Disk = Node & {
id: Scalars['PrefixedID']['output'];
/** The interface type of the disk */
interfaceType: DiskInterfaceType;
/** Whether the disk is spinning or not */
isSpinning: Scalars['Boolean']['output'];
/** The model name of the disk */
name: Scalars['String']['output'];
/** The partitions on the disk */
@@ -674,6 +678,7 @@ export enum DiskSmartStatus {
export type Docker = Node & {
__typename?: 'Docker';
containerUpdateStatuses: Array<ExplicitStatusItem>;
containers: Array<DockerContainer>;
id: Scalars['PrefixedID']['output'];
networks: Array<DockerNetwork>;
@@ -699,6 +704,8 @@ export type DockerContainer = Node & {
id: Scalars['PrefixedID']['output'];
image: Scalars['String']['output'];
imageId: Scalars['String']['output'];
isRebuildReady?: Maybe<Scalars['Boolean']['output']>;
isUpdateAvailable?: Maybe<Scalars['Boolean']['output']>;
labels?: Maybe<Scalars['JSON']['output']>;
mounts?: Maybe<Array<Scalars['JSON']['output']>>;
names: Array<Scalars['String']['output']>;
@@ -770,6 +777,12 @@ export type EnableDynamicRemoteAccessInput = {
url: AccessUrlInput;
};
export type ExplicitStatusItem = {
__typename?: 'ExplicitStatusItem';
name: Scalars['String']['output'];
updateStatus: UpdateStatus;
};
export type Flash = Node & {
__typename?: 'Flash';
guid: Scalars['String']['output'];
@@ -1225,6 +1238,7 @@ export type Mutation = {
rclone: RCloneMutations;
/** Reads each notification to recompute & update the overview. */
recalculateOverview: NotificationOverview;
refreshDockerDigests: Scalars['Boolean']['output'];
/** Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. */
removePlugin: Scalars['Boolean']['output'];
setDockerFolderChildren: ResolvedOrganizerV1;
@@ -2260,6 +2274,14 @@ export type UpdateSettingsResponse = {
warnings?: Maybe<Array<Scalars['String']['output']>>;
};
/** Update status of a container. */
export enum UpdateStatus {
REBUILD_READY = 'REBUILD_READY',
UNKNOWN = 'UNKNOWN',
UPDATE_AVAILABLE = 'UPDATE_AVAILABLE',
UP_TO_DATE = 'UP_TO_DATE'
}
export type Uptime = {
__typename?: 'Uptime';
timestamp?: Maybe<Scalars['String']['output']>;
@@ -2640,6 +2662,16 @@ export type UpdateConnectSettingsMutationVariables = Exact<{
export type UpdateConnectSettingsMutation = { __typename?: 'Mutation', updateSettings: { __typename?: 'UpdateSettingsResponse', restartRequired: boolean, values: any } };
export type GetCpuInfoQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCpuInfoQuery = { __typename?: 'Query', info: { __typename?: 'Info', cpu: { __typename?: 'InfoCpu', id: string, manufacturer?: string | null, brand?: string | null, vendor?: string | null, family?: string | null, model?: string | null } } };
export type CpuMetricsSubscriptionVariables = Exact<{ [key: string]: never; }>;
export type CpuMetricsSubscription = { __typename?: 'Subscription', systemMetricsCpu: { __typename?: 'CpuUtilization', id: string, percentTotal: number, cpus: Array<{ __typename?: 'CpuLoad', percentTotal: number, percentUser: number, percentSystem: number }> } };
export type LogFilesQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2838,6 +2870,8 @@ export const PreviewEffectivePermissionsDocument = {"kind":"Document","definitio
export const GetPermissionsForRolesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPermissionsForRoles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roles"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPermissionsForRoles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"roles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roles"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<GetPermissionsForRolesQuery, GetPermissionsForRolesQueryVariables>;
export const UnifiedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]}}]} as unknown as DocumentNode<UnifiedQuery, UnifiedQueryVariables>;
export const UpdateConnectSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateConnectSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"restartRequired"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<UpdateConnectSettingsMutation, UpdateConnectSettingsMutationVariables>;
export const GetCpuInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCpuInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cpu"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"brand"}},{"kind":"Field","name":{"kind":"Name","value":"vendor"}},{"kind":"Field","name":{"kind":"Name","value":"family"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}}]}}]} as unknown as DocumentNode<GetCpuInfoQuery, GetCpuInfoQueryVariables>;
export const CpuMetricsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"CpuMetrics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemMetricsCpu"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"percentTotal"}},{"kind":"Field","name":{"kind":"Name","value":"cpus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"percentTotal"}},{"kind":"Field","name":{"kind":"Name","value":"percentUser"}},{"kind":"Field","name":{"kind":"Name","value":"percentSystem"}}]}}]}}]}}]} as unknown as DocumentNode<CpuMetricsSubscription, CpuMetricsSubscriptionVariables>;
export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode<LogFilesQuery, LogFilesQueryVariables>;
export const LogFileContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFileContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lines"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"lines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lines"}}},{"kind":"Argument","name":{"kind":"Name","value":"startLine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}},{"kind":"Field","name":{"kind":"Name","value":"startLine"}}]}}]}}]} as unknown as DocumentNode<LogFileContentQuery, LogFileContentQueryVariables>;
export const LogFileSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LogFileSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}}]}}]}}]} as unknown as DocumentNode<LogFileSubscriptionSubscription, LogFileSubscriptionSubscriptionVariables>;

View File

@@ -1,2 +1,2 @@
export * from './fragment-masking';
export * from './gql';
export * from "./fragment-masking";
export * from "./gql";

View File

@@ -1,327 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Standalone Vue Apps Test Page</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.test-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
}
h2 {
color: #666;
margin-top: 0;
margin-bottom: 15px;
font-size: 18px;
}
.status {
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
font-family: monospace;
font-size: 14px;
}
.status.loading {
background: #fff3cd;
color: #856404;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.mount-target {
padding: 20px;
background: #fafafa;
border: 2px dashed #ddd;
border-radius: 4px;
min-height: 100px;
position: relative;
}
.mount-target::before {
content: attr(data-label);
position: absolute;
top: -10px;
left: 10px;
background: white;
padding: 0 5px;
color: #999;
font-size: 12px;
}
.debug-info {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
}
.multiple-mounts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.test-button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
}
.test-button:hover {
background: #0056b3;
}
.test-button:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
</head>
<body>
<!-- Teleport target for dropdowns and modals -->
<div id="teleports"></div>
<!-- Mount point for Modals component -->
<unraid-modals></unraid-modals>
<div class="container">
<h1>🧪 Standalone Vue Apps Test Page</h1>
<div id="status" class="status loading">Loading...</div>
<!-- Test Section 1: Single Mount -->
<div class="test-section">
<h2>Test 1: Single Component Mount</h2>
<p>Testing single instance of HeaderOsVersion component</p>
<div class="mount-target" data-label="HeaderOsVersion Mount">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
<!-- Test Section 2: Multiple Mounts -->
<div class="test-section">
<h2>Test 2: Multiple Component Mounts (Shared Pinia Store)</h2>
<p>Testing that multiple instances share the same Pinia store</p>
<div class="multiple-mounts">
<div class="mount-target" data-label="Instance 1">
<unraid-header-os-version></unraid-header-os-version>
</div>
<div class="mount-target" data-label="Instance 2">
<unraid-header-os-version></unraid-header-os-version>
</div>
<div class="mount-target" data-label="Instance 3">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
</div>
<!-- Test Section 3: Dynamic Mount -->
<div class="test-section">
<h2>Test 3: Dynamic Component Creation</h2>
<p>Test dynamically adding components after page load</p>
<button class="test-button" id="addComponent">Add New Component</button>
<button class="test-button" id="removeComponent">Remove Last Component</button>
<button class="test-button" id="remountAll">Remount All</button>
<div id="dynamicContainer" style="margin-top: 20px;">
<!-- Dynamic components will be added here -->
</div>
</div>
<!-- Test Section 4: Modal Testing -->
<div class="test-section">
<h2>Test 4: Modal Components</h2>
<p>Test modal functionality</p>
<button class="test-button" onclick="testTrialModal()">Open Trial Modal</button>
<button class="test-button" onclick="testUpdateModal()">Open Update Modal</button>
<button class="test-button" onclick="testApiKeyModal()">Open API Key Modal</button>
<div style="margin-top: 10px;">
<small>Note: Modals require proper store state to display</small>
</div>
</div>
<!-- Debug Info -->
<div class="test-section">
<h2>Debug Information</h2>
<div class="debug-info" id="debugInfo">
Waiting for initialization...
</div>
</div>
</div>
<!-- Mock configurations for local testing -->
<script>
// Set GraphQL endpoint directly to API server
// Change this to match your API server port
window.GRAPHQL_ENDPOINT = 'http://localhost:3001/graphql';
// Mock webGui path for images
window.__WEBGUI_PATH__ = '';
// Add some debug logging
window.addEventListener('DOMContentLoaded', () => {
const status = document.getElementById('status');
const debugInfo = document.getElementById('debugInfo');
// Log when scripts are loaded
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'SCRIPT') {
console.log('Script loaded:', node.src || 'inline');
}
});
}
});
});
observer.observe(document.head, { childList: true });
observer.observe(document.body, { childList: true });
// Check for Vue app mounting
let checkInterval = setInterval(() => {
const mountedElements = document.querySelectorAll('unraid-header-os-version');
let mountedCount = 0;
mountedElements.forEach(el => {
if (el.innerHTML.trim() !== '') {
mountedCount++;
}
});
if (mountedCount > 0) {
status.className = 'status success';
status.textContent = `✅ Successfully mounted ${mountedCount} component(s)`;
// Update debug info
debugInfo.textContent = `
Components Found: ${mountedElements.length}
Components Mounted: ${mountedCount}
Vue Apps: ${window.mountedApps ? Object.keys(window.mountedApps).length : 0}
Pinia Store: ${window.globalPinia ? 'Initialized' : 'Not found'}
GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
`.trim();
clearInterval(checkInterval);
}
}, 500);
// Timeout after 10 seconds
setTimeout(() => {
if (checkInterval) {
clearInterval(checkInterval);
if (status.className === 'status loading') {
status.className = 'status error';
status.textContent = '❌ Failed to mount components (timeout)';
}
}
}, 10000);
});
// Dynamic component controls
document.addEventListener('DOMContentLoaded', () => {
let dynamicCount = 0;
const dynamicContainer = document.getElementById('dynamicContainer');
document.getElementById('addComponent').addEventListener('click', () => {
dynamicCount++;
const wrapper = document.createElement('div');
wrapper.className = 'mount-target';
wrapper.setAttribute('data-label', `Dynamic Instance ${dynamicCount}`);
wrapper.style.marginBottom = '10px';
wrapper.innerHTML = '<unraid-header-os-version></unraid-header-os-version>';
dynamicContainer.appendChild(wrapper);
// Trigger mount if app is already loaded
if (window.mountVueApp) {
window.mountVueApp({
component: window.HeaderOsVersion,
selector: 'unraid-header-os-version',
appId: `dynamic-${dynamicCount}`,
});
}
});
document.getElementById('removeComponent').addEventListener('click', () => {
const lastChild = dynamicContainer.lastElementChild;
if (lastChild) {
dynamicContainer.removeChild(lastChild);
dynamicCount = Math.max(0, dynamicCount - 1);
}
});
document.getElementById('remountAll').addEventListener('click', () => {
// This would require the mount function to be exposed globally
console.log('Remounting all components...');
location.reload();
});
});
// Modal test functions
window.testTrialModal = function() {
console.log('Testing trial modal...');
if (window.globalPinia) {
const trialStore = window.globalPinia._s.get('trial');
if (trialStore) {
trialStore.trialModalVisible = true;
console.log('Trial modal triggered');
} else {
console.error('Trial store not found');
}
}
};
window.testUpdateModal = function() {
console.log('Testing update modal...');
if (window.globalPinia) {
const updateStore = window.globalPinia._s.get('updateOs');
if (updateStore) {
updateStore.updateOsModalVisible = true;
console.log('Update modal triggered');
} else {
console.error('Update store not found');
}
}
};
window.testApiKeyModal = function() {
console.log('Testing API key modal...');
if (window.globalPinia) {
const apiKeyStore = window.globalPinia._s.get('apiKey');
if (apiKeyStore) {
apiKeyStore.showCreateModal = true;
console.log('API key modal triggered');
} else {
console.error('API key store not found');
}
}
};
</script>
<!-- Load the standalone app -->
<script type="module" src=".nuxt/standalone-apps/standalone-apps.js"></script>
</body>
</html>

View File

@@ -1,62 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Update Modal Test - Unraid Component Test</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
html {
font-size: 10px;
}
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 20px;
background: #f3f4f6;
}
.header {
background: #1f2937;
color: white;
padding: 20px;
margin-bottom: 20px;
border-radius: 8px;
}
.header h1 {
margin: 0;
}
.back-link {
color: white;
text-decoration: none;
margin-bottom: 10px;
display: inline-block;
opacity: 0.8;
}
.back-link:hover {
opacity: 1;
}
</style>
</head>
<body>
<div class="header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<a href="/test-pages/" class="back-link">← Back to Test Pages</a>
<h1>🧪 Update Modal Test Scenarios</h1>
</div>
<div>
<unraid-test-theme-switcher></unraid-test-theme-switcher>
</div>
</div>
</div>
<!-- Mount the test component -->
<unraid-test-update-modal></unraid-test-update-modal>
<!-- Mount the modals component which includes the changelog modal -->
<unraid-modals></unraid-modals>
<!-- Load the manifest and inject resources -->
<script src="/test-pages/load-manifest.js"></script>
</body>
</html>

View File

@@ -129,6 +129,10 @@ export default defineConfig({
terserOptions: sharedTerserOptions,
},
optimizeDeps: {
include: ['ajv', 'ajv-errors', 'ajv-formats'],
},
server: {
port: 3000,
proxy: {