Rebuilt the network view in Svelte

This commit is contained in:
David Carley
2022-07-04 16:19:23 -07:00
parent 10df5ada62
commit 9710d56779
52 changed files with 11186 additions and 867 deletions

24
src/svelte-components/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,48 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
`vite dev` and `vite build` wouldn't work in a SvelteKit environment, for example.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Svelte + TS + Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

9507
src/svelte-components/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "svelte-components",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"postbuild": "smui-theme compile dist/smui.css -i src/theme",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/kit": "^1.0.0-next.357",
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.49",
"@tsconfig/svelte": "^3.0.0",
"node-sass": "^7.0.1",
"polyfill-object.fromentries": "^1.0.1",
"smui-theme": "^6.0.0-beta.16",
"svelte": "^3.48.0",
"svelte-check": "^2.8.0",
"svelte-material-ui": "^6.0.0-beta.16",
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.0",
"typescript": "^4.7.4",
"vite": "^2.9.13"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,28 @@
/* To browse the icons in material-symbols, see https://marella.me/material-symbols/demo/ */
@font-face {
font-family: "Material Symbols Outlined";
font-style: normal;
font-weight: 100 700;
font-display: block;
src: url("./fonts/material-symbols-outlined.woff2") format("woff2");
}
.material-symbols-outlined {
font-family: "Material Symbols Outlined";
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "liga";
font-variation-settings: "FILL" 1, "wght" 400, "GRAD" 0, "opsz" 48;
}

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import WifiConnectionDialog from "../dialogs/WifiConnectionDialog.svelte";
import ChangeHostnameDialog from "../dialogs/ChangeHostnameDialog.svelte";
import Paper from "@smui/paper";
import Button, { Label } from "@smui/button";
import List, { Item, Graphic, Text, Meta } from "@smui/list";
import Card from "@smui/card";
import { networkInfo } from "../lib/NetworkInfo";
import type { WifiNetwork } from "../lib/NetworkInfo";
let changeHostnameDialog = {
open: false,
};
let wifiConnectionDialog = {
open: false,
network: {} as WifiNetwork,
};
function getWifiStrengthIcon(network: WifiNetwork) {
const strength = Math.ceil((Number(network.Quality) / 100) * 4);
switch (strength) {
case 1:
return "";
case 2:
return "wifi_1_bar";
case 3:
return "wifi_2_bar";
case 4:
return "wifi";
}
}
function onChangeHostname() {
changeHostnameDialog = {
open: true,
};
}
function onNetworkSelected(network: WifiNetwork) {
wifiConnectionDialog = {
open: true,
network,
};
}
</script>
<WifiConnectionDialog {...wifiConnectionDialog} />
<ChangeHostnameDialog {...changeHostnameDialog} />
<div class="admin-network-view">
<h1>Network Info</h1>
<div class="pure-form pure-form-aligned">
<div class="pure-control-group">
<label for="hostname">Hostname</label>
<Card id="hostname" variant="outlined">
<Text id="hostname">
{$networkInfo.hostname}
</Text>
</Card>
<Button on:click={onChangeHostname} touch variant="raised">
<Label>Change</Label>
</Button>
</div>
</div>
<div class="pure-form pure-form-aligned">
<div class="pure-control-group">
<label for="ip-addresses">IP Addresses</label>
<Card id="ip-addresses" variant="outlined">
{#each $networkInfo.ipAddresses as ipAddress}
<div>
<Text id="hostname">
{ipAddress}
</Text>
</div>
{/each}
</Card>
</div>
</div>
<div class="pure-form pure-form-aligned">
<div class="pure-control-group">
<label for="wifi">Wi-Fi</label>
<div class="wifi-networks">
<Card id="wifi" variant="outlined">
<List>
{#if $networkInfo.wifi.networks.length === 0}
<Item class="wifi-network">
<Text>Scanning...</Text>
</Item>
{:else}
{#each $networkInfo.wifi.networks as network}
<Item
class="wifi-network"
on:SMUI:action={() => onNetworkSelected(network)}
>
<Graphic
class="strength {$networkInfo.wifi.ssid === network.Name
? 'active'
: ''}"
>
<span class="material-symbols-outlined background"
>wifi</span
>
<span class="material-symbols-outlined">
{getWifiStrengthIcon(network)}
</span>
</Graphic>
<Text style="margin-right: 20px;">{network.Name}</Text>
{#if network.Encryption !== "Open"}
<Meta>
<span class="material-symbols-outlined lock">lock</span>
</Meta>
{/if}
</Item>
{/each}
{/if}
</List>
</Card>
<em style="display: block;">
Click on a Wi-Fi network to connect or disconnect.
</em>
</div>
</div>
</div>
</div>
<style lang="scss">
$primary: #0078e7;
$very-dark: #555;
$text: #777;
$grey: #bbb;
$light: #ddd;
:global {
.admin-network-view {
.pure-form-aligned .pure-control-group label {
vertical-align: top;
font-size: 15pt;
font-weight: bold;
}
button {
margin: 0;
}
.mdc-card {
width: 400px;
min-height: 38px;
display: inline-block;
vertical-align: top;
margin-bottom: 20px;
margin-right: 20px;
padding: 5px 15px;
}
.wifi-networks {
display: inline-block;
.mdc-card {
padding: 0;
margin-bottom: 5px;
}
}
.wifi-network {
.lock {
font-size: 20px;
vertical-align: text-bottom;
}
.strength {
border-radius: 50%;
padding: 3px;
background-color: $light;
color: $very-dark;
margin-right: 10px;
position: relative;
&.active {
background-color: $primary;
color: white;
}
span {
position: absolute;
width: 24px;
height: 24px;
&.background {
opacity: 0.25;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import Dialog, { Title, Content, Actions } from "@smui/dialog";
import Button, { Label } from "@smui/button";
import TextField from "@smui/textfield";
import MessageDialog from "./MessageDialog.svelte";
import * as api from "../lib/api";
// https://man7.org/linux/man-pages/man7/hostname.7.html
//
// Each element of the hostname must be from 1 to 63 characters long
// and the entire hostname, including the dots, can be at most 253
// characters long. Valid characters for hostnames are ASCII(7)
// letters from a to z, the digits from 0 to 9, and the hyphen (-).
// A hostname may not start with a hyphen.
const pattern = /[a-zA-Z0-9][a-zA-Z0-9-]{0,62}/;
export let open = false;
let rebooting = false;
let redirectTimeout = 45;
let hostname = "";
$: setTimeout(() => {
hostname = (hostname.match(pattern) || [""])[0].toLowerCase();
}, 0);
$: if (open) {
hostname = "";
}
async function onConfirm() {
rebooting = true;
await api.PUT("hostname", { hostname });
await api.PUT("reboot");
const interval = setInterval(() => {
if (0 < redirectTimeout) {
redirectTimeout -= 1;
} else {
clearInterval(interval);
location.hostname = getRedirectTarget();
}
}, 1000);
}
function getRedirectTarget() {
if (location.hostname.endsWith(".local")) {
return `${hostname}.local`;
}
if (location.hostname.endsWith(".lan")) {
return `${hostname}.lan`;
}
return hostname;
}
</script>
<MessageDialog open={rebooting} title="Rebooting">
Rebooting to apply the hostname change...
</MessageDialog>
<Dialog
bind:open
scrimClickAction=""
aria-labelledby="simple-title"
aria-describedby="simple-content"
>
<Title id="simple-title">Change Hostname</Title>
<Content id="simple-content">
<TextField
bind:value={hostname}
label="New Hostname"
spellcheck="false"
variant="filled"
style="width: 100%;"
/>
<p>
<em>Clicking Confirm will reboot the controller to apply the change.</em>
</p>
</Content>
<Actions>
<Button>
<Label>Cancel</Label>
</Button>
<Button defaultAction on:click={onConfirm} disabled={hostname.length === 0}>
<Label>Confirm</Label>
</Button>
</Actions>
</Dialog>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import Dialog, { Title, Content } from "@smui/dialog";
export let open: boolean;
export let title: string;
</script>
<Dialog
bind:open
scrimClickAction=""
escapeKeyAction=""
aria-labelledby="simple-title"
aria-describedby="simple-content"
>
<Title id="simple-title">{title}</Title>
<Content id="simple-content">
<slot />
</Content>
</Dialog>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import Dialog, { Title, Content, Actions } from "@smui/dialog";
import Button, { Label } from "@smui/button";
import TextField from "@smui/textfield";
import Icon from "@smui/textfield/icon";
import HelperText from "@smui/textfield/helper-text";
import MessageDialog from "./MessageDialog.svelte";
import type { WifiNetwork } from "../lib/NetworkInfo";
import * as api from "../lib/api";
export let open = false;
export let network: WifiNetwork;
let rebooting = false;
let needPassword = false;
let password = "";
let showPassword = false;
let connectOrDisconnect: string;
let connectToOrDisconnectFrom: string;
$: needPassword = !network?.active && network?.Encryption !== "Open";
$: {
connectOrDisconnect = network?.active ? "Disconnect" : "Connect";
connectToOrDisconnectFrom = network?.active ? "Disconnect from" : "Connect to";
}
$: if (open) {
password = "";
}
async function onConfirm() {
rebooting = true;
await api.PUT("network", {
wifi: {
enabled: !network.active,
ssid: network.Name,
password,
},
});
}
</script>
<MessageDialog open={rebooting} title="Rebooting">
Rebooting to apply Wifi changes...
</MessageDialog>
<Dialog
bind:open
scrimClickAction=""
aria-labelledby="simple-title"
aria-describedby="simple-content"
>
<Title id="simple-title">{connectToOrDisconnectFrom} {network.Name}</Title>
<Content id="simple-content">
{#if needPassword}
<TextField
bind:value={password}
label="Password"
spellcheck="false"
variant="filled"
type={showPassword ? "text" : "password"}
style="width: 100%;"
>
<div
slot="trailingIcon"
on:click={() => (showPassword = !showPassword)}
>
<Icon class="material-symbols-outlined">
{showPassword ? "password" : "abc"}
</Icon>
</div>
<HelperText persistent slot="helper">
Wifi passwords must be 8 to 128 characters
</HelperText>
</TextField>
{/if}
<p>
<em>
Clicking {connectOrDisconnect} will reboot the controller to apply the changes.
</em>
</p>
</Content>
<Actions>
<Button>
<Label>Cancel</Label>
</Button>
<Button
defaultAction
on:click={onConfirm}
disabled={needPassword && (password.length < 8 || password.length > 128)}
>
<Label>{connectOrDisconnect}</Label>
</Button>
</Actions>
</Dialog>

View File

@@ -0,0 +1,93 @@
import { readable } from "svelte/store";
import * as api from "./api";
export type WifiNetwork = {
Quality: string;
Channel: string;
Frequency: string;
Mode: string;
"Bit Rates": string;
Name: string;
Address: string;
Encryption: string;
"Signal Level": string;
"Noise Level": string;
lastSeen: number;
active: boolean;
};
export type NetworkInfo = {
ipAddresses: Array<string>;
hostname: string;
wifi: {
ssid: string;
networks: Array<WifiNetwork>;
};
};
const empty: NetworkInfo = {
ipAddresses: [],
hostname: "",
wifi: {
ssid: "",
networks: []
}
}
export const networkInfo = readable<NetworkInfo>(empty, (set) => {
getNetworkInfo();
const networkInfoIntervalId = setInterval(getNetworkInfo, 5000);
async function getNetworkInfo() {
const networksByName: Record<string, WifiNetwork> = {}
try {
const networkInfo: NetworkInfo = await api.GET("network");
const now = Date.now();
for (let network of networkInfo.wifi.networks) {
if (network.Name) {
network.lastSeen = now;
network.active = networkInfo.wifi.ssid === network.Name;
networksByName[network.Name] = network;
}
}
for (let network of Object.values(networksByName)) {
if (network.lastSeen - now > 30000) {
delete networksByName[network.Name];
}
}
set({
ipAddresses: networkInfo.ipAddresses,
hostname: networkInfo.hostname,
wifi: {
ssid: networkInfo.wifi.ssid,
networks: Object.values(networksByName).sort((a, b) => {
switch (true) {
case a.active:
return -1;
case b.active:
return 1;
default:
return a.Name.localeCompare(b.Name);
}
})
}
});
} catch (error) {
console.debug("Failed to fetch network info", error);
}
}
return () => {
clearInterval(networkInfoIntervalId);
}
})
export function init() {
return networkInfo.subscribe(() => ({}));
}

View File

@@ -0,0 +1,41 @@
type HttpMethod = "GET" | "PUT" | "POST" | "DELETE";
async function doFetch(method: HttpMethod, url: string, data: any, config: RequestInit) {
try {
const response = await fetch(`/api/${url}`, {
...config,
method,
cache: "no-cache",
body: (typeof data === 'object')
? JSON.stringify(data)
: undefined,
headers: (typeof data === 'object')
? {
"Content-Type": 'application/json; charset=utf-8'
}
: {}
});
return await response.json();
} catch (error) {
console.debug('API Error: ' + url + ': ' + error);
throw error;
}
}
export async function GET(url: string, config: RequestInit = {}) {
return doFetch('GET', url, undefined, config);
}
export async function PUT(url: string, data: any = undefined, config: RequestInit = {}) {
return doFetch('PUT', url, data, config);
}
export async function POST(url: string, data: any = undefined, config: RequestInit = {}) {
return doFetch('POST', url, data, config);
}
export async function DELETE(url: string, config = {}) {
return doFetch('DELETE', url, undefined, config);
}

View File

@@ -0,0 +1,15 @@
import 'polyfill-object.fromentries';
import AdminNetworkView from './components/AdminNetworkView.svelte';
import { init as initNetworkInfo } from './lib/NetworkInfo';
export function create(component: string, target: HTMLElement, props: Record<string, any>) {
switch (component) {
case "AdminNetworkView":
return new AdminNetworkView({ target, props });
default:
throw new Error("Unknown component");
}
}
export { initNetworkInfo };

View File

@@ -0,0 +1,22 @@
@use 'sass:color';
@use '@material/theme/color-palette';
// Svelte Colors!
@use '@material/theme/index' as theme with (
$primary: #0078e7,
$secondary: #676778,
$surface: #fff,
$background: #fff,
$error: color-palette.$red-900,
$on-surface: #777
);
@use "@material/elevation/mdc-elevation";
@use "@material/list";
@include list.deprecated-core-styles;
:root {
--mdc-theme-text-primary-on-background: #777;
}

View File

@@ -0,0 +1,12 @@
@use 'sass:color';
@use '@material/theme/color-palette';
// Svelte Colors! (Dark Theme)
@use '@material/theme/index' as theme with (
$primary: #ff3e00,
$secondary: color.scale(#676778, $whiteness: -10%),
$surface: color.adjust(color-palette.$grey-900, $blue: +4),
$background: #000,
$error: color-palette.$red-700
);

View File

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

View File

@@ -0,0 +1,7 @@
import sveltePreprocess from 'svelte-preprocess'
export default {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: sveltePreprocess()
}

View File

@@ -0,0 +1,22 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"resolveJsonModule": true,
"baseUrl": ".",
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleSuffixes": [".svelte", ""]
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
svelte()
],
build: {
target: "chrome60",
lib: {
entry: resolve(__dirname, 'src/main.ts'),
name: 'SvelteComponents',
formats: ['iife'],
fileName: () => "index.js"
}
}
})