UI Phenikaa

This commit is contained in:
2026-07-22 10:13:19 +07:00
parent 0714b442be
commit 3da8608cf1
13 changed files with 1079 additions and 183 deletions

View File

@@ -5,12 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" />
<title>Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>

View File

@@ -8,13 +8,13 @@
"name": "robot-installer-web-client",
"version": "1.0.0",
"dependencies": {
"@fluentui/react-icons": "^2.0.333",
"@fontsource-variable/montserrat": "^5.3.0",
"@vitejs/plugin-react": "^5.0.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^7.0.0"
},
"devDependencies": {}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
@@ -234,6 +234,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -279,6 +288,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -695,6 +710,64 @@
"node": ">=18"
}
},
"node_modules/@fluentui/react-icons": {
"version": "2.0.333",
"resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.333.tgz",
"integrity": "sha512-HvS5kKw9tGweI3Poy6OGAiFqrt6HGElO46DFhNBeoIQRK/q35mZ2ep0u762MFuvOfLX3bVGSB8frORFTjh1E7A==",
"license": "MIT",
"dependencies": {
"@griffel/react": "^1.6.1",
"tslib": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fontsource-variable/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@griffel/core": {
"version": "1.21.3",
"resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.21.3.tgz",
"integrity": "sha512-FMnlwhtmCRWvXEg2j/6W90wzvW+PFqdrsWFslfmxwS6l9X73gO0dnndKphht9ZOsJODvmLdqlnU1Lh8igg2mKw==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "^0.9.0",
"@griffel/style-types": "^1.4.2",
"csstype": "^3.2.3",
"rtl-css-js": "^1.16.1",
"stylis": "^4.4.0",
"tslib": "^2.1.0"
}
},
"node_modules/@griffel/react": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.7.6.tgz",
"integrity": "sha512-hqkbKRfSN/jKvPFzMoaA+gzoA9INfDyDqt7ypHOJP3KjZWp+n5f/QxLpmvqE3ssqDDS7YsFKN6qPmYBRC2SA7w==",
"license": "MIT",
"dependencies": {
"@griffel/core": "^1.21.3",
"tslib": "^2.1.0"
},
"peerDependencies": {
"react": ">=16.14.0 <20.0.0"
}
},
"node_modules/@griffel/style-types": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.4.2.tgz",
"integrity": "sha512-MsSghfpyxR2MpTrYdcCozISsSLkmFjNw94wNPi4bDBRLW8W43718W/ZjmUdVkoM0KXMtJPYuEkx8Mzibqb03qA==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.3"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1248,6 +1321,12 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1400,15 +1479,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.468.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1562,6 +1632,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rtl-css-js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1586,6 +1665,12 @@
"node": ">=0.10.0"
}
},
"node_modules/stylis": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -1602,6 +1687,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -10,11 +10,11 @@
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@fluentui/react-icons": "^2.0.333",
"@fontsource-variable/montserrat": "^5.3.0",
"@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.468.0"
},
"devDependencies": {}
"vite": "^7.0.0"
}
}

View File

@@ -1,30 +1,31 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
Activity,
AlertCircle,
Box,
CheckCircle2,
Clipboard,
Cpu,
Download,
ExternalLink,
HardDrive,
Loader2,
PackageCheck,
Play,
PlugZap,
RefreshCcw,
RotateCcw,
Search,
Server,
Settings,
ShieldCheck,
Trash2,
WifiOff,
X,
XCircle
} from 'lucide-react';
ArrowCounterclockwise20Regular as RotateCcw,
ArrowDownload20Regular as Download,
ArrowSync20Regular as RefreshCcw,
Board20Regular as Cpu,
Bot24Filled as PackageCheck,
Box20Regular as Box,
CheckmarkCircle20Filled as CheckCircle2,
Clipboard20Regular as Clipboard,
Delete20Regular as Trash2,
Dismiss20Regular as X,
ErrorCircle20Filled as XCircle,
HardDrive20Regular as HardDrive,
Open20Regular as ExternalLink,
Play20Filled as Play,
PlugConnected20Regular as PlugZap,
Pulse20Regular as Activity,
Search20Regular as Search,
Server20Regular as Server,
Settings20Regular as Settings,
ShieldCheckmark20Filled as ShieldCheck,
SpinnerIos20Regular as Loader2,
Warning20Filled as AlertCircle,
WifiOff20Regular as WifiOff
} from '@fluentui/react-icons';
import '@fontsource-variable/montserrat';
import {
DEFAULT_AGENT_BASE_URL,
DEFAULT_PACKAGE_BASE_URL,
@@ -755,7 +756,7 @@ function App() {
<aside className="sidebar">
<div className="brand-block">
<div className="brand-mark">
<PackageCheck size={20} aria-hidden="true" />
<PackageCheck fontSize={20} aria-hidden="true" />
</div>
<div className="brand-copy">
<strong>Robot Installer</strong>
@@ -790,7 +791,7 @@ function App() {
</div>
</div>
<button className="btn btn-secondary full" type="button" onClick={openEndpointDialog}>
<Settings size={15} aria-hidden="true" />
<Settings fontSize={15} aria-hidden="true" />
Endpoint settings
</button>
</div>
@@ -810,10 +811,10 @@ function App() {
tone={agentStatusTone}
/>
<a className="icon-button" href={joinUrl(packageBaseUrl, '/api/apps')} target="_blank" rel="noreferrer" title="Open package API">
<ExternalLink size={17} aria-hidden="true" />
<ExternalLink fontSize={17} aria-hidden="true" />
</a>
<button className="btn btn-secondary" type="button" onClick={refreshAll}>
<RefreshCcw size={15} aria-hidden="true" />
<RefreshCcw fontSize={15} aria-hidden="true" />
Retry
</button>
</div>
@@ -832,13 +833,13 @@ function App() {
{canUseAgentEndpoint && !agentHealth && (
<div className="offline-banner">
<AlertCircle size={19} aria-hidden="true" />
<AlertCircle fontSize={19} aria-hidden="true" />
<div>
<strong>{agentTargetLabel} is offline</strong>
<code>{agentCommand}</code>
</div>
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
<Clipboard size={15} aria-hidden="true" />
<Clipboard fontSize={15} aria-hidden="true" />
Copy
</button>
</div>
@@ -846,13 +847,13 @@ function App() {
{canUseAgentEndpoint && agentNeedsUpdate && (
<div className="offline-banner agent-update-banner">
<AlertCircle size={19} aria-hidden="true" />
<AlertCircle fontSize={19} aria-hidden="true" />
<div>
<strong>Agent {latestAgentPackage.version} is ready</strong>
<code>{agentCommand}</code>
</div>
<button className="btn btn-secondary" type="button" onClick={copyInstallCommand}>
<Clipboard size={15} aria-hidden="true" />
<Clipboard fontSize={15} aria-hidden="true" />
Copy
</button>
</div>
@@ -864,7 +865,7 @@ function App() {
<label className="filter-field wide">
<span>Search</span>
<div className="input-with-icon">
<Search size={15} aria-hidden="true" />
<Search fontSize={15} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
@@ -952,7 +953,7 @@ function App() {
runAppAction('install', app);
}}
>
{installBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <Play size={14} aria-hidden="true" />}
{installBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <Play fontSize={14} aria-hidden="true" />}
Install
</button>
)}
@@ -966,7 +967,7 @@ function App() {
runAppAction('update', app);
}}
>
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
{updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
Retry
</button>
)}
@@ -980,7 +981,7 @@ function App() {
runAppAction('update', app);
}}
>
{updateBusy ? <Loader2 className="spin" size={14} aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
{updateBusy ? <Loader2 className="spin" fontSize={14} aria-hidden="true" /> : <RotateCcw fontSize={14} aria-hidden="true" />}
Update
</button>
)}
@@ -993,7 +994,7 @@ function App() {
title={`Open ${app.appName}`}
onClick={(event) => event.stopPropagation()}
>
<ExternalLink size={14} aria-hidden="true" />
<ExternalLink fontSize={14} aria-hidden="true" />
Open App
</a>
)}
@@ -1008,7 +1009,7 @@ function App() {
runAppAction('remove', app);
}}
>
{removeBusy ? <Loader2 className="spin" size={16} aria-hidden="true" /> : <Trash2 size={16} aria-hidden="true" />}
{removeBusy ? <Loader2 className="spin" fontSize={16} aria-hidden="true" /> : <Trash2 fontSize={16} aria-hidden="true" />}
</button>
)}
</div>
@@ -1084,7 +1085,7 @@ function AgentStatusButton({ title, detail, tone, onClick }) {
return (
<button className={`agent-status-button tone-${tone || 'info'}`} type="button" onClick={onClick}>
<span className="agent-status-icon">
<Icon className={tone === 'info' ? 'spin' : ''} size={16} aria-hidden="true" />
<Icon className={tone === 'info' ? 'spin' : ''} fontSize={16} aria-hidden="true" />
</span>
<span className="agent-status-copy">
<strong>{title}</strong>
@@ -1130,7 +1131,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
<p>Changes only apply after you press Apply.</p>
</div>
<button className="icon-button subtle" type="button" title="Close" onClick={onCancel}>
<X size={16} aria-hidden="true" />
<X fontSize={16} aria-hidden="true" />
</button>
</div>
@@ -1163,7 +1164,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
Cancel
</button>
<button className="btn btn-primary" type="submit">
<Settings size={15} aria-hidden="true" />
<Settings fontSize={15} aria-hidden="true" />
Apply endpoints
</button>
</div>
@@ -1176,7 +1177,7 @@ function EndpointDialog({ draftSettings, onApply, onCancel, onChange }) {
function StatusBox({ icon: Icon, title, detail, tone }) {
return (
<div className={`status-box tone-${tone || 'muted'}`}>
<span className="status-icon"><Icon size={16} aria-hidden="true" /></span>
<span className="status-icon"><Icon fontSize={16} aria-hidden="true" /></span>
<div>
<strong>{title}</strong>
<span>{detail}</span>
@@ -1188,7 +1189,7 @@ function StatusBox({ icon: Icon, title, detail, tone }) {
function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand, osLabel }) {
return (
<div className={`offline-banner client-os-banner ${canShowCommand ? 'command-visible' : ''}`}>
<AlertCircle size={19} aria-hidden="true" />
<AlertCircle fontSize={19} aria-hidden="true" />
<div>
<strong>{isWindows ? 'Remote Ubuntu endpoint needed' : 'Local Agent requires Linux'}</strong>
<p>
@@ -1200,7 +1201,7 @@ function ClientOsNotice({ agentCommand, canShowCommand, isWindows, onCopyCommand
</div>
{canShowCommand && (
<button className="btn btn-secondary" type="button" onClick={onCopyCommand}>
<Clipboard size={15} aria-hidden="true" />
<Clipboard fontSize={15} aria-hidden="true" />
Copy
</button>
)}
@@ -1268,11 +1269,11 @@ function TaskPanel({ task, onClear, onRefresh }) {
<div className="panel-actions">
<span className={statusBadgeClass(task.status)}>{task.status || 'queued'}</span>
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh} disabled={!canRefresh}>
<RefreshCcw size={16} aria-hidden="true" />
<RefreshCcw fontSize={16} aria-hidden="true" />
</button>
{canClear && (
<button className="icon-button subtle" type="button" title="Clear task" onClick={onClear}>
<XCircle size={16} aria-hidden="true" />
<XCircle fontSize={16} aria-hidden="true" />
</button>
)}
</div>
@@ -1305,7 +1306,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
<div className="component-list task-components">
<div className="component-list-title">
<Activity size={15} aria-hidden="true" />
<Activity fontSize={15} aria-hidden="true" />
Components
</div>
{components.map((component) => (
@@ -1351,7 +1352,7 @@ function ServiceChecks({ checks = [], status = 'not-checked', showSource = false
return (
<div className="service-check-list">
<div className="service-check-empty">
<Activity size={14} aria-hidden="true" />
<Activity fontSize={14} aria-hidden="true" />
<span>{message || 'Service status unavailable'}</span>
</div>
</div>
@@ -1364,8 +1365,8 @@ function ServiceChecks({ checks = [], status = 'not-checked', showSource = false
<div className="service-check-row" key={`${check.componentId || ''}:${check.serviceName}`}>
<div className="service-check-name">
{check.healthy
? <CheckCircle2 className="service-check-icon healthy" size={14} aria-hidden="true" />
: <AlertCircle className="service-check-icon unhealthy" size={14} aria-hidden="true" />}
? <CheckCircle2 className="service-check-icon healthy" fontSize={14} aria-hidden="true" />
: <AlertCircle className="service-check-icon unhealthy" fontSize={14} aria-hidden="true" />}
<span>
<strong>{check.serviceName}</strong>
<small>
@@ -1412,9 +1413,9 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
<p>{status.message || endpoint || '127.0.0.1:5010'}</p>
</div>
<div className="panel-actions">
{needsUpdate ? <AlertCircle className="panel-state warning" size={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" size={20} aria-hidden="true" /> : <XCircle className="panel-state danger" size={20} aria-hidden="true" />}
{needsUpdate ? <AlertCircle className="panel-state warning" fontSize={20} aria-hidden="true" /> : health ? <CheckCircle2 className="panel-state success" fontSize={20} aria-hidden="true" /> : <XCircle className="panel-state danger" fontSize={20} aria-hidden="true" />}
<button className="icon-button subtle" type="button" title="Close" onClick={onClose}>
<X size={16} aria-hidden="true" />
<X fontSize={16} aria-hidden="true" />
</button>
</div>
</div>
@@ -1454,18 +1455,18 @@ function AgentPanel({ health, systemInfo, status, title, endpoint, latestAgentPa
</div>
</dl>
<div className="agent-metrics">
<span><Cpu size={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
<span><HardDrive size={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
<span><Cpu fontSize={14} aria-hidden="true" /> {systemInfo?.kernel || 'kernel -'}</span>
<span><HardDrive fontSize={14} aria-hidden="true" /> {systemInfo?.diskFree || 'disk -'}</span>
</div>
{showAgentActions && (
<div className="agent-update-action">
<button className={`btn ${needsUpdate ? 'btn-warning' : 'btn-secondary'}`} type="button" onClick={onCopyUpdate}>
<Clipboard size={15} aria-hidden="true" />
<Clipboard fontSize={15} aria-hidden="true" />
{needsUpdate ? 'Copy update command' : 'Copy Agent command'}
</button>
{needsUpdate && latestAgentPackage?.downloadUrl && (
<a className="btn btn-secondary" href={latestAgentPackage.downloadUrl} target="_blank" rel="noreferrer">
<Download size={15} aria-hidden="true" />
<Download fontSize={15} aria-hidden="true" />
Latest .deb
</a>
)}
@@ -1488,7 +1489,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
<h2>{app?.appName || 'App detail'}</h2>
<p>{app?.appCode || app?.appId || status.message || packageBaseUrl}</p>
</div>
{status.state === 'loading' ? <Loader2 className="spin panel-state" size={20} aria-hidden="true" /> : <Box className="panel-state" size={20} aria-hidden="true" />}
{status.state === 'loading' ? <Loader2 className="spin panel-state" fontSize={20} aria-hidden="true" /> : <Box className="panel-state" fontSize={20} aria-hidden="true" />}
</div>
{app ? (
@@ -1510,7 +1511,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
<div className="component-list">
<div className="component-list-title">
<ShieldCheck size={15} aria-hidden="true" />
<ShieldCheck fontSize={15} aria-hidden="true" />
Components
</div>
{status.state === 'danger' && (
@@ -1533,7 +1534,7 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
{app.installed && installedServiceStatus !== 'not-checked' && (
<div className="service-health-panel">
<div className="component-list-title">
<Activity size={15} aria-hidden="true" />
<Activity fontSize={15} aria-hidden="true" />
Service health
<span className={`badge badge-${installedServiceStatus === 'healthy' ? 'success' : installedServiceStatus === 'unhealthy' ? 'danger' : 'muted'}`}>
{serviceCheckSummary(installedServiceStatus, installedServiceChecks)}
@@ -1559,7 +1560,7 @@ function Toast({ toast }) {
const Icon = tone === 'danger' ? AlertCircle : tone === 'success' ? CheckCircle2 : Activity;
return (
<div className={`toast tone-${tone || 'info'}`}>
<Icon size={17} aria-hidden="true" />
<Icon fontSize={17} aria-hidden="true" />
<span>{toast.message}</span>
</div>
);

View File

@@ -1,28 +1,93 @@
:root {
--primary: #3755c3;
--primary-dim: #2848b7;
--primary-container: #dde1ff;
--on-primary: #f8f7ff;
--background: #f7f9fb;
--surface-lowest: #ffffff;
--surface-low: #f0f4f7;
--surface: #e8eff3;
--surface-high: #e1e9ee;
--on-surface: #2a3439;
--on-surface-variant: #566166;
--outline-variant: #a9b4b9;
--danger: #b42318;
--danger-bg: #fee4e2;
--success: #067647;
--success-bg: #dcfae6;
--warning: #b54708;
--warning-bg: #fef0c7;
--info: #175cd3;
--info-bg: #d1e9ff;
--muted-bg: #e2e8f0;
--radius: 8px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18);
color-scheme: light;
/* Phenikaa-X universal tokens — design-language.md v0.4.4 */
--blue-50: #eaedf5;
--blue-100: #c5cce0;
--blue-200: #8c9ac4;
--blue-300: #6e7ca8;
--blue-500: #223771;
--blue-700: #1a2a57;
--blue-900: #1e2243;
--orange-100: #fcd9c4;
--orange-300: #f58220;
--orange-500: #f26522;
--orange-700: #c44e14;
--neutral-0: #ffffff;
--neutral-50: #f7f8fb;
--neutral-100: #eeedf6;
--neutral-200: #dddfea;
--neutral-300: #c5c8d8;
--neutral-400: #9fa3b8;
--neutral-450: #8c90a8;
--neutral-500: #757a91;
--neutral-600: #565b70;
--neutral-700: #3c4054;
--neutral-800: #272a3a;
--neutral-900: #161824;
--surface-base: var(--neutral-0);
--surface-raised: var(--neutral-50);
--surface-brand-tint: var(--neutral-100);
--text-primary: var(--neutral-900);
--text-secondary: var(--neutral-600);
--text-tertiary: var(--neutral-500);
--border-subtle: var(--neutral-300);
--border-strong: var(--neutral-450);
--brand-primary: var(--blue-500);
--brand-primary-strong: var(--blue-700);
--brand-accent: var(--orange-500);
--brand-accent-strong: var(--orange-700);
--semantic-success: #2e7d32;
--semantic-success-bg: #e8f5e9;
--semantic-warning: #9a5b00;
--semantic-warning-icon: #cb6119;
--semantic-warning-bg: #fff4db;
--semantic-error: #c62828;
--semantic-error-bg: #ffebee;
--semantic-info: #1565c0;
--semantic-info-bg: #e7f1fc;
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
/* Compatibility aliases used by the existing components. */
--primary: var(--brand-primary);
--primary-dim: var(--brand-primary-strong);
--primary-container: var(--blue-50);
--on-primary: var(--neutral-0);
--background: var(--surface-raised);
--surface-lowest: var(--surface-base);
--surface-low: var(--surface-raised);
--surface: var(--surface-brand-tint);
--surface-high: var(--neutral-200);
--on-surface: var(--text-primary);
--on-surface-variant: var(--text-secondary);
--outline: var(--border-strong);
--outline-variant: var(--border-subtle);
--danger: var(--semantic-error);
--danger-bg: var(--semantic-error-bg);
--success: var(--semantic-success);
--success-bg: var(--semantic-success-bg);
--warning: var(--semantic-warning);
--warning-bg: var(--semantic-warning-bg);
--info: var(--semantic-info);
--info-bg: var(--semantic-info-bg);
--muted-bg: var(--neutral-200);
--radius: var(--radius-md);
--shadow-sm: var(--elevation-1);
--shadow-lg: var(--elevation-2);
}
* {
@@ -40,8 +105,9 @@ body {
margin: 0;
background: var(--background);
color: var(--on-surface);
font-family: "Inter", Arial, sans-serif;
font-size: 14px;
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.5;
overflow: hidden;
}
@@ -75,7 +141,7 @@ p {
h1,
h2,
.brand-copy strong {
font-family: "Manrope", Arial, sans-serif;
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
}
code {
@@ -1446,3 +1512,290 @@ tbody tr.selected-row td.action-col {
}
}
/* --------------------------------------------------------------------------
Phenikaa-X application theme
Keeps the existing component contract while applying the Layer-1 language.
-------------------------------------------------------------------------- */
::selection {
background: var(--orange-100);
color: var(--text-primary);
}
:focus-visible {
outline: 2px solid var(--blue-500);
outline-offset: 2px;
}
.sidebar {
background: var(--blue-900);
border-color: var(--neutral-700);
}
.brand-block {
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
min-height: 72px;
padding: var(--space-md);
}
.brand-mark {
background: var(--brand-accent);
border-radius: var(--radius-md);
box-shadow: none;
color: var(--neutral-0);
height: 40px;
width: 40px;
}
.brand-copy strong,
.sidebar .status-box strong,
.sidebar .endpoint-summary-row strong {
color: var(--neutral-0);
}
.brand-copy strong {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-copy span,
.sidebar .nav-label,
.sidebar .endpoint-summary-row span,
.sidebar .status-box div > span {
color: var(--blue-100);
}
.brand-copy span,
.nav-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
}
.sidebar .status-box,
.sidebar .endpoint-summary {
background: rgba(255, 255, 255, 0.055);
border-color: rgba(197, 204, 224, 0.2);
}
.sidebar .status-box:hover,
.sidebar .endpoint-summary:hover {
background: rgba(255, 255, 255, 0.085);
border-color: rgba(197, 204, 224, 0.35);
}
.sidebar .btn-secondary {
background: transparent;
border-color: var(--blue-300);
color: var(--neutral-0);
}
.sidebar .btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--blue-100);
}
.topbar {
background: rgba(255, 255, 255, 0.96);
border-color: var(--border-subtle);
min-height: 64px;
}
.topbar-title span {
color: var(--text-tertiary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
}
.topbar-title strong {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.page {
padding: var(--space-lg);
}
.page h1 {
color: var(--text-primary);
font-size: 25px;
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.2;
}
.panel,
.table-panel,
.dialog-panel,
.offline-banner,
.agent-status-button {
border-color: var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--elevation-1);
}
.panel,
.table-panel,
.dialog-panel {
background: var(--surface-base);
}
.panel-header,
.dialog-header,
.dialog-actions,
.table-wrap thead,
.page-filters {
border-color: var(--border-subtle);
}
.btn,
.icon-button,
input,
select,
textarea {
border-radius: var(--radius-md);
}
.btn {
font-size: 13px;
font-weight: 600;
}
.btn-primary {
background: var(--brand-primary);
color: var(--neutral-0);
}
.btn-primary:hover {
background: var(--brand-primary-strong);
}
.btn-warning {
background: var(--brand-accent);
color: var(--neutral-900);
}
.btn-warning:hover {
background: var(--brand-accent-strong);
color: var(--neutral-0);
}
.btn-secondary,
.icon-button {
background: var(--surface-base);
border-color: var(--border-subtle);
color: var(--text-secondary);
}
.btn-secondary:hover,
.icon-button:hover,
.icon-button.subtle:hover {
background: var(--blue-50);
border-color: var(--blue-200);
color: var(--brand-primary);
}
.settings-field input,
.filter-field input,
.filter-field select,
input,
select,
textarea {
background: var(--surface-base);
border-color: var(--border-strong);
color: var(--text-primary);
}
.settings-field input:focus,
.filter-field input:focus,
.filter-field select:focus,
input:focus,
select:focus,
textarea:focus {
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
}
table {
color: var(--text-primary);
}
thead {
background: var(--surface-brand-tint);
}
th {
color: var(--blue-700);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.045em;
}
td {
border-color: var(--neutral-200);
font-size: 13px;
}
tbody tr:hover,
.selected-row {
background: var(--blue-50);
}
.badge {
border-radius: var(--radius-sm);
font-weight: 600;
}
.badge-success {
background: var(--semantic-success-bg);
color: var(--semantic-success);
}
.badge-danger {
background: var(--semantic-error-bg);
color: var(--semantic-error);
}
.badge-warning {
background: var(--semantic-warning-bg);
color: var(--semantic-warning);
}
.badge-info,
.badge-primary {
background: var(--semantic-info-bg);
color: var(--semantic-info);
}
code,
.task-log-list {
background: var(--blue-900);
color: var(--neutral-100);
}
@media (max-width: 980px) {
.sidebar {
border-bottom-color: var(--neutral-700);
}
}
@media (max-width: 680px) {
.page {
padding: var(--space-md);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -8,6 +8,8 @@
"name": "robot-installer-web-server",
"version": "1.0.0",
"dependencies": {
"@fluentui/svg-icons": "^1.1.333",
"@fontsource-variable/montserrat": "^5.3.0",
"dotenv": "^17.4.2",
"ejs": "^3.1.10",
"express": "^4.19.2",
@@ -276,6 +278,21 @@
"node": ">=20"
}
},
"node_modules/@fluentui/svg-icons": {
"version": "1.1.333",
"resolved": "https://registry.npmjs.org/@fluentui/svg-icons/-/svg-icons-1.1.333.tgz",
"integrity": "sha512-uSFMDQq25/sVgHvD9KhP+c1rZrHWqIA0OZkofu9Ii3Q5b72DqLgqOreABHbPD67U5XfsFc4iPlZzWEl1849Obg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-7PaZoxaxrWLAyrhO46v65An9LhUhfkTExWLhfbywYZCnZEgg/W1rEHnlNmZKjNZ3nJTVYyicqxlJt10z/26yTA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@js-joda/core": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz",

View File

@@ -9,6 +9,8 @@
"dev": "node server.js"
},
"dependencies": {
"@fluentui/svg-icons": "^1.1.333",
"@fontsource-variable/montserrat": "^5.3.0",
"dotenv": "^17.4.2",
"ejs": "^3.1.10",
"express": "^4.19.2",

View File

@@ -1,28 +1,92 @@
:root {
--primary: #3755c3;
--primary-dim: #2848b7;
--primary-container: #dde1ff;
--on-primary: #f8f7ff;
--background: #f7f9fb;
--surface-lowest: #ffffff;
--surface-low: #f0f4f7;
--surface: #e8eff3;
--surface-high: #e1e9ee;
--on-surface: #2a3439;
--on-surface-variant: #566166;
--outline: #717c82;
--outline-variant: #a9b4b9;
--danger: #b42318;
--danger-bg: #fee4e2;
--success: #067647;
--success-bg: #dcfae6;
--warning: #b54708;
--warning-bg: #fef0c7;
--info: #175cd3;
--info-bg: #d1e9ff;
--radius: 8px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.18);
color-scheme: light;
/* Phenikaa-X universal tokens — design-language.md v0.4.4 */
--blue-50: #eaedf5;
--blue-100: #c5cce0;
--blue-200: #8c9ac4;
--blue-300: #6e7ca8;
--blue-500: #223771;
--blue-700: #1a2a57;
--blue-900: #1e2243;
--orange-100: #fcd9c4;
--orange-300: #f58220;
--orange-500: #f26522;
--orange-700: #c44e14;
--neutral-0: #ffffff;
--neutral-50: #f7f8fb;
--neutral-100: #eeedf6;
--neutral-200: #dddfea;
--neutral-300: #c5c8d8;
--neutral-400: #9fa3b8;
--neutral-450: #8c90a8;
--neutral-500: #757a91;
--neutral-600: #565b70;
--neutral-700: #3c4054;
--neutral-800: #272a3a;
--neutral-900: #161824;
--surface-base: var(--neutral-0);
--surface-raised: var(--neutral-50);
--surface-brand-tint: var(--neutral-100);
--text-primary: var(--neutral-900);
--text-secondary: var(--neutral-600);
--text-tertiary: var(--neutral-500);
--border-subtle: var(--neutral-300);
--border-strong: var(--neutral-450);
--brand-primary: var(--blue-500);
--brand-primary-strong: var(--blue-700);
--brand-accent: var(--orange-500);
--brand-accent-strong: var(--orange-700);
--semantic-success: #2e7d32;
--semantic-success-bg: #e8f5e9;
--semantic-warning: #9a5b00;
--semantic-warning-icon: #cb6119;
--semantic-warning-bg: #fff4db;
--semantic-error: #c62828;
--semantic-error-bg: #ffebee;
--semantic-info: #1565c0;
--semantic-info-bg: #e7f1fc;
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--elevation-1: 0 1px 3px rgba(22, 24, 36, 0.12);
--elevation-2: 0 4px 12px rgba(22, 24, 36, 0.18);
/* Compatibility aliases used by the existing components. */
--primary: var(--brand-primary);
--primary-dim: var(--brand-primary-strong);
--primary-container: var(--blue-50);
--on-primary: var(--neutral-0);
--background: var(--surface-raised);
--surface-lowest: var(--surface-base);
--surface-low: var(--surface-raised);
--surface: var(--surface-brand-tint);
--surface-high: var(--neutral-200);
--on-surface: var(--text-primary);
--on-surface-variant: var(--text-secondary);
--outline: var(--border-strong);
--outline-variant: var(--border-subtle);
--danger: var(--semantic-error);
--danger-bg: var(--semantic-error-bg);
--success: var(--semantic-success);
--success-bg: var(--semantic-success-bg);
--warning: var(--semantic-warning);
--warning-bg: var(--semantic-warning-bg);
--info: var(--semantic-info);
--info-bg: var(--semantic-info-bg);
--radius: var(--radius-md);
--shadow-sm: var(--elevation-1);
--shadow-lg: var(--elevation-2);
}
* {
@@ -39,8 +103,9 @@ body {
margin: 0;
background: var(--background);
color: var(--on-surface);
font-family: "Inter", Arial, sans-serif;
font-size: 14px;
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.5;
overflow: hidden;
}
@@ -48,7 +113,7 @@ h1,
h2,
h3,
.brand-copy strong {
font-family: "Manrope", Arial, sans-serif;
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
}
h1,
@@ -79,17 +144,13 @@ button:disabled {
opacity: 0.5;
}
.material-symbols-outlined {
direction: ltr;
.fluent-icon {
background: currentColor;
display: inline-flex;
font-family: "Material Symbols Outlined";
font-size: 1.25rem;
font-style: normal;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 20;
letter-spacing: 0;
line-height: 1;
text-transform: none;
white-space: nowrap;
flex: 0 0 auto;
height: 1.25rem;
mask: var(--fluent-icon-url) center / contain no-repeat;
width: 1.25rem;
}
.app-shell {
@@ -304,7 +365,7 @@ button:disabled {
.notification-panel-header strong {
color: #111827;
font-family: "Manrope", Arial, sans-serif;
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 15px;
}
@@ -394,8 +455,10 @@ button:disabled {
width: 34px;
}
.notification-item-icon .material-symbols-outlined {
font-size: 19px;
.notification-item-icon .material-symbols-outlined,
.notification-item-icon .fluent-icon {
height: 19px;
width: 19px;
}
.notification-item.tone-success .notification-item-icon {
@@ -468,9 +531,11 @@ button:disabled {
text-align: center;
}
.notification-empty .material-symbols-outlined {
.notification-empty .material-symbols-outlined,
.notification-empty .fluent-icon {
color: #94a3b8;
font-size: 32px;
height: 32px;
width: 32px;
}
.notification-empty strong {
@@ -705,7 +770,7 @@ button:disabled {
.metric-card strong {
color: #111827;
font-family: "Manrope", Arial, sans-serif;
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 28px;
font-weight: 800;
}
@@ -1208,9 +1273,11 @@ tbody tr:hover td.action-col {
text-align: center;
}
.file-dropzone-content > .material-symbols-outlined {
.file-dropzone-content > .material-symbols-outlined,
.file-dropzone-content > .fluent-icon {
color: var(--primary);
font-size: 34px;
height: 34px;
width: 34px;
}
.file-dropzone-content strong {
@@ -1237,7 +1304,8 @@ tbody tr:hover td.action-col {
padding: 10px;
}
.file-preview > .material-symbols-outlined {
.file-preview > .material-symbols-outlined,
.file-preview > .fluent-icon {
align-items: center;
background: var(--primary-container);
border-radius: var(--radius);
@@ -1798,3 +1866,323 @@ tbody tr:hover td.action-col {
width: 100%;
}
}
/* --------------------------------------------------------------------------
Phenikaa-X application theme
Keeps the existing EJS/JavaScript contract while applying Layer-1 tokens.
-------------------------------------------------------------------------- */
::selection {
background: var(--orange-100);
color: var(--text-primary);
}
:focus-visible {
outline: 2px solid var(--blue-500);
outline-offset: 2px;
}
.sidebar {
background: var(--blue-900);
border-color: var(--neutral-700);
}
.brand-block {
border-bottom: 1px solid rgba(197, 204, 224, 0.18);
min-height: 72px;
padding: var(--space-md);
}
.brand-mark {
background: var(--brand-accent);
border-radius: var(--radius-md);
box-shadow: none;
color: var(--neutral-0);
height: 40px;
width: 40px;
}
.brand-copy strong {
color: var(--neutral-0);
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-copy span,
.sidebar .nav-label,
.sidebar-copyright {
color: var(--blue-100);
}
.brand-copy span,
.nav-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
}
.nav-section {
gap: var(--space-xxs);
padding: var(--space-sm);
}
.nav-item {
border-left-width: 4px;
border-radius: 0 var(--radius-md) var(--radius-md) 0;
color: var(--blue-100);
font-size: 13px;
font-weight: 500;
gap: var(--space-sm);
min-height: 44px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--neutral-0);
}
.nav-item.active {
background: rgba(255, 255, 255, 0.12);
border-left-color: var(--brand-accent);
color: var(--neutral-0);
font-weight: 600;
}
.topbar {
background: rgba(255, 255, 255, 0.96);
border-color: var(--border-subtle);
min-height: 64px;
}
.page {
padding: var(--space-lg);
}
.page-header h1,
.auth-heading h1 {
color: var(--text-primary);
font-size: 25px;
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.2;
}
.panel,
.table-panel,
.metric-card,
.modal-content,
.notification-panel,
.empty-state,
.auth-panel {
background: var(--surface-base);
border-color: var(--border-subtle);
border-radius: var(--radius-md);
box-shadow: var(--elevation-1);
}
.modal-content,
.notification-panel,
.auth-panel {
box-shadow: var(--elevation-2);
}
.panel-header,
.modal-header,
.modal-actions,
.page-filters,
thead,
td {
border-color: var(--border-subtle);
}
.btn,
.icon-button,
input,
select,
textarea {
border-radius: var(--radius-md);
}
.btn {
font-size: 13px;
font-weight: 600;
}
.btn-primary {
background: var(--brand-primary);
color: var(--neutral-0);
}
.btn-primary:hover {
background: var(--brand-primary-strong);
}
.btn-warning {
background: var(--brand-accent);
color: var(--neutral-900);
}
.btn-warning:hover {
background: var(--brand-accent-strong);
color: var(--neutral-0);
}
.btn-secondary,
.icon-button {
background: var(--surface-base);
border-color: var(--border-subtle);
color: var(--text-secondary);
}
.btn-secondary:hover,
.icon-button:hover,
.icon-button.subtle:hover {
background: var(--blue-50);
border-color: var(--blue-200);
color: var(--brand-primary);
}
.form-field input,
.form-field select,
.form-field textarea,
.filter-field input,
.filter-field select,
input,
select,
textarea {
background: var(--surface-base);
border-color: var(--border-strong);
color: var(--text-primary);
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus,
.filter-field input:focus,
.filter-field select:focus,
input:focus,
select:focus,
textarea:focus {
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px rgba(34, 55, 113, 0.16);
}
thead {
background: var(--surface-brand-tint);
}
th {
color: var(--blue-700);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.045em;
}
td {
font-size: 13px;
}
tbody tr:hover {
background: var(--blue-50);
}
.badge {
border-radius: var(--radius-sm);
font-weight: 600;
}
.badge-success {
background: var(--semantic-success-bg);
color: var(--semantic-success);
}
.badge-danger {
background: var(--semantic-error-bg);
color: var(--semantic-error);
}
.badge-warning {
background: var(--semantic-warning-bg);
color: var(--semantic-warning);
}
.badge-info,
.badge-primary {
background: var(--semantic-info-bg);
color: var(--semantic-info);
}
.empty-state > .fluent-icon {
color: var(--brand-primary);
height: 42px;
width: 42px;
}
.error-state > .fluent-icon {
color: var(--semantic-error);
}
.auth-shell {
background:
linear-gradient(135deg, rgba(30, 34, 67, 0.98), rgba(34, 55, 113, 0.93) 42%, rgba(247, 248, 251, 0.98) 42.1%),
var(--surface-raised);
}
.auth-panel {
border-top: 4px solid var(--brand-accent);
gap: var(--space-lg);
padding: var(--space-lg);
}
.auth-brand .brand-copy strong {
color: var(--text-primary);
}
.auth-brand .brand-copy span {
color: var(--text-tertiary);
}
.auth-confirm-icon {
background: var(--blue-50);
border-radius: var(--radius-md);
color: var(--brand-primary);
}
.auth-confirm-icon .fluent-icon {
height: 28px;
width: 28px;
}
.toast {
background: var(--blue-900);
border-radius: var(--radius-md);
box-shadow: var(--elevation-2);
}
@media (max-width: 900px) {
.sidebar {
box-shadow: var(--elevation-2);
}
}
@media (max-width: 560px) {
.page {
padding: var(--space-md);
}
.auth-page {
padding: var(--space-md);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -2,6 +2,64 @@
const body = document.body;
const menuButton = document.getElementById('mobileMenuBtn');
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
const FLUENT_ICON_FILES = Object.freeze({
add: 'add_20_regular.svg',
add_box: 'box_24_regular.svg',
apps: 'apps_24_regular.svg',
archive: 'archive_24_regular.svg',
attach_file: 'attach_20_regular.svg',
campaign: 'megaphone_20_regular.svg',
check_circle: 'checkmark_circle_20_filled.svg',
close: 'dismiss_20_regular.svg',
dashboard: 'grid_24_regular.svg',
database_off: 'database_warning_20_regular.svg',
delete: 'delete_20_regular.svg',
deployed_code: 'cube_24_regular.svg',
download: 'arrow_download_20_regular.svg',
draft: 'document_24_regular.svg',
edit: 'edit_20_regular.svg',
error: 'error_circle_20_filled.svg',
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
group: 'people_24_regular.svg',
inventory_2: 'box_24_regular.svg',
link_off: 'link_dismiss_20_regular.svg',
login: 'arrow_enter_20_regular.svg',
logout: 'sign_out_20_regular.svg',
mark_email_unread: 'mail_unread_24_regular.svg',
memory: 'memory_16_regular.svg',
menu: 'navigation_20_regular.svg',
notifications: 'alert_20_regular.svg',
notifications_none: 'alert_off_20_regular.svg',
person_add: 'person_add_20_regular.svg',
precision_manufacturing: 'bot_24_filled.svg',
save: 'save_20_regular.svg',
search_off: 'search_24_regular.svg',
send: 'send_20_regular.svg',
stars: 'star_20_filled.svg',
swap_horiz: 'arrow_swap_20_regular.svg',
sync: 'arrow_sync_20_regular.svg',
upgrade: 'arrow_up_20_regular.svg',
upload: 'arrow_upload_20_regular.svg',
upload_file: 'document_arrow_up_24_regular.svg',
visibility: 'eye_20_regular.svg',
warning: 'warning_20_filled.svg'
});
function applyFluentIcon(element, iconName) {
if (!element) return;
const fileName = FLUENT_ICON_FILES[iconName] || 'question_20_regular.svg';
element.classList.remove('material-symbols-outlined');
element.classList.add('fluent-icon');
element.style.setProperty('--fluent-icon-url', `url("/vendor/fluent-icons/${fileName}")`);
element.setAttribute('aria-hidden', 'true');
element.textContent = '';
}
function hydrateFluentIcons(root) {
root.querySelectorAll('.material-symbols-outlined').forEach((element) => {
applyFluentIcon(element, element.textContent.trim());
});
}
function initNotiflix() {
if (!window.Notiflix) return;
@@ -11,8 +69,8 @@
position: 'right-top',
distance: '16px',
timeout: 2600,
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
borderRadius: '4px',
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
fontSize: '13px',
messageMaxLength: 180,
clickToClose: true,
@@ -21,35 +79,35 @@
useIcon: true,
zindex: 5000,
success: {
background: '#067647',
background: '#2e7d32',
textColor: '#ffffff'
},
failure: {
background: '#b42318',
background: '#c62828',
textColor: '#ffffff'
},
warning: {
background: '#b54708',
background: '#9a5b00',
textColor: '#ffffff'
},
info: {
background: '#3755c3',
background: '#223771',
textColor: '#ffffff'
}
});
window.Notiflix.Confirm.init({
width: '360px',
borderRadius: '8px',
fontFamily: 'Inter, sans-serif',
titleColor: '#111827',
borderRadius: '4px',
fontFamily: 'Montserrat Variable, Montserrat, sans-serif',
titleColor: '#161824',
titleFontSize: '16px',
messageColor: '#475569',
messageColor: '#565b70',
messageFontSize: '13px',
okButtonBackground: '#3755c3',
okButtonBackground: '#223771',
okButtonColor: '#ffffff',
cancelButtonBackground: '#e2e8f0',
cancelButtonColor: '#334155',
cancelButtonBackground: '#eeedf6',
cancelButtonColor: '#3c4054',
backOverlayColor: 'rgba(15, 23, 42, 0.42)',
zindex: 5001,
cssAnimationStyle: 'zoom'
@@ -701,9 +759,7 @@
const icon = document.createElement('span');
icon.className = 'notification-item-icon';
const iconGlyph = document.createElement('span');
iconGlyph.className = 'material-symbols-outlined';
iconGlyph.setAttribute('aria-hidden', 'true');
iconGlyph.textContent = getNotificationIcon(notification);
applyFluentIcon(iconGlyph, getNotificationIcon(notification));
icon.appendChild(iconGlyph);
const copy = document.createElement('span');
@@ -926,6 +982,7 @@
});
}
hydrateFluentIcons(document);
initNotiflix();
initFileDropzones();
initRegistrationUniqueChecks();

View File

@@ -127,6 +127,8 @@ app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '32kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
app.use('/vendor/montserrat', express.static(path.join(__dirname, 'node_modules/@fontsource-variable/montserrat')));
app.use('/vendor/fluent-icons', express.static(path.join(__dirname, 'node_modules/@fluentui/svg-icons/icons')));
app.use(applyPublicApiCors);
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
const arch = normalizeAgentArch(req.query.arch);

View File

@@ -4,10 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/montserrat/index.css">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>

View File

@@ -4,10 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/montserrat/index.css">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>

View File

@@ -4,10 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= title %> | Robot Installer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/vendor/montserrat/index.css">
<link rel="stylesheet" href="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.css">
<link rel="stylesheet" href="/css/styles.css">
</head>