create-anywherescada-app 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +83 -0
- package/package.json +34 -0
- package/template/env.example +1 -0
- package/template/gitignore +4 -0
- package/template/npmrc +1 -0
- package/template/package.json +29 -0
- package/template/src/app.html +12 -0
- package/template/src/app.scss +27 -0
- package/template/src/hooks.server.ts +11 -0
- package/template/src/lib/anywherescada.ts +62 -0
- package/template/src/lib/types.ts +39 -0
- package/template/src/routes/+layout.server.ts +7 -0
- package/template/src/routes/+layout.svelte +18 -0
- package/template/src/routes/+page.server.ts +24 -0
- package/template/src/routes/+page.svelte +346 -0
- package/template/src/routes/api/subscribe/+server.ts +67 -0
- package/template/svelte.config.js +12 -0
- package/template/tsconfig.json +14 -0
- package/template/vite.config.ts +6 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve, dirname, join } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, cpSync } from "node:fs";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const projectName = process.argv[2];
|
|
10
|
+
if (!projectName) {
|
|
11
|
+
console.error("Usage: npx create-anywherescada-app <project-name>");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const targetDir = resolve(process.cwd(), projectName);
|
|
15
|
+
if (existsSync(targetDir)) {
|
|
16
|
+
console.error(`Error: Directory "${projectName}" already exists.`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
function prompt(question) {
|
|
20
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
rl.question(question, (answer) => {
|
|
23
|
+
rl.close();
|
|
24
|
+
resolve(answer.trim());
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function main() {
|
|
29
|
+
console.log(`\nCreating AnywhereScada app in ${targetDir}...\n`);
|
|
30
|
+
// Copy template
|
|
31
|
+
const templateDir = join(__dirname, "..", "template");
|
|
32
|
+
cpSync(templateDir, targetDir, { recursive: true });
|
|
33
|
+
// Rename dotfiles
|
|
34
|
+
const renames = [
|
|
35
|
+
["gitignore", ".gitignore"],
|
|
36
|
+
["env.example", ".env"],
|
|
37
|
+
["npmrc", ".npmrc"],
|
|
38
|
+
];
|
|
39
|
+
for (const [from, to] of renames) {
|
|
40
|
+
const fromPath = join(targetDir, from);
|
|
41
|
+
const toPath = join(targetDir, to);
|
|
42
|
+
if (existsSync(fromPath)) {
|
|
43
|
+
renameSync(fromPath, toPath);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Prompt for API key
|
|
47
|
+
const apiKey = await prompt("Enter your AnywhereScada API key (or press Enter to skip): ");
|
|
48
|
+
if (apiKey) {
|
|
49
|
+
const envPath = join(targetDir, ".env");
|
|
50
|
+
let envContent = readFileSync(envPath, "utf-8");
|
|
51
|
+
envContent = envContent.replace("ANYWHERESCADA_API_KEY=", `ANYWHERESCADA_API_KEY=${apiKey}`);
|
|
52
|
+
writeFileSync(envPath, envContent);
|
|
53
|
+
}
|
|
54
|
+
// Install dependencies
|
|
55
|
+
console.log("\nInstalling dependencies...\n");
|
|
56
|
+
execSync("npm install", { cwd: targetDir, stdio: "inherit" });
|
|
57
|
+
// Initialize git
|
|
58
|
+
try {
|
|
59
|
+
execSync("git init", { cwd: targetDir, stdio: "ignore" });
|
|
60
|
+
execSync("git add -A", { cwd: targetDir, stdio: "ignore" });
|
|
61
|
+
execSync('git commit -m "Initial commit from create-anywherescada-app"', {
|
|
62
|
+
cwd: targetDir,
|
|
63
|
+
stdio: "ignore",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// git not available, skip
|
|
68
|
+
}
|
|
69
|
+
console.log(`
|
|
70
|
+
Done! Your AnywhereScada app is ready.
|
|
71
|
+
|
|
72
|
+
cd ${projectName}
|
|
73
|
+
npm run dev
|
|
74
|
+
|
|
75
|
+
${apiKey ? "Your API key has been saved to .env" : "Set your API key in .env:\n ANYWHERESCADA_API_KEY=as_live_..."}
|
|
76
|
+
|
|
77
|
+
Learn more: https://anywherescada.com
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
main().catch((err) => {
|
|
81
|
+
console.error(err);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-anywherescada-app",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Create a SvelteKit app connected to AnywhereScada",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-anywherescada-app": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"template"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"svelte",
|
|
19
|
+
"sveltekit",
|
|
20
|
+
"scada",
|
|
21
|
+
"anywherescada",
|
|
22
|
+
"iot",
|
|
23
|
+
"sparkplug"
|
|
24
|
+
],
|
|
25
|
+
"author": "Joy Automation",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.0.0",
|
|
29
|
+
"typescript": "^5.8.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ANYWHERESCADA_API_KEY=
|
package/template/npmrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
legacy-peer-deps=true
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-anywherescada-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@sveltejs/adapter-auto": "^6.0.0",
|
|
14
|
+
"@sveltejs/kit": "^2.22.0",
|
|
15
|
+
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
|
16
|
+
"svelte": "^5.35.0",
|
|
17
|
+
"svelte-check": "^4.2.0",
|
|
18
|
+
"@types/ws": "^8.18.0",
|
|
19
|
+
"typescript": "^5.8.0",
|
|
20
|
+
"vite": "^7.0.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@fontsource/space-grotesk": "^5.2.8",
|
|
24
|
+
"@joyautomation/salt": "^0.0.21",
|
|
25
|
+
"graphql-ws": "^6.0.0",
|
|
26
|
+
"sass": "^1.89.0",
|
|
27
|
+
"ws": "^8.19.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
%sveltekit.head%
|
|
8
|
+
</head>
|
|
9
|
+
<body data-sveltekit-preload-data="hover" class="%theme%">
|
|
10
|
+
<div style="display: contents">%sveltekit.body%</div>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
@use '@fontsource/space-grotesk';
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
font-family: 'Space Grotesk', sans-serif;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
header {
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: space-between;
|
|
11
|
+
padding: calc(var(--spacing-unit) * 4) calc(var(--spacing-unit) * 6);
|
|
12
|
+
background: var(--theme-neutral-100);
|
|
13
|
+
border-bottom: 1px solid var(--theme-neutral-300);
|
|
14
|
+
|
|
15
|
+
h1 {
|
|
16
|
+
font-size: var(--text-xl);
|
|
17
|
+
font-weight: 700;
|
|
18
|
+
color: var(--theme-text);
|
|
19
|
+
margin: 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
main {
|
|
24
|
+
padding: calc(var(--spacing-unit) * 6);
|
|
25
|
+
max-width: 1400px;
|
|
26
|
+
margin: 0 auto;
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Handle } from '@sveltejs/kit';
|
|
2
|
+
|
|
3
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
4
|
+
const theme = event.cookies.get('theme') ?? 'themeLight';
|
|
5
|
+
const validThemes = ['themeLight', 'themeDark'];
|
|
6
|
+
const safeTheme = validThemes.includes(theme) ? theme : 'themeLight';
|
|
7
|
+
|
|
8
|
+
return resolve(event, {
|
|
9
|
+
transformPageChunk: ({ html }) => html.replace('%theme%', safeTheme)
|
|
10
|
+
});
|
|
11
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const API_URL = 'https://api.anywherescada.com/graphql';
|
|
2
|
+
|
|
3
|
+
export async function query<T>(
|
|
4
|
+
apiKey: string,
|
|
5
|
+
gql: string,
|
|
6
|
+
variables?: Record<string, unknown>
|
|
7
|
+
): Promise<T> {
|
|
8
|
+
const response = await fetch(API_URL, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: {
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
Authorization: `Bearer ${apiKey}`
|
|
13
|
+
},
|
|
14
|
+
body: JSON.stringify({ query: gql, variables })
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const json = await response.json();
|
|
22
|
+
if (json.errors) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`GraphQL errors: ${json.errors.map((e: { message: string }) => e.message).join(', ')}`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return json.data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const QUERIES = {
|
|
32
|
+
groups: `
|
|
33
|
+
query GetGroups {
|
|
34
|
+
groups {
|
|
35
|
+
id
|
|
36
|
+
nodes {
|
|
37
|
+
id
|
|
38
|
+
metrics { id name value type scanRate }
|
|
39
|
+
devices {
|
|
40
|
+
id
|
|
41
|
+
metrics { id name value type scanRate }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const SUBSCRIPTIONS = {
|
|
50
|
+
metricUpdate: `
|
|
51
|
+
subscription {
|
|
52
|
+
metricUpdate {
|
|
53
|
+
groupId
|
|
54
|
+
nodeId
|
|
55
|
+
deviceId
|
|
56
|
+
metricId
|
|
57
|
+
value
|
|
58
|
+
timestamp
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
`
|
|
62
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type SparkplugMetricProperty = {
|
|
2
|
+
id: string;
|
|
3
|
+
type: string;
|
|
4
|
+
value: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type SparkplugMetric = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
value: string;
|
|
11
|
+
type: string;
|
|
12
|
+
scanRate: number;
|
|
13
|
+
properties: SparkplugMetricProperty[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SparkplugDevice = {
|
|
17
|
+
id: string;
|
|
18
|
+
metrics: SparkplugMetric[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SparkplugNode = {
|
|
22
|
+
id: string;
|
|
23
|
+
metrics: SparkplugMetric[];
|
|
24
|
+
devices: SparkplugDevice[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type SparkplugGroup = {
|
|
28
|
+
id: string;
|
|
29
|
+
nodes: SparkplugNode[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type MetricUpdate = {
|
|
33
|
+
groupId: string;
|
|
34
|
+
nodeId: string;
|
|
35
|
+
deviceId: string;
|
|
36
|
+
metricId: string;
|
|
37
|
+
value: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import '@joyautomation/salt/styles.scss';
|
|
3
|
+
import '../app.scss';
|
|
4
|
+
import { Toast, ThemeButton } from '@joyautomation/salt';
|
|
5
|
+
|
|
6
|
+
const { data, children } = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<header>
|
|
10
|
+
<h1>My SCADA App</h1>
|
|
11
|
+
<ThemeButton theme={data.theme} />
|
|
12
|
+
</header>
|
|
13
|
+
|
|
14
|
+
<main>
|
|
15
|
+
{@render children()}
|
|
16
|
+
</main>
|
|
17
|
+
|
|
18
|
+
<Toast />
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PageServerLoad } from './$types';
|
|
2
|
+
import { ANYWHERESCADA_API_KEY } from '$env/static/private';
|
|
3
|
+
import { query, QUERIES } from '$lib/anywherescada';
|
|
4
|
+
import { actions as saltActions } from '@joyautomation/salt';
|
|
5
|
+
import type { SparkplugGroup } from '$lib/types';
|
|
6
|
+
|
|
7
|
+
const { setTheme } = saltActions;
|
|
8
|
+
|
|
9
|
+
export const load: PageServerLoad = async () => {
|
|
10
|
+
if (!ANYWHERESCADA_API_KEY) {
|
|
11
|
+
return { groups: [], error: 'ANYWHERESCADA_API_KEY is not set. Add it to your .env file.' };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const data = await query<{ groups: SparkplugGroup[] }>(ANYWHERESCADA_API_KEY, QUERIES.groups);
|
|
16
|
+
return { groups: data.groups, error: null };
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return { groups: [], error: err instanceof Error ? err.message : 'Failed to fetch data' };
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const actions = {
|
|
23
|
+
setTheme
|
|
24
|
+
};
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ChevronDown, ChevronRight } from '@joyautomation/salt/icons';
|
|
3
|
+
import type { SparkplugGroup, MetricUpdate } from '$lib/types';
|
|
4
|
+
|
|
5
|
+
const { data } = $props();
|
|
6
|
+
|
|
7
|
+
// svelte-ignore state_referenced_locally — initialized from server, then updated via SSE
|
|
8
|
+
let groups = $state<SparkplugGroup[]>(data.groups);
|
|
9
|
+
let connected = $state(false);
|
|
10
|
+
// svelte-ignore state_referenced_locally
|
|
11
|
+
let error = $state<string | null>(data.error);
|
|
12
|
+
|
|
13
|
+
let expandedGroups = $state<Set<string>>(new Set());
|
|
14
|
+
let expandedNodes = $state<Set<string>>(new Set());
|
|
15
|
+
let expandedDevices = $state<Set<string>>(new Set());
|
|
16
|
+
|
|
17
|
+
function toggleSet(set: Set<string>, key: string): Set<string> {
|
|
18
|
+
const next = new Set(set);
|
|
19
|
+
if (next.has(key)) {
|
|
20
|
+
next.delete(key);
|
|
21
|
+
} else {
|
|
22
|
+
next.add(key);
|
|
23
|
+
}
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function applyUpdate(update: MetricUpdate) {
|
|
28
|
+
groups = groups.map((group) => {
|
|
29
|
+
if (group.id !== update.groupId) return group;
|
|
30
|
+
return {
|
|
31
|
+
...group,
|
|
32
|
+
nodes: group.nodes.map((node) => {
|
|
33
|
+
if (node.id !== update.nodeId) return node;
|
|
34
|
+
if (!update.deviceId) {
|
|
35
|
+
return {
|
|
36
|
+
...node,
|
|
37
|
+
metrics: node.metrics.map((m) =>
|
|
38
|
+
m.id === update.metricId ? { ...m, value: update.value } : m
|
|
39
|
+
)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
...node,
|
|
44
|
+
devices: node.devices.map((device) => {
|
|
45
|
+
if (device.id !== update.deviceId) return device;
|
|
46
|
+
return {
|
|
47
|
+
...device,
|
|
48
|
+
metrics: device.metrics.map((m) =>
|
|
49
|
+
m.id === update.metricId ? { ...m, value: update.value } : m
|
|
50
|
+
)
|
|
51
|
+
};
|
|
52
|
+
})
|
|
53
|
+
};
|
|
54
|
+
})
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
$effect(() => {
|
|
60
|
+
if (error) return;
|
|
61
|
+
|
|
62
|
+
const eventSource = new EventSource('/api/subscribe');
|
|
63
|
+
|
|
64
|
+
eventSource.onopen = () => {
|
|
65
|
+
connected = true;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
eventSource.addEventListener('metricUpdate', (event) => {
|
|
69
|
+
const updates: MetricUpdate[] = JSON.parse(event.data);
|
|
70
|
+
for (const update of updates) {
|
|
71
|
+
applyUpdate(update);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
eventSource.onerror = () => {
|
|
76
|
+
connected = false;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
eventSource.close();
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<div class="dashboard">
|
|
86
|
+
<div class="status-bar">
|
|
87
|
+
<h2>Live Metrics</h2>
|
|
88
|
+
<div class="connection-status">
|
|
89
|
+
<span class="status-dot" class:connected></span>
|
|
90
|
+
{connected ? 'Connected' : 'Connecting...'}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{#if error}
|
|
95
|
+
<div class="error-banner">
|
|
96
|
+
<p>{error}</p>
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
{#if groups.length === 0 && !error}
|
|
101
|
+
<div class="empty-state">
|
|
102
|
+
<p>No data available. Make sure your space has active Sparkplug nodes publishing data.</p>
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
{#each groups as group}
|
|
107
|
+
<div class="group-card">
|
|
108
|
+
<button class="group-header" onclick={() => (expandedGroups = toggleSet(expandedGroups, group.id))}>
|
|
109
|
+
{#if expandedGroups.has(group.id)}
|
|
110
|
+
<ChevronDown />
|
|
111
|
+
{:else}
|
|
112
|
+
<ChevronRight />
|
|
113
|
+
{/if}
|
|
114
|
+
<span class="group-name">{group.id}</span>
|
|
115
|
+
<span class="badge">{group.nodes.length} node{group.nodes.length !== 1 ? 's' : ''}</span>
|
|
116
|
+
</button>
|
|
117
|
+
|
|
118
|
+
{#if expandedGroups.has(group.id)}
|
|
119
|
+
<div class="group-content">
|
|
120
|
+
{#each group.nodes as node}
|
|
121
|
+
{@const nodeKey = `${group.id}/${node.id}`}
|
|
122
|
+
<div class="node-section">
|
|
123
|
+
<button class="node-header" onclick={() => (expandedNodes = toggleSet(expandedNodes, nodeKey))}>
|
|
124
|
+
{#if expandedNodes.has(nodeKey)}
|
|
125
|
+
<ChevronDown />
|
|
126
|
+
{:else}
|
|
127
|
+
<ChevronRight />
|
|
128
|
+
{/if}
|
|
129
|
+
<span class="node-name">{node.id}</span>
|
|
130
|
+
</button>
|
|
131
|
+
|
|
132
|
+
{#if expandedNodes.has(nodeKey)}
|
|
133
|
+
<div class="node-content">
|
|
134
|
+
{#if node.metrics.length > 0}
|
|
135
|
+
<div class="metrics-section">
|
|
136
|
+
<h4>Node Metrics</h4>
|
|
137
|
+
<div class="metrics-grid">
|
|
138
|
+
{#each node.metrics as metric}
|
|
139
|
+
<div class="metric-card">
|
|
140
|
+
<span class="metric-name">{metric.name}</span>
|
|
141
|
+
<span class="metric-value">{metric.value}</span>
|
|
142
|
+
<span class="metric-type">{metric.type}</span>
|
|
143
|
+
</div>
|
|
144
|
+
{/each}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
{/if}
|
|
148
|
+
|
|
149
|
+
{#each node.devices as device}
|
|
150
|
+
{@const deviceKey = `${group.id}/${node.id}/${device.id}`}
|
|
151
|
+
<div class="device-section">
|
|
152
|
+
<button class="device-header" onclick={() => (expandedDevices = toggleSet(expandedDevices, deviceKey))}>
|
|
153
|
+
{#if expandedDevices.has(deviceKey)}
|
|
154
|
+
<ChevronDown />
|
|
155
|
+
{:else}
|
|
156
|
+
<ChevronRight />
|
|
157
|
+
{/if}
|
|
158
|
+
<span class="device-name">{device.id}</span>
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
{#if expandedDevices.has(deviceKey)}
|
|
162
|
+
<div class="metrics-grid">
|
|
163
|
+
{#each device.metrics as metric}
|
|
164
|
+
<div class="metric-card">
|
|
165
|
+
<span class="metric-name">{metric.name}</span>
|
|
166
|
+
<span class="metric-value">{metric.value}</span>
|
|
167
|
+
<span class="metric-type">{metric.type}</span>
|
|
168
|
+
</div>
|
|
169
|
+
{/each}
|
|
170
|
+
</div>
|
|
171
|
+
{/if}
|
|
172
|
+
</div>
|
|
173
|
+
{/each}
|
|
174
|
+
</div>
|
|
175
|
+
{/if}
|
|
176
|
+
</div>
|
|
177
|
+
{/each}
|
|
178
|
+
</div>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
{/each}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<style lang="scss">
|
|
185
|
+
.dashboard {
|
|
186
|
+
display: flex;
|
|
187
|
+
flex-direction: column;
|
|
188
|
+
gap: calc(var(--spacing-unit) * 4);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.status-bar {
|
|
192
|
+
display: flex;
|
|
193
|
+
justify-content: space-between;
|
|
194
|
+
align-items: center;
|
|
195
|
+
|
|
196
|
+
h2 {
|
|
197
|
+
margin: 0;
|
|
198
|
+
font-size: var(--text-2xl);
|
|
199
|
+
color: var(--theme-text);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.connection-status {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
gap: calc(var(--spacing-unit) * 2);
|
|
207
|
+
font-size: var(--text-sm);
|
|
208
|
+
color: var(--theme-neutral-600);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.status-dot {
|
|
212
|
+
width: 8px;
|
|
213
|
+
height: 8px;
|
|
214
|
+
border-radius: 50%;
|
|
215
|
+
background: var(--red-500);
|
|
216
|
+
|
|
217
|
+
&.connected {
|
|
218
|
+
background: var(--green-500);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.error-banner {
|
|
223
|
+
padding: calc(var(--spacing-unit) * 4);
|
|
224
|
+
background: var(--theme-error-100);
|
|
225
|
+
border: 1px solid var(--theme-error-300);
|
|
226
|
+
border-radius: var(--rounded-lg);
|
|
227
|
+
color: var(--theme-error-700);
|
|
228
|
+
|
|
229
|
+
p {
|
|
230
|
+
margin: 0;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.empty-state {
|
|
235
|
+
text-align: center;
|
|
236
|
+
padding: calc(var(--spacing-unit) * 12);
|
|
237
|
+
color: var(--theme-neutral-500);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.group-card {
|
|
241
|
+
background: var(--theme-neutral-50);
|
|
242
|
+
border: 1px solid var(--theme-neutral-300);
|
|
243
|
+
border-radius: var(--rounded-lg);
|
|
244
|
+
overflow: hidden;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.group-header,
|
|
248
|
+
.node-header,
|
|
249
|
+
.device-header {
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
gap: calc(var(--spacing-unit) * 2);
|
|
253
|
+
width: 100%;
|
|
254
|
+
padding: calc(var(--spacing-unit) * 3) calc(var(--spacing-unit) * 4);
|
|
255
|
+
border: none;
|
|
256
|
+
background: transparent;
|
|
257
|
+
cursor: pointer;
|
|
258
|
+
font-size: var(--text-base);
|
|
259
|
+
color: var(--theme-text);
|
|
260
|
+
text-align: left;
|
|
261
|
+
|
|
262
|
+
&:hover {
|
|
263
|
+
background: var(--theme-neutral-200);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.group-header {
|
|
268
|
+
background: var(--theme-neutral-100);
|
|
269
|
+
font-weight: 600;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.badge {
|
|
273
|
+
margin-left: auto;
|
|
274
|
+
font-size: var(--text-xs);
|
|
275
|
+
padding: calc(var(--spacing-unit) * 0.5) calc(var(--spacing-unit) * 2);
|
|
276
|
+
background: var(--theme-primary);
|
|
277
|
+
color: white;
|
|
278
|
+
border-radius: var(--rounded-full);
|
|
279
|
+
font-weight: 500;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.group-content {
|
|
283
|
+
padding: calc(var(--spacing-unit) * 2);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.node-section {
|
|
287
|
+
border: 1px solid var(--theme-neutral-200);
|
|
288
|
+
border-radius: var(--rounded-md);
|
|
289
|
+
margin-bottom: calc(var(--spacing-unit) * 2);
|
|
290
|
+
overflow: hidden;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.node-content {
|
|
294
|
+
padding: calc(var(--spacing-unit) * 3);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.metrics-section h4 {
|
|
298
|
+
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
|
299
|
+
font-size: var(--text-sm);
|
|
300
|
+
color: var(--theme-neutral-500);
|
|
301
|
+
text-transform: uppercase;
|
|
302
|
+
letter-spacing: 0.05em;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.device-section {
|
|
306
|
+
margin-top: calc(var(--spacing-unit) * 2);
|
|
307
|
+
border: 1px solid var(--theme-neutral-200);
|
|
308
|
+
border-radius: var(--rounded-md);
|
|
309
|
+
overflow: hidden;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.metrics-grid {
|
|
313
|
+
display: grid;
|
|
314
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
315
|
+
gap: calc(var(--spacing-unit) * 2);
|
|
316
|
+
padding: calc(var(--spacing-unit) * 2);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.metric-card {
|
|
320
|
+
display: flex;
|
|
321
|
+
flex-direction: column;
|
|
322
|
+
gap: calc(var(--spacing-unit) * 1);
|
|
323
|
+
padding: calc(var(--spacing-unit) * 3);
|
|
324
|
+
background: var(--theme-neutral-100);
|
|
325
|
+
border-radius: var(--rounded-md);
|
|
326
|
+
border: 1px solid var(--theme-neutral-200);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.metric-name {
|
|
330
|
+
font-size: var(--text-sm);
|
|
331
|
+
color: var(--theme-neutral-600);
|
|
332
|
+
font-weight: 500;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.metric-value {
|
|
336
|
+
font-size: var(--text-xl);
|
|
337
|
+
font-weight: 700;
|
|
338
|
+
color: var(--theme-text);
|
|
339
|
+
font-variant-numeric: tabular-nums;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.metric-type {
|
|
343
|
+
font-size: var(--text-xs);
|
|
344
|
+
color: var(--theme-neutral-400);
|
|
345
|
+
}
|
|
346
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { RequestHandler } from './$types';
|
|
2
|
+
import { ANYWHERESCADA_API_KEY } from '$env/static/private';
|
|
3
|
+
import { createClient } from 'graphql-ws';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import { SUBSCRIPTIONS } from '$lib/anywherescada';
|
|
6
|
+
|
|
7
|
+
export const GET: RequestHandler = () => {
|
|
8
|
+
if (!ANYWHERESCADA_API_KEY) {
|
|
9
|
+
return new Response('API key not configured', { status: 500 });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let cleanup: (() => void) | undefined;
|
|
13
|
+
|
|
14
|
+
const stream = new ReadableStream({
|
|
15
|
+
start(controller) {
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
|
|
18
|
+
const client = createClient({
|
|
19
|
+
url: `wss://api.anywherescada.com/graphql?token=${ANYWHERESCADA_API_KEY}`,
|
|
20
|
+
webSocketImpl: WebSocket
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const unsubscribe = client.subscribe(
|
|
24
|
+
{ query: SUBSCRIPTIONS.metricUpdate },
|
|
25
|
+
{
|
|
26
|
+
next: (result) => {
|
|
27
|
+
if (result.data?.metricUpdate) {
|
|
28
|
+
const data = `event: metricUpdate\ndata: ${JSON.stringify(result.data.metricUpdate)}\n\n`;
|
|
29
|
+
controller.enqueue(encoder.encode(data));
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
error: (err) => {
|
|
33
|
+
console.error('Subscription error:', err);
|
|
34
|
+
try {
|
|
35
|
+
controller.close();
|
|
36
|
+
} catch {
|
|
37
|
+
// Already closed
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
complete: () => {
|
|
41
|
+
try {
|
|
42
|
+
controller.close();
|
|
43
|
+
} catch {
|
|
44
|
+
// Already closed
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
cleanup = () => {
|
|
51
|
+
unsubscribe();
|
|
52
|
+
client.dispose();
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
cancel() {
|
|
56
|
+
cleanup?.();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return new Response(stream, {
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'text/event-stream',
|
|
63
|
+
'Cache-Control': 'no-cache',
|
|
64
|
+
Connection: 'keep-alive'
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import adapter from '@sveltejs/adapter-auto';
|
|
2
|
+
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
3
|
+
|
|
4
|
+
/** @type {import('@sveltejs/kit').Config} */
|
|
5
|
+
const config = {
|
|
6
|
+
preprocess: [vitePreprocess()],
|
|
7
|
+
kit: {
|
|
8
|
+
adapter: adapter()
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default config;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./.svelte-kit/tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"allowJs": true,
|
|
5
|
+
"checkJs": true,
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"forceConsistentCasingInFileNames": true,
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"moduleResolution": "bundler"
|
|
13
|
+
}
|
|
14
|
+
}
|