Initial SFERA platform baseline
@@ -0,0 +1,71 @@
|
||||
# SFERA Web
|
||||
|
||||
Next.js frontend for the SFERA 1C semantic workspace.
|
||||
|
||||
## Run On This NAS Workspace
|
||||
|
||||
Use the mapped `R:` drive. It points to `\\nas\MST` and avoids Windows tooling issues with UNC current directories.
|
||||
|
||||
```bat
|
||||
cd /d R:\codex\SFERA\frontend\sfera-web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Do not run `npm` from the UNC path `\\nas\MST\...`. Windows starts `cmd.exe` in `C:\Windows` for UNC current directories, so package scripts cannot find `package.json`.
|
||||
|
||||
The app resolves the API endpoint in this order:
|
||||
|
||||
1. `SFERA_API_URL`
|
||||
2. `NEXT_PUBLIC_SFERA_API_URL`
|
||||
3. the current panel host with API port `8000`
|
||||
|
||||
Examples:
|
||||
|
||||
- `http://192.168.200.60:3000` -> `http://192.168.200.60:8000`
|
||||
- `http://docker-test.cin.su:3000` -> `http://docker-test.cin.su:8000`
|
||||
- `http://127.0.0.1:3001` -> `http://localhost:8000`
|
||||
|
||||
If the stand moves to another API port, set:
|
||||
|
||||
```text
|
||||
SFERA_API_URL=http://api-host:8080
|
||||
```
|
||||
|
||||
You can also override only the derived port:
|
||||
|
||||
```text
|
||||
SFERA_API_PORT=8080
|
||||
```
|
||||
|
||||
## Checks
|
||||
|
||||
```bat
|
||||
cd /d R:\codex\SFERA\frontend\sfera-web
|
||||
node_modules\.bin\tsc.cmd -p tsconfig.json --noEmit
|
||||
node_modules\.bin\next.cmd build
|
||||
npm run smoke:editor
|
||||
npm run smoke:editor:runtime
|
||||
npm run smoke:project-setup
|
||||
```
|
||||
|
||||
`npm run smoke:editor` checks the running stand URL. By default it uses `http://192.168.200.60:3000` and verifies that the overview stays a dashboard while editor modes open directly:
|
||||
|
||||
- `mode=module`
|
||||
- `mode=form`
|
||||
- `mode=events`
|
||||
- `mode=learning`
|
||||
|
||||
Use `SFERA_WEB_URL` to point the smoke check at another host or port. The HTML smoke retries each page briefly while a freshly started stand settles.
|
||||
|
||||
`npm run smoke:editor:runtime` opens the running editor in an installed Chrome/Edge through `playwright-core`. It catches browser runtime failures such as Monaco `Runtime TypeError`, verifies that Monaco renders in `mode=module`, opens `mode=versions`, clicks a version row, checks full version payload details, and exercises metadata draft preview/apply through the API.
|
||||
|
||||
`npm run smoke:project-setup` checks the new Project Setup / Import Center flow. It prepares the `default` project through the web API proxy, verifies that the indexed Project IDE Shell opens, and exercises metadata draft preview/save to workspace history.
|
||||
|
||||
By default the runtime smoke uses the installed Chrome/Edge paths on this Windows stand. Override when needed:
|
||||
|
||||
```text
|
||||
SFERA_BROWSER_PATH=C:\Path\To\chrome.exe
|
||||
SFERA_WEB_URL=http://docker-test.cin.su:3000
|
||||
```
|
||||
|
||||
Use `npm run smoke:editor:all` to run both the HTML smoke and browser runtime smoke. Browser-side editor actions require API CORS for the panel origin; the API allows LAN panel origins, localhost, and `docker-test.cin.su` by default. The Project Setup smoke uses the Next.js `/api/sfera/...` proxy and therefore also verifies frontend-to-backend routing.
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "sfera-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --hostname 0.0.0.0 --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start --hostname 0.0.0.0 --port 3000",
|
||||
"lint": "next lint",
|
||||
"smoke:editor": "node scripts/smoke-editor-modes.mjs",
|
||||
"smoke:editor:all": "npm run smoke:editor && npm run smoke:editor:runtime",
|
||||
"smoke:editor:runtime": "node scripts/smoke-editor-runtime.mjs",
|
||||
"smoke:project-setup": "node scripts/smoke-project-setup.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^15.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "^15.0.4",
|
||||
"playwright-core": "^1.59.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,25 @@
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
Copyright © 2026 Andrew Ponomarev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the “Software”), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,9 @@
|
||||
1C-like metadata icons
|
||||
======================
|
||||
|
||||
Source: MetadataViewer1C by Andrew Ponomarev
|
||||
Repository: https://github.com/asweetand-a11y/MetadataViewer1C
|
||||
License: MIT, see LICENSE.MetadataViewer1C.md
|
||||
|
||||
These SVGs are used as a visually similar open-source icon set for SFERA's 1C metadata tree.
|
||||
They are not extracted from the 1C:Enterprise Configurator executable or distribution.
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M20.95 31.95 35.4 17.5l-2.15-2.15-12.3 12.3L15 21.7l-2.15 2.15ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24.5 21.7h3v-4.2h4.2v-3h-4.2v-4.2h-3v4.2h-4.2v3h4.2Zm-4.2 7.8h11.4v-3H20.3ZM13 40q-1.2 0-2.1-.9-.9-.9-.9-2.1V5q0-1.2.9-2.1.9-.9 2.1-.9h17.4L42 13.6V37q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h26V14.9L28.9 5H13v32Zm-6 9q-1.2 0-2.1-.9Q4 44.2 4 43V12.05h3V43h24.9v3Zm6-9V5v32Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M32 22q.85 0 1.425-.575Q34 20.85 34 20q0-.85-.575-1.425Q32.85 18 32 18q-.85 0-1.425.575Q30 19.15 30 20q0 .85.575 1.425Q31.15 22 32 22Zm-16-5h10v-3H16ZM9 42q-1.7-5.7-3.35-11.375Q4 24.95 4 19q0-4.6 3.2-7.8T15 8h10q1.45-1.9 3.525-2.95Q30.6 4 33 4q1.25 0 2.125.875T36 7q0 .3-.075.6t-.175.55q-.2.55-.375 1.125T35.1 10.45L39.65 15H44v13.95l-5.65 1.85L35 42H24v-4h-4v4Zm2.25-3H17v-4h10v4h5.75l3.15-10.5 5.1-1.75V18h-2.6L32 11.6q.05-1.25.325-2.425Q32.6 8 32.9 6.8q-1.9.5-3.6 1.475-1.7.975-2.6 2.725H15q-3.3 0-5.65 2.35Q7 15.7 7 19q0 5.15 1.45 10.075Q9.9 34 11.25 39ZM24 22.9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 811 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M8 25.5v-3h32v3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M31 44v-4.75L15.5 31.5H6v-11h8.75l6.25-7V4h11v11h-8.3L17 22.5v6.45l14 6.95V33h11v11Zm-7-32h5V7h-5ZM9 28.5h5v-5H9ZM34 41h5v-5h-5ZM26.5 9.5ZM11.5 26Zm25 12.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 400 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M15.7 36.6h2.5v-4.4h4.4v-2.5h-4.4v-4.4h-2.5v4.4h-4.4v2.5h4.4Zm10.75-1.75H36.5V32.4H26.45Zm0-5.35H36.5V27H26.45Zm1.85-8.15 3.05-3.05 3.05 3.05 1.8-1.8-3.05-3.05 3.05-3.05-1.8-1.8-3.05 3.05-3.05-3.05-1.8 1.8 3.05 3.05-3.05 3.05Zm-16.25-3.6h9.8v-2.5h-9.8ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M6 42V6h36v36Zm3-25h30V9H9Zm11 11h8v-8h-8Zm0 11h8v-8h-8ZM9 28h8v-8H9Zm22 0h8v-8h-8ZM9 39h8v-8H9Zm22 0h8v-8h-8Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M14.5 40V13H4V8h26v5H19.5v27Zm18 0V23H26v-5h18v5h-6.5v17Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="m24 41.5-18-14 2.5-1.85L24 37.7l15.5-12.05L42 27.5Zm0-7.6-18-14 18-14 18 14Zm0-15.05Zm0 11.25 13.1-10.2L24 9.7 10.9 19.9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M15 33.7q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm0-8.2q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm0-8.2q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm6.6 16.4h12.2v-3H21.6Zm0-8.2h12.2v-3H21.6Zm0-8.2h12.2v-3H21.6ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 811 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M15.35 16.6q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm8.65 0q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm8.65 0q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 753 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M19.15 32.5 32.5 24l-13.35-8.5ZM24 44q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.15 1.575-7.8 1.575-3.65 4.3-6.35 2.725-2.7 6.375-4.275Q19.9 4 24 4q4.15 0 7.8 1.575 3.65 1.575 6.35 4.275 2.7 2.7 4.275 6.35Q44 19.85 44 24q0 4.1-1.575 7.75-1.575 3.65-4.275 6.375t-6.35 4.3Q28.15 44 24 44Zm0-3q7.1 0 12.05-4.975Q41 31.05 41 24q0-7.1-4.95-12.05Q31.1 7 24 7q-7.05 0-12.025 4.95Q7 16.9 7 24q0 7.05 4.975 12.025Q16.95 41 24 41Zm0-17Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 704 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M13.4 35.5q-3.1 0-5.25-2.15T6 28.1q0-3.1 2.15-5.25t5.25-2.15q3.1 0 5.25 2.15t2.15 5.25q0 3.1-2.15 5.25T13.4 35.5Zm0-3q1.85 0 3.125-1.275T17.8 28.1q0-1.85-1.275-3.125T13.4 23.7q-1.85 0-3.125 1.275T9 28.1q0 1.85 1.275 3.125T13.4 32.5Zm8.45-16.7q1.9 0 3.15-1.25t1.25-3.15q0-1.9-1.25-3.15T21.85 7q-1.9 0-3.15 1.25t-1.25 3.15q0 1.9 1.25 3.15t3.15 1.25Zm0 3q-3.1 0-5.25-2.15t-2.15-5.25q0-3.1 2.15-5.25T21.85 4q3.1 0 5.25 2.15t2.15 5.25q0 3.1-2.15 5.25t-5.25 2.15ZM34.6 39q1.9 0 3.15-1.25T39 34.6q0-1.9-1.25-3.15T34.6 30.2q-1.9 0-3.15 1.25T30.2 34.6q0 1.9 1.25 3.15T34.6 39Zm0 3q-3.1 0-5.25-2.15T27.2 34.6q0-3.1 2.15-5.25t5.25-2.15q3.1 0 5.25 2.15T42 34.6q0 3.1-2.15 5.25T34.6 42ZM21.85 11.4ZM13.4 28.1Zm21.2 6.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 950 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M13.85 34.05H27.6v-3H13.85Zm0-8.55h20.3v-3h-20.3Zm0-8.55h20.3v-3h-20.3ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M6 6h16.5v16.5H6Zm3 2.95v9.4ZM25.5 6H42v16.5H25.5Zm4.15 2.95v9.4ZM6 25.5h16.5V42H6Zm3 4.05V39Zm23.25-4.05h3v6.75H42v3h-6.75V42h-3v-6.75H25.5v-3h6.75ZM28.5 9v10.5H39V9ZM9 9v10.5h10.5V9Zm0 19.5V39h10.5V28.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 449 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="m14 42-2.15-2.15 4.3-4.3q-5.4.35-9.775-3.525T2 22q0-5.85 4.075-9.925Q10.15 8 16 8h6v3h-6q-4.55 0-7.775 3.225Q5 17.45 5 22q0 4.7 3.275 7.7t8.025 2.9l-4.45-4.45L14 26l8 8Zm12-2V26h18v14Zm0-18V8h18v14Zm3-3h12v-8H29Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 456 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M7 36q-1.2 0-2.1-.9Q4 34.2 4 33V15q0-1.15.9-2.075Q5.8 12 7 12h34q1.2 0 2.1.925.9.925.9 2.075v18q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h34V15h-6.5v9h-3v-9h-6v9h-3v-9h-6v9h-3v-9H7v18Zm6.5-9h3Zm9 0h3Zm9 0h3ZM24 24Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style><path class="icon-canvas-transparent" d="M16 16H0V0h16v16z" id="canvas"/><path class="icon-vs-out" d="M15 16H2V0h8.621L15 4.379V16z" id="outline"/><path class="icon-vs-fg" d="M13 14H4V2h5v4h4v8zm-3-9V2.207L12.793 5H10z" id="iconFg"/><path class="icon-vs-bg" d="M3 1v14h11V4.793L10.207 1H3zm10 13H4V2h5v4h4v8zm-3-9V2.207L12.793 5H10z" id="iconBg"/></svg>
|
||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M28.05 31V20h3.75l3.25 3.25V31Zm-3 3h13V22l-5-5h-8Zm-18 6q-1.2 0-2.1-.925-.9-.925-.9-2.075V11q0-1.15.9-2.075Q5.85 8 7.05 8h14l3 3h17q1.15 0 2.075.925.925.925.925 2.075v23q0 1.15-.925 2.075Q42.2 40 41.05 40Zm0-29v26h34V14H22.8l-3-3H7.05Zm0 0v26Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 -960 960 960">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M120-80v-60h100v-30h-60v-60h60v-30H120v-60h160v90l-30 30 30 30v90H120Zm0-280v-150h100v-30H120v-60h160v150H180v30h100v60H120Zm60-280v-180h-60v-60h120v240h-60Zm189 431v-60h471v60H369Zm0-243v-60h471v60H369Zm0-243v-60h471v60H369Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24 42v-3.55l10.8-10.8 3.55 3.55L27.55 42ZM6 31.5v-3h15v3Zm34.5-2.45-3.55-3.55 1.45-1.45q.4-.4 1.05-.4t1.05.4l1.45 1.45q.4.4.4 1.05t-.4 1.05ZM6 23.25v-3h23.5v3ZM6 15v-3h23.5v3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M11 44q-1.2 0-2.1-.9Q8 42.2 8 41V7q0-1.2.9-2.1Q9.8 4 11 4h17l12 12v7.8h-3V18H26V7H11v34h15v3Zm0-3V7v34Zm26.8-11.15 1.4 1.4-8.2 8.2V42h2.55l8.2-8.2 1.4 1.4-8.8 8.8H29v-5.35Zm5.35 5.35-5.35-5.35 3.05-3.05q.45-.45 1.05-.45.6 0 1.05.45l3.25 3.25q.45.45.45 1.05 0 .6-.45 1.05Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 515 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M28.5 40v-3h6q1.05 0 1.775-.725Q37 35.55 37 34.5v-5q0-1.85 1.125-3.3 1.125-1.45 2.875-2v-.4q-1.75-.5-2.875-1.975T37 18.5v-5q0-1.05-.725-1.775Q35.55 11 34.5 11h-6V8h6q2.3 0 3.9 1.6t1.6 3.9v5q0 1.05.725 1.775Q41.45 21 42.5 21H44v6h-1.5q-1.05 0-1.775.725Q40 28.45 40 29.5v5q0 2.3-1.6 3.9T34.5 40Zm-15 0q-2.3 0-3.9-1.6T8 34.5v-5q0-1.05-.725-1.775Q6.55 27 5.5 27H4v-6h1.5q1.05 0 1.775-.725Q8 19.55 8 18.5v-5q0-2.3 1.6-3.9T13.5 8h6v3h-6q-1.05 0-1.775.725Q11 12.45 11 13.5v5q0 1.85-1.125 3.325T7 23.8v.4q1.75.55 2.875 2T11 29.5v5q0 1.05.725 1.775Q12.45 37 13.5 37h6v3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M19.95 42 22 27.9h-7.3q-.55 0-.8-.5t0-.95L26.15 6h2.05l-2.05 14.05h7.2q.55 0 .825.5.275.5.025.95L22 42Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M8.35 40v-3h6.5l-.75-.6q-3.2-2.55-4.65-5.55-1.45-3-1.45-6.7 0-5.3 3.125-9.525Q14.25 10.4 19.35 8.8v3.1q-3.75 1.45-6.05 4.825T11 24.15q0 3.15 1.175 5.475 1.175 2.325 3.175 4.025l1.5 1.05v-6.2h3V40Zm20.35-.75V36.1q3.8-1.45 6.05-4.825T37 23.85q0-2.4-1.175-4.875T32.75 14.6l-1.45-1.3v6.2h-3V8h11.5v3h-6.55l.75.7q3 2.8 4.5 6t1.5 6.15q0 5.3-3.1 9.55-3.1 4.25-8.2 5.85Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 606 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="m24.5 28.85 11.4-11.4-2.1-2.05-9.25 9.3-4.85-4.85-2.1 2.1ZM13 38q-1.2 0-2.1-.9-.9-.9-.9-2.1V7q0-1.2.9-2.1.9-.9 2.1-.9h28q1.2 0 2.1.9.9.9.9 2.1v28q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h28V7H13v28Zm-6 9q-1.2 0-2.1-.9Q4 42.2 4 41V10h3v31h31v3Zm6-37v28V7Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24 22q-8.05 0-13.025-2.45T6 14q0-3.15 4.975-5.575Q15.95 6 24 6t13.025 2.425Q42 10.85 42 14q0 3.1-4.975 5.55Q32.05 22 24 22Zm0 10q-7.3 0-12.65-2.2Q6 27.6 6 24.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 27 24 27q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 21.5 42 19.5v5q0 3.1-5.35 5.3Q31.3 32 24 32Zm0 10q-7.3 0-12.65-2.2Q6 37.6 6 34.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 37 24 37q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 31.5 42 29.5v5q0 3.1-5.35 5.3Q31.3 42 24 42Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 780 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M22 40q-.85 0-1.425-.575Q20 38.85 20 38V26L8.05 10.75q-.7-.85-.2-1.8Q8.35 8 9.4 8h29.2q1.05 0 1.55.95t-.2 1.8L28 26v12q0 .85-.575 1.425Q26.85 40 26 40Zm2-13.8L36 11H12Zm0 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M7.05 40q-1.2 0-2.1-.925-.9-.925-.9-2.075V11q0-1.15.9-2.075Q5.85 8 7.05 8h14l3 3h17q1.15 0 2.075.925.925.925.925 2.075v23q0 1.15-.925 2.075Q42.2 40 41.05 40Zm0-29v26h34V14H22.8l-3-3H7.05Zm0 0v26Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M8 38v-3h19.3v3Zm0-8.3v-3h32v3Zm0-8.35v-3h32v3ZM8 13v-3h32v3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24 44q-4.2 0-7.85-1.575Q12.5 40.85 9.8 38.15q-2.7-2.7-4.25-6.375Q4 28.1 4 23.9t1.55-7.825Q7.1 12.45 9.8 9.75t6.35-4.225Q19.8 4 24 4q4.2 0 7.85 1.525Q35.5 7.05 38.2 9.75q2.7 2.7 4.25 6.325Q44 19.7 44 23.9t-1.55 7.875Q40.9 35.45 38.2 38.15t-6.35 4.275Q28.2 44 24 44Zm0-2.9q1.75-1.8 2.925-4.125Q28.1 34.65 28.85 31.45H19.2q.7 3 1.875 5.4Q22.25 39.25 24 41.1Zm-4.25-.6q-1.25-1.9-2.15-4.1-.9-2.2-1.5-4.95H8.6Q10.5 35 13 37.025q2.5 2.025 6.75 3.475Zm8.55-.05q3.6-1.15 6.475-3.45 2.875-2.3 4.625-5.55h-7.45q-.65 2.7-1.525 4.9-.875 2.2-2.125 4.1Zm-20.7-12h7.95q-.15-1.35-.175-2.425-.025-1.075-.025-2.125 0-1.25.05-2.225.05-.975.2-2.175h-8q-.35 1.2-.475 2.15T7 23.9q0 1.3.125 2.325.125 1.025.475 2.225Zm11.05 0H29.4q.2-1.55.25-2.525.05-.975.05-2.025 0-1-.05-1.925T29.4 19.5H18.65q-.2 1.55-.25 2.475-.05.925-.05 1.925 0 1.05.05 2.025.05.975.25 2.525Zm13.75 0h8q.35-1.2.475-2.225Q41 25.2 41 23.9q0-1.3-.125-2.25T40.4 19.5h-7.95q.15 1.75.2 2.675.05.925.05 1.725 0 1.1-.075 2.075-.075.975-.225 2.475Zm-.5-11.95h7.5q-1.65-3.45-4.525-5.75Q32 8.45 28.25 7.5q1.25 1.85 2.125 4t1.525 5Zm-12.7 0h9.7q-.55-2.65-1.85-5.125T24 7q-1.6 1.35-2.7 3.55-1.1 2.2-2.1 5.95Zm-10.6 0h7.55q.55-2.7 1.4-4.825.85-2.125 2.15-4.125-3.75.95-6.55 3.2T8.6 16.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M4 39.5v-31h40v31Zm3-21.65h6.25V11.5H7Zm9.25 0h6.25V11.5h-6.25Zm9.25 0h6.25V11.5H25.5Zm9.25 0H41V11.5h-6.25Zm0 9.35H41v-6.35h-6.25Zm-9.25 0h6.25v-6.35H25.5Zm-9.25 0h6.25v-6.35h-6.25Zm-3-6.35H7v6.35h6.25Zm21.5 15.65H41v-6.3h-6.25Zm-9.25 0h6.25v-6.3H25.5Zm-9.25 0h6.25v-6.3h-6.25ZM7 36.5h6.25v-6.3H7Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M12.55 40q-4.4 0-7.475-3.075Q2 33.85 2 29.45q0-3.9 2.5-6.85 2.5-2.95 6.35-3.55 1-4.85 4.7-7.925T24.1 8.05q5.6 0 9.45 4.075Q37.4 16.2 37.4 21.9v1.2q3.6-.1 6.1 2.325Q46 27.85 46 31.55q0 3.45-2.5 5.95T37.55 40Zm0-3h25q2.25 0 3.85-1.6t1.6-3.85q0-2.25-1.6-3.85t-3.85-1.6H34.4v-4.2q0-4.55-3.05-7.7-3.05-3.15-7.45-3.15t-7.475 3.15q-3.075 3.15-3.075 7.7h-.95q-3.1 0-5.25 2.175T5 29.45q0 3.15 2.2 5.35Q9.4 37 12.55 37ZM33 15V4h11v11Zm5-5h5V5h-5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 680 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M34 36q-4.95 0-8.475-3.525Q22 28.95 22 24q0-4.95 3.525-8.475Q29.05 12 34 12q4.95 0 8.475 3.525Q46 19.05 46 24q0 4.95-3.525 8.475Q38.95 36 34 36Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm40-80h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M8 40V18h7v22Zm12.5 0V8h7v32ZM33 40V26h7v14Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="m22.4 39.65-11-6.45q-.7-.4-1.075-1.1-.375-.7-.375-1.5V17.75q0-.8.375-1.5t1.075-1.1L22.45 8.6q.7-.4 1.55-.4.85 0 1.55.4l11.05 6.55q.7.4 1.075 1.1.375.7.375 1.5V30.6q0 .8-.4 1.5t-1.1 1.1L25.4 39.65q-.7.4-1.5.4t-1.5-.4Zm.1-3.45V25L13 19.55V30.5Zm3 0 9.55-5.7V19.55L25.5 25ZM2 11.45V7.6q0-2.35 1.625-3.975T7.6 2h3.85v3H7.6q-1.1 0-1.85.75T5 7.6v3.85ZM7.6 46q-2.35 0-3.975-1.625T2 40.4v-3.85h3v3.85q0 1.1.75 1.85T7.6 43h3.85v3Zm28.95-.2v-3h3.85q1.1 0 1.85-.75T43 40.2v-3.85h3v3.85q0 2.35-1.625 3.975T40.4 45.8ZM43 11.45V7.6q0-1.1-.75-1.85T40.4 5h-3.85V2h3.85q2.35 0 3.975 1.625T46 7.6v3.85ZM24 22.3l9.5-5.5-9.5-5.45-9.5 5.45Zm0 1.25Zm0-1.25Zm1.5 2.7Zm-3 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 894 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M14 27.4q-1.4 0-2.4-1t-1-2.4q0-1.4 1-2.4t2.4-1q1.4 0 2.4 1t1 2.4q0 1.4-1 2.4t-2.4 1Zm0 8.6q-5 0-8.5-3.5T2 24q0-5 3.5-8.5T14 12q3.6 0 6.3 1.7 2.7 1.7 4.25 5.15h17.8L48 24.5l-8.35 7.65-4.4-3.2-4.4 3.2-3.75-3h-2.55q-1.25 3-3.925 4.925Q17.95 36 14 36Zm0-3q2.9 0 5.35-1.925 2.45-1.925 3.15-4.925h5.7l2.7 2.25 4.4-3.15 4.1 3.1 4.25-3.95-2.55-2.55H22.5q-.6-2.8-3-4.825Q17.1 15 14 15q-3.75 0-6.375 2.625T5 24q0 3.75 2.625 6.375T14 33Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 670 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24 38q5.85 0 9.925-4.075Q38 29.85 38 24q0-5.85-4.075-9.925Q29.85 10 24 10v14l-9.55 10q1.85 1.95 4.35 2.975Q21.3 38 24 38Zm0 6q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.15 1.575-7.8 1.575-3.65 4.3-6.35 2.725-2.7 6.375-4.275Q19.9 4 24 4q4.15 0 7.8 1.575 3.65 1.575 6.35 4.275 2.7 2.7 4.275 6.35Q44 19.85 44 24q0 4.1-1.575 7.75-1.575 3.65-4.275 6.375t-6.35 4.3Q28.15 44 24 44Zm0-3q7.1 0 12.05-4.975Q41 31.05 41 24q0-7.1-4.95-12.05Q31.1 7 24 7q-7.05 0-12.025 4.95Q7 16.9 7 24q0 7.05 4.975 12.025Q16.95 41 24 41Zm0-17Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 -960 960 960">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M120-280v-60h560v60H120Zm80-170v-60h560v60H200Zm80-170v-60h560v60H280Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24 29.25 18.75 24 24 18.75 29.25 24Zm-4.25-14.7L15.6 10.4 24 2l8.4 8.4-4.15 4.15L24 10.3ZM10.4 32.4 2 24l8.4-8.4 4.15 4.15L10.3 24l4.25 4.25Zm27.2 0-4.15-4.15L37.7 24l-4.25-4.25 4.15-4.15L46 24ZM24 46l-8.4-8.4 4.15-4.15L24 37.7l4.25-4.25 4.15 4.15Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M603-160h56v-83l102-24q21-5 43.5-11.5T827-299q0-8-8-14.5T792-326q-38-11-58-34t-18-49q2-21 19.5-37.5T785-474q28-10 41.5-22.5T840-526q0-20-17-30.5t-37-3.5q-6 2-12.5 3t-12.5 1q-26 0-43.5-20T700-625q0-26 13.5-47.5T727-720q0-17-14.5-28.5T677-760q-29 0-40.5 12.5T625-700q0 17 1.5 33t1.5 33q0 45-16 66t-50 21q-11 0-23-3t-23-3q-6 0-9.5 2.5T503-543q0 10 12 20t23 19q26 23 38 44t12 42q0 27-19.5 46T521-353q-11 0-21-2t-21-3q-28-2-43 5t-15 21q0 14 13 23t38 13l131 21v115Zm-450 40-73-33 200-440 73 33-200 440Zm370 40v-128l-57-8q-55-8-89.5-39.5T342-329q0-48 39.5-80.5T476-439q5 0 8.5.5t7.5 1.5q-8-8-15-14.5T464-465q-20-22-29-40.5t-9-37.5q0-38 21.5-62.5T504-630q7 0 19 .5t26 2.5q2-20 0-41t-2-41q0-63 34-97t97-34q56 0 93.5 32t36.5 78q0 23-8 45t-18 43q5-1 10.5-1.5t10.5-.5q48 0 82.5 34.5T920-525q0 43-30.5 77.5T818-402q7 2 13 4t12 5q29 14 47 39.5t18 55.5q0 38-33.5 66.5T779-188l-40 10v98H523ZM138-537q-29-29-43.5-65.5T80-679q0-84 58-142.5T280-880q40 0 76.5 15t65.5 44l-56 56q-17-17-39.5-26t-46.5-9q-50 0-85 35t-35 85q0 24 9 46.5t26 39.5l-57 57Zm493 128Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M8 46V35h5v-5.5H8v-11h5V13H8V2h13v11h-5v5.5h5v4h8v-4h13v11H29v-4h-8v4h-5V35h5v11Zm3-3h7v-5h-7Zm0-16.5h7v-5h-7Zm21 0h7v-5h-7ZM11 10h7V5h-7Zm3.5-2.5Zm0 16.5Zm21 0Zm-21 16.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M7 37h9.35V11H7v26Zm12.35 0h9.3V11h-9.3v26Zm12.3 0H41V11h-9.35v26ZM7 40q-1.2 0-2.1-.9Q4 38.2 4 37V11q0-1.2.9-2.1Q5.8 8 7 8h34q1.2 0 2.1.9.9.9.9 2.1v26q0 1.2-.9 2.1-.9.9-2.1.9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M11.1 37.3 4 30.2l2.1-2.1 5 4.95 8.95-8.95 2.1 2.15Zm0-16L4 14.2l2.1-2.1 5 4.95 8.95-8.95 2.1 2.15ZM26 33.5v-3h18v3Zm0-16v-3h18v3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M13 33h13v-3H13Zm19 0h3V15h-3Zm-19-7.5h13v-3H13Zm0-7.5h13v-3H13ZM6.6 42q-1.2 0-2.1-.9-.9-.9-.9-2.1V9q0-1.2.9-2.1.9-.9 2.1-.9h34.8q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h34.8V9H6.6v30Zm0 0V9v30Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24 44q-4.2 0-7.85-1.575Q12.5 40.85 9.8 38.15q-2.7-2.7-4.25-6.375Q4 28.1 4 23.9t1.55-7.825Q7.1 12.45 9.8 9.75t6.35-4.225Q19.8 4 24 4q1.15 0 2.275.125T28.5 4.5v10.4q-.7-2.25-1.85-4.275Q25.5 8.6 24 7q-1.6 1.35-2.7 3.55-1.1 2.2-2.1 5.95h9.3v3h-9.85q-.2 1.55-.25 2.475-.05.925-.05 1.925 0 1.05.05 2.025.05.975.25 2.525H29.4q.2-1.55.25-2.525.05-.975.05-2.025 0-1-.05-1.925T29.4 19.5h3.05q.15 1.75.2 2.675.05.925.05 1.725 0 1.1-.075 2.075-.075.975-.225 2.475h8q.35-1.2.475-2.225Q41 25.2 41 23.9q0-1.3-.125-2.25T40.4 19.5h3.15q.25 1.05.35 2.15.1 1.1.1 2.25 0 4.2-1.55 7.875T38.2 38.15q-2.7 2.7-6.35 4.275Q28.2 44 24 44ZM7.6 28.45h7.95q-.15-1.35-.175-2.425-.025-1.075-.025-2.125 0-1.25.05-2.225.05-.975.2-2.175h-8q-.35 1.2-.475 2.15T7 23.9q0 1.3.125 2.325.125 1.025.475 2.225ZM19.75 40.5q-1.25-1.9-2.15-4.1-.9-2.2-1.5-4.95H8.6Q10.5 35 13 37.025q2.5 2.025 6.75 3.475ZM8.6 16.5h7.55q.55-2.7 1.4-4.825.85-2.125 2.15-4.125-3.75.95-6.55 3.2T8.6 16.5ZM24 41.1q1.75-1.8 2.925-4.125Q28.1 34.65 28.85 31.45H19.2q.7 3 1.875 5.4Q22.25 39.25 24 41.1Zm4.3-.65q3.6-1.15 6.475-3.45 2.875-2.3 4.625-5.55h-7.45q-.65 2.7-1.525 4.9-.875 2.2-2.125 4.1ZM33 15V4h11v11Zm5-5h5V5h-5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M12.55 40q-4.4 0-7.475-3.075Q2 33.85 2 29.45q0-3.9 2.5-6.85 2.5-2.95 6.35-3.55 1-4.85 4.7-7.925T24.1 8.05q5.6 0 9.45 4.075Q37.4 16.2 37.4 21.9v1.2q3.6-.1 6.1 2.325Q46 27.85 46 31.55q0 3.45-2.5 5.95T37.55 40Zm0-3h25q2.25 0 3.85-1.6t1.6-3.85q0-2.25-1.6-3.85t-3.85-1.6H34.4v-4.2q0-4.55-3.05-7.7-3.05-3.15-7.45-3.15t-7.475 3.15q-3.075 3.15-3.075 7.7h-.95q-3.1 0-5.25 2.175T5 29.45q0 3.15 2.2 5.35Q9.4 37 12.55 37ZM24 24Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 661 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
|
||||
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
|
||||
<path class="icon-vs-fg" d="M24 44q-3.3 0-6.525-1.2-3.225-1.2-5.775-3.15-2.55-1.95-4.125-4.4Q6 32.8 6 30.25v-5l6.75 5.05-2.9 2.9q1.55 2.9 5.3 5.175T22.5 40.9V21.5H16v-3h6.5v-3.7q-1.9-.7-2.95-2.1-1.05-1.4-1.05-3.2 0-2.3 1.625-3.9T24 4q2.3 0 3.9 1.6t1.6 3.9q0 1.8-1.05 3.2-1.05 1.4-2.95 2.1v3.7H32v3h-6.5v19.4q3.6-.25 7.35-2.525 3.75-2.275 5.3-5.175l-2.9-2.9L42 25.25v5q0 2.55-1.575 5t-4.125 4.4q-2.55 1.95-5.775 3.15Q27.3 44 24 44Zm0-32q1.05 0 1.775-.75.725-.75.725-1.75 0-1.05-.725-1.775Q25.05 7 24 7q-1 0-1.75.725T21.5 9.5q0 1 .75 1.75T24 12Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 759 B |
@@ -0,0 +1,165 @@
|
||||
const baseUrl = process.env.SFERA_WEB_URL ?? "http://192.168.200.60:3000";
|
||||
const projectId = process.env.SFERA_PROJECT_ID ?? "demo";
|
||||
const routine = encodeURIComponent(process.env.SFERA_ROUTINE ?? "Проведение");
|
||||
const attempts = Number(process.env.SFERA_SMOKE_ATTEMPTS ?? "5");
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: "root opens IDE workspace",
|
||||
url: `${baseUrl}/?lang=ru&project=${projectId}`,
|
||||
mustInclude: [
|
||||
"SFERA",
|
||||
"data-top-project-bar",
|
||||
"data-top-bar-logo",
|
||||
"data-top-bar-selector=\"workspace\"",
|
||||
"data-top-bar-selector=\"project\"",
|
||||
"data-top-bar-selector=\"environment\"",
|
||||
"data-top-bar-selector=\"active-task\"",
|
||||
"data-top-bar-action=\"project-settings\"",
|
||||
"data-top-bar-action=\"create-project\"",
|
||||
"data-top-bar-badge=\"api-status\"",
|
||||
"data-top-bar-badge=\"agent-status\"",
|
||||
"data-top-bar-language",
|
||||
"data-top-bar-button=\"profile\"",
|
||||
"Ctrl+K",
|
||||
"data-status-bar",
|
||||
"data-status-item=\"current-user\""
|
||||
],
|
||||
mustNotInclude: ["Открыть в редакторе"]
|
||||
},
|
||||
{
|
||||
name: "root opens IDE workspace (en)",
|
||||
url: `${baseUrl}/?lang=en&project=${projectId}`,
|
||||
mustInclude: [
|
||||
"SFERA",
|
||||
"data-top-project-bar",
|
||||
"data-top-bar-logo",
|
||||
"data-top-bar-selector=\"workspace\"",
|
||||
"data-top-bar-selector=\"project\"",
|
||||
"data-top-bar-selector=\"environment\"",
|
||||
"data-top-bar-selector=\"active-task\"",
|
||||
"data-top-bar-action=\"project-settings\"",
|
||||
"data-top-bar-action=\"create-project\"",
|
||||
"data-top-bar-badge=\"api-status\"",
|
||||
"data-top-bar-badge=\"agent-status\"",
|
||||
"data-top-bar-language",
|
||||
"data-top-bar-button=\"profile\"",
|
||||
"Ctrl+K",
|
||||
"data-status-bar",
|
||||
"data-status-item=\"current-user\""
|
||||
],
|
||||
mustNotInclude: ["Open in editor"]
|
||||
},
|
||||
{
|
||||
name: "project settings route",
|
||||
url: `${baseUrl}/project-settings`,
|
||||
mustInclude: [
|
||||
"Project Settings",
|
||||
"Import Center",
|
||||
"REFERENCE_CONFIGURATION",
|
||||
"Reference config",
|
||||
"data-import-action=\"REFERENCE_CONFIGURATION:import\"",
|
||||
"data-import-action=\"XML_DUMP:check\"",
|
||||
"data-settings-section=\"docker-runtime-adapter\"",
|
||||
"data-settings-section=\"its-documentation-access\"",
|
||||
"Пользователи и доступ",
|
||||
"Интеграции задач",
|
||||
"Docker/runtime adapter",
|
||||
"ITS/documentation access",
|
||||
"Audit",
|
||||
"Backup/restore",
|
||||
"SFERA_ITS_URL",
|
||||
"SFERA_ITS_USERNAME",
|
||||
"SFERA_ITS_PASSWORD",
|
||||
"https://its.1c.ru/db/v838doc#browse:13:-1:7",
|
||||
".env.local",
|
||||
"<set locally>"
|
||||
],
|
||||
mustNotInclude: ["Открыть в редакторе"]
|
||||
},
|
||||
{
|
||||
name: "project settings route (en)",
|
||||
url: `${baseUrl}/project-settings?lang=en`,
|
||||
mustInclude: [
|
||||
"Project Settings",
|
||||
"Import Center",
|
||||
"REFERENCE_CONFIGURATION",
|
||||
"Reference config",
|
||||
"data-import-action=\"REFERENCE_CONFIGURATION:import\"",
|
||||
"data-import-action=\"XML_DUMP:check\"",
|
||||
"data-settings-section=\"task-session-policy\"",
|
||||
"data-settings-section=\"docker-runtime-adapter\"",
|
||||
"data-settings-section=\"its-documentation-access\"",
|
||||
"Task/session policy",
|
||||
"Docker/runtime adapter",
|
||||
"ITS/documentation access",
|
||||
"Audit",
|
||||
"Backup/restore",
|
||||
"SFERA_ITS_URL",
|
||||
"SFERA_ITS_USERNAME",
|
||||
"SFERA_ITS_PASSWORD",
|
||||
".env.local",
|
||||
"<set locally>"
|
||||
],
|
||||
mustNotInclude: ["Open in editor"]
|
||||
},
|
||||
{
|
||||
name: "module mode",
|
||||
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=module&routine=${routine}`,
|
||||
mustInclude: ["data-ide-workspace", "data-left-navigation-panel", "data-right-context-inspector", "data-open-objects-bar", "data-open-document-pin", "data-open-document-close", "data-fallback-tree-search", "data-fallback-tree-filters", "Alt+1 Alt+2 Alt+3", "Редактор BSL", "Код модуля не загружен", "Выберите реальный модуль", "Основная конфигурация", "Расширение: <Имя>", "SFERA", "Среды"]
|
||||
},
|
||||
{
|
||||
name: "form mode",
|
||||
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=form&routine=${routine}`,
|
||||
mustInclude: ["Дизайнер формы", "Провести и закрыть", "Товары"]
|
||||
},
|
||||
{
|
||||
name: "events mode",
|
||||
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=events&routine=${routine}`,
|
||||
mustInclude: ["Инспектор событий", "ПриСозданииНаСервере", "ПередЗаписью"]
|
||||
},
|
||||
{
|
||||
name: "learning mode",
|
||||
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=learning&routine=${routine}`,
|
||||
mustInclude: ["Обучение", "переменные доступны", "стандарты команды"]
|
||||
}
|
||||
];
|
||||
|
||||
for (const check of checks) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
await runCheck(check);
|
||||
console.log(`ok ${check.name}`);
|
||||
lastError = undefined;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCheck(check) {
|
||||
const response = await fetch(check.url, { headers: { Accept: "text/html" } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`${check.name}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
for (const expected of check.mustInclude ?? []) {
|
||||
if (!html.includes(expected)) {
|
||||
throw new Error(`${check.name}: missing "${expected}"`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of check.mustNotInclude ?? []) {
|
||||
if (html.includes(forbidden)) {
|
||||
throw new Error(`${check.name}: unexpected "${forbidden}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const baseUrl = process.env.SFERA_WEB_URL ?? "http://192.168.200.60:3000";
|
||||
const projectId = process.env.SFERA_PROJECT_ID ?? "demo";
|
||||
const uniqueSuffix = Date.now().toString(36);
|
||||
const browserPath =
|
||||
process.env.SFERA_BROWSER_PATH ??
|
||||
[
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
].find((path) => existsSync(path));
|
||||
|
||||
if (!browserPath) {
|
||||
throw new Error("No installed Chrome or Edge browser found. Set SFERA_BROWSER_PATH.");
|
||||
}
|
||||
|
||||
const runtimeErrors = [];
|
||||
const browser = await chromium.launch({
|
||||
executablePath: browserPath,
|
||||
headless: true
|
||||
});
|
||||
|
||||
function assertNoFatalRuntimeErrors(stage) {
|
||||
const fatalErrors = runtimeErrors.filter((message) => {
|
||||
return (
|
||||
message.includes("Runtime TypeError") ||
|
||||
message.includes("Cannot read properties") ||
|
||||
message.includes("Unhandled Runtime Error")
|
||||
);
|
||||
});
|
||||
if (fatalErrors.length > 0) {
|
||||
throw new Error(`${stage} failed:\n${fatalErrors.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function collapseBottomPanelIfOpen(page) {
|
||||
if ((await page.locator("[data-bottom-tool-panel]").count()) > 0) {
|
||||
await page.keyboard.press("Alt+3");
|
||||
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "detached", timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewportSize({ width: 1680, height: 1050 });
|
||||
page.on("pageerror", (error) => {
|
||||
runtimeErrors.push(`pageerror: ${error.message}`);
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
runtimeErrors.push(`console: ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await page.goto(`${baseUrl}/editor?lang=ru&project=${encodeURIComponent(projectId)}&mode=module`, {
|
||||
waitUntil: "load",
|
||||
timeout: 60000
|
||||
});
|
||||
if (!response?.ok()) {
|
||||
throw new Error(`editor runtime smoke: ${response?.status()} ${response?.statusText()}`);
|
||||
}
|
||||
|
||||
await page.getByRole("heading", { name: "Редактор BSL" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByRole("heading", { name: "Семантический diff" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
for (const marker of ["snapshot", "agent", "parser", "diagnostics", "active-task", "privacy", "ai-tokens", "current-user"]) {
|
||||
await page.locator(`[data-status-item="${marker}"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
await page.locator("[data-global-search-input]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.keyboard.press("Control+K");
|
||||
const globalSearchFocused = await page.locator("[data-global-search-input]").evaluate((input) => document.activeElement === input);
|
||||
if (!globalSearchFocused) {
|
||||
throw new Error("Ctrl+K did not focus global search");
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
|
||||
});
|
||||
await page.locator("[data-run-check-status]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("[data-left-navigation-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
const hasVirtualScrollContainer =
|
||||
(await page.locator("[data-virtual-scroll]").count()) > 0 ||
|
||||
(await page.locator("[data-fallback-tree-scroll]").count()) > 0;
|
||||
if (!hasVirtualScrollContainer) {
|
||||
throw new Error("Left navigation panel has no virtual/fallback scroll container marker");
|
||||
}
|
||||
await page.locator("[data-right-context-inspector]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
for (const tabName of ["Проблемы", "Semantic diff", "Вывод", "История", "Тесты", "AI"]) {
|
||||
await page.locator("[data-bottom-tool-panel]").getByText(tabName).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
await page.locator("[data-fallback-tree-search-input]").fill("SFERA");
|
||||
await page.getByText("SFERA", { exact: true }).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("[data-fallback-tree-search-input]").fill("");
|
||||
await page.locator("[data-fallback-tree-filter='sfera']").click();
|
||||
await page.getByText("SFERA", { exact: true }).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("[data-fallback-tree-filter='all']").click();
|
||||
const lazyFilterCount = await page.locator("[data-lazy-tree-filter]").count();
|
||||
if (lazyFilterCount > 0) {
|
||||
await page.locator("[data-lazy-tree-filter='sfera']").click();
|
||||
await page.getByText("SFERA", { exact: true }).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("[data-lazy-tree-filter='all']").click();
|
||||
}
|
||||
await page.keyboard.press("Alt+1");
|
||||
await page.locator("[data-left-navigation-panel]").waitFor({ state: "detached", timeout: 15000 });
|
||||
await page.locator("[data-panel-rail]").first().click();
|
||||
await page.locator("[data-left-navigation-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.keyboard.press("Alt+2");
|
||||
await page.locator("[data-right-context-inspector]").waitFor({ state: "detached", timeout: 15000 });
|
||||
await page.locator("[data-panel-rail]").last().click();
|
||||
await page.locator("[data-right-context-inspector]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.keyboard.press("Alt+3");
|
||||
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "detached", timeout: 15000 });
|
||||
await page.getByRole("button", { name: "Нижняя панель" }).click();
|
||||
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.keyboard.press("Alt+3");
|
||||
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "detached", timeout: 15000 });
|
||||
const activeDocumentBeforeNext = await page.locator('[data-open-document][aria-pressed="true"]').first().getAttribute("data-open-document");
|
||||
const openDocumentCount = await page.locator("[data-open-document]").count();
|
||||
if (openDocumentCount < 2) {
|
||||
throw new Error("Open Objects Bar has fewer than two switchable objects");
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
const activeDocumentAfterNext = await page.locator('[data-open-document][aria-pressed="true"]').first().getAttribute("data-open-document");
|
||||
if (!activeDocumentAfterNext || activeDocumentAfterNext === activeDocumentBeforeNext) {
|
||||
throw new Error("Ctrl+Tab did not switch the active open object");
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await page.locator(`[data-open-document="${activeDocumentBeforeNext}"][aria-pressed="true"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator(`[data-open-document-pin="${activeDocumentAfterNext}"]`).click();
|
||||
const pinnedCloseButton = page.locator(`[data-open-document-close="${activeDocumentAfterNext}"]`);
|
||||
if (!(await pinnedCloseButton.isDisabled())) {
|
||||
throw new Error("Pinned open object close button is not disabled");
|
||||
}
|
||||
await page.locator(`[data-open-document-pin="${activeDocumentAfterNext}"]`).click();
|
||||
await pinnedCloseButton.click();
|
||||
await page.locator(`[data-open-document="${activeDocumentAfterNext}"]`).waitFor({ state: "detached", timeout: 15000 });
|
||||
const ctrlWCandidate = await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll<HTMLElement>("[data-open-document]"));
|
||||
const candidate = buttons.find((button) => button.getAttribute("data-open-document-mode") !== "module");
|
||||
return candidate?.getAttribute("data-open-document") ?? null;
|
||||
});
|
||||
if (ctrlWCandidate) {
|
||||
const targetTab = page.locator(`[data-open-document="${ctrlWCandidate}"]`);
|
||||
await targetTab.click();
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "w", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await targetTab.waitFor({ state: "detached", timeout: 15000 });
|
||||
const moduleTabs = page.locator('[data-open-document-mode="module"]');
|
||||
const moduleTabCount = await moduleTabs.count();
|
||||
if (moduleTabCount > 0) {
|
||||
await moduleTabs.first().click();
|
||||
}
|
||||
}
|
||||
const openObjectsState = await page.evaluate((storageKey) => window.localStorage.getItem(storageKey), `sfera.open-objects.${projectId}`);
|
||||
if (!openObjectsState?.includes(activeDocumentAfterNext)) {
|
||||
throw new Error("Open Objects Bar state was not persisted");
|
||||
}
|
||||
await page.reload({ waitUntil: "load", timeout: 60000 });
|
||||
await page.getByRole("heading", { name: "Редактор BSL" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
const restoredState = await page.evaluate((storageKey) => window.localStorage.getItem(storageKey), `sfera.open-objects.${projectId}`);
|
||||
if (restoredState !== openObjectsState) {
|
||||
throw new Error("Open Objects Bar state was not restored after reload");
|
||||
}
|
||||
await page.locator(`[data-open-document="${activeDocumentAfterNext}"]`).waitFor({ state: "detached", timeout: 15000 });
|
||||
const restoredModuleTabs = page.locator('[data-open-document-mode="module"]');
|
||||
if ((await restoredModuleTabs.count()) > 0) {
|
||||
await restoredModuleTabs.first().click();
|
||||
}
|
||||
await page.locator(".monaco-editor").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("#symbol-search-input").fill("Проверить");
|
||||
await page.locator("#symbol-search-input").press("Enter");
|
||||
await page.locator("[data-symbol-result]").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator('button[data-editor-action="find-usages"]').click();
|
||||
await page.locator("[data-symbol-references]").waitFor({ state: "visible", timeout: 15000 });
|
||||
const applyButton = page.locator('button[data-editor-action="apply-to-sfera"]');
|
||||
await applyButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
if (await applyButton.isEnabled()) {
|
||||
await applyButton.click();
|
||||
await page.getByText("Записано в SFERA").waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
assertNoFatalRuntimeErrors("editor module runtime smoke");
|
||||
|
||||
const versionsResponse = await page.goto(`${baseUrl}/editor?lang=ru&project=${encodeURIComponent(projectId)}&mode=versions`, {
|
||||
waitUntil: "load",
|
||||
timeout: 60000
|
||||
});
|
||||
if (!versionsResponse?.ok()) {
|
||||
throw new Error(`editor versions runtime smoke: ${versionsResponse?.status()} ${versionsResponse?.statusText()}`);
|
||||
}
|
||||
await page.getByRole("heading", { name: "Версии" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
await collapseBottomPanelIfOpen(page);
|
||||
await page.getByText("Линия версий").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
const versionButtons = page.locator("button[data-version-id]");
|
||||
const versionButtonCount = await versionButtons.count();
|
||||
if (versionButtonCount > 0) {
|
||||
await versionButtons.first().click();
|
||||
await page.getByText("Полные данные").waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
assertNoFatalRuntimeErrors("editor versions runtime smoke");
|
||||
|
||||
const rollbackButtons = page.locator('button[data-editor-action="rollback-plan"]');
|
||||
const rollbackButtonCount = await rollbackButtons.count();
|
||||
if (rollbackButtonCount > 0) {
|
||||
await rollbackButtons.first().click();
|
||||
await page.locator("[data-rollback-preview]").waitFor({ state: "visible", timeout: 15000 });
|
||||
const applyRollbackButton = page.locator('button[data-editor-action="apply-rollback"]');
|
||||
await applyRollbackButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
if (await applyRollbackButton.isEnabled()) {
|
||||
await applyRollbackButton.click();
|
||||
await page.locator("[data-rollback-apply-message]").waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
}
|
||||
assertNoFatalRuntimeErrors("editor rollback runtime smoke");
|
||||
|
||||
const metadataResponse = await page.goto(`${baseUrl}/editor?lang=ru&project=${encodeURIComponent(projectId)}&mode=properties`, {
|
||||
waitUntil: "load",
|
||||
timeout: 60000
|
||||
});
|
||||
if (!metadataResponse?.ok()) {
|
||||
throw new Error(`editor metadata runtime smoke: ${metadataResponse?.status()} ${metadataResponse?.statusText()}`);
|
||||
}
|
||||
await page.getByRole("heading", { name: "Свойства" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
await collapseBottomPanelIfOpen(page);
|
||||
await page.getByLabel("Имя объекта").fill(`АвтоОбъект${uniqueSuffix}`);
|
||||
await page.getByLabel("Синоним").fill(`Авто объект ${uniqueSuffix}`);
|
||||
const addAttributeButton = page.locator('button[data-editor-action="add-metadata-attribute"]');
|
||||
await addAttributeButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await addAttributeButton.click();
|
||||
await page.getByRole("textbox", { name: "Имя реквизита" }).nth(1).fill("Комментарий");
|
||||
await page.getByRole("textbox", { name: "Тип" }).nth(1).fill("Строка250");
|
||||
const addFormButton = page.locator('button[data-editor-action="add-metadata-form"]');
|
||||
await addFormButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await addFormButton.click();
|
||||
await page.getByRole("textbox", { name: "Имя формы" }).last().fill(`ФормаВыбора${uniqueSuffix}`);
|
||||
const addCommandButton = page.locator('button[data-editor-action="add-metadata-command"]');
|
||||
await addCommandButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
await addCommandButton.click();
|
||||
await page.getByRole("textbox", { name: "Имя команды" }).last().fill(`Отправить${uniqueSuffix}`);
|
||||
await page.getByRole("textbox", { name: "Обработчик" }).last().fill(`Отправить${uniqueSuffix}Команда`);
|
||||
const metadataButton = page.locator('button[data-editor-action="apply-metadata-object"]');
|
||||
await metadataButton.waitFor({ state: "visible", timeout: 15000 });
|
||||
if (await metadataButton.isEnabled()) {
|
||||
await metadataButton.click();
|
||||
const draftMessage = page.locator("[data-metadata-draft-message]");
|
||||
await draftMessage.waitFor({ state: "visible", timeout: 15000 });
|
||||
try {
|
||||
await page.locator("[data-metadata-draft-preview]").waitFor({ state: "visible", timeout: 15000 });
|
||||
} catch (error) {
|
||||
throw new Error(`metadata draft preview did not appear: ${await draftMessage.textContent()}`);
|
||||
}
|
||||
}
|
||||
assertNoFatalRuntimeErrors("editor metadata runtime smoke");
|
||||
|
||||
const englishResponse = await page.goto(`${baseUrl}/editor?lang=en&project=${encodeURIComponent(projectId)}&mode=module`, {
|
||||
waitUntil: "load",
|
||||
timeout: 60000
|
||||
});
|
||||
if (!englishResponse?.ok()) {
|
||||
throw new Error(`editor english runtime smoke: ${englishResponse?.status()} ${englishResponse?.statusText()}`);
|
||||
}
|
||||
await page.getByRole("heading", { name: "BSL Editor" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("[data-top-project-bar]").waitFor({ state: "visible", timeout: 15000 });
|
||||
for (const selector of ["workspace", "project", "environment", "active-task"]) {
|
||||
await page.locator(`[data-top-bar-selector="${selector}"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
for (const action of ["project-settings", "create-project"]) {
|
||||
await page.locator(`[data-top-bar-action="${action}"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
for (const badge of ["api-status", "agent-status"]) {
|
||||
await page.locator(`[data-top-bar-badge="${badge}"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
await page.locator("[data-top-bar-language]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator('[data-top-bar-button="profile"]').waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
for (const tabName of ["Problems", "Semantic diff", "Output", "Change history", "Tests", "AI"]) {
|
||||
await page.locator("[data-bottom-tool-panel]").getByText(tabName).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
for (const statusMarker of ["snapshot", "agent", "parser", "diagnostics", "active-task", "privacy", "ai-tokens", "current-user"]) {
|
||||
await page.locator(`[data-status-item="${statusMarker}"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
assertNoFatalRuntimeErrors("editor english runtime smoke");
|
||||
|
||||
console.log("ok editor runtime");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const baseUrl = process.env.SFERA_WEB_URL ?? "http://localhost:3000";
|
||||
const projectId = process.env.SFERA_PROJECT_ID ?? "default";
|
||||
const browserPath =
|
||||
process.env.SFERA_BROWSER_PATH ??
|
||||
[
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
].find((path) => existsSync(path));
|
||||
|
||||
if (!browserPath) {
|
||||
throw new Error("No installed Chrome or Edge browser found. Set SFERA_BROWSER_PATH.");
|
||||
}
|
||||
|
||||
await prepareIndexedProject();
|
||||
|
||||
const runtimeErrors = [];
|
||||
const browser = await chromium.launch({
|
||||
executablePath: browserPath,
|
||||
headless: true
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on("pageerror", (error) => {
|
||||
runtimeErrors.push(`pageerror: ${error.message}`);
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
runtimeErrors.push(`console: ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await page.goto(`${baseUrl}/project-settings`, { waitUntil: "load", timeout: 60000 });
|
||||
if (!response?.ok()) {
|
||||
throw new Error(`project setup smoke: ${response?.status()} ${response?.statusText()}`);
|
||||
}
|
||||
|
||||
await page.locator("[data-project-import-center]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByRole("heading", { name: "Project Settings" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
for (const section of ["Пользователи и доступ", "Интеграции задач", "Docker/runtime adapter", "Audit", "Backup/restore"]) {
|
||||
await page.getByText(section).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
for (const sectionMarker of ["docker-runtime-adapter", "its-documentation-access"]) {
|
||||
await page.locator(`[data-settings-section="${sectionMarker}"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
await page.getByText("ITS/documentation access").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("SFERA_ITS_URL").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("SFERA_ITS_USERNAME").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("SFERA_ITS_PASSWORD").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("input[value*='its.1c.ru/db/v838doc']").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText(".env.local").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("input[value='<set locally>']").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("select").filter({ hasText: "REFERENCE_CONFIGURATION" }).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByRole("button", { name: /Reference config/ }).waitFor({ state: "visible", timeout: 15000 });
|
||||
const hasMetadataOnlyPrivacy = await page.locator("select").evaluateAll((selects) => {
|
||||
return selects.some((select) => select.value === "METADATA_ONLY" || [...select.options].some((option) => option.value === "METADATA_ONLY"));
|
||||
});
|
||||
if (!hasMetadataOnlyPrivacy) {
|
||||
throw new Error("project setup smoke: missing METADATA_ONLY privacy mode");
|
||||
}
|
||||
await page.getByText("Project Setup / Import Center").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("Режим работы с конфигурацией 1С").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator('[data-import-mode="FULL_REPLACE"]').waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator('[data-run-import-mode="SYNC_PREVIEW"]').click();
|
||||
const importMode = await page.evaluate(() => window.localStorage.getItem("sfera.import.mode"));
|
||||
if (importMode !== "SYNC_PREVIEW") {
|
||||
throw new Error(`project setup smoke: import mode was not persisted, got ${importMode}`);
|
||||
}
|
||||
await page.locator('[data-run-import-mode="FULL_REPLACE"]').click();
|
||||
await page.locator("[data-import-validation-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("Не заполнен путь к источнику").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator('input[placeholder*="/mnt/share/project"]').fill("/tmp/nonexistent-smoke-import");
|
||||
await page.locator('[data-run-import-mode="FULL_REPLACE"]').click();
|
||||
await page.getByText("Подтвердите полное обновление").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByRole("button", { name: "Отмена" }).click();
|
||||
await page.locator('[data-run-import-mode="SYNC_PREVIEW"]').click();
|
||||
await page.locator('[data-import-action="XML_DUMP:check"]').click();
|
||||
await page.locator("[data-import-check-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("Source preflight").waitFor({ state: "visible", timeout: 15000 });
|
||||
|
||||
const englishResponse = await page.goto(`${baseUrl}/project-settings?lang=en`, { waitUntil: "load", timeout: 60000 });
|
||||
if (!englishResponse?.ok()) {
|
||||
throw new Error(`project setup smoke (en): ${englishResponse?.status()} ${englishResponse?.statusText()}`);
|
||||
}
|
||||
await page.getByRole("heading", { name: "Project Settings" }).waitFor({ state: "visible", timeout: 15000 });
|
||||
for (const section of ["Task/session policy", "Docker/runtime adapter", "ITS/documentation access", "Audit", "Backup/restore"]) {
|
||||
await page.getByText(section).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
for (const sectionMarker of ["task-session-policy", "docker-runtime-adapter", "its-documentation-access"]) {
|
||||
await page.locator(`[data-settings-section="${sectionMarker}"]`).waitFor({ state: "visible", timeout: 15000 });
|
||||
}
|
||||
await page.locator("select").filter({ hasText: "REFERENCE_CONFIGURATION" }).first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByRole("button", { name: /Reference config/ }).waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("SFERA_ITS_URL").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("SFERA_ITS_USERNAME").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("SFERA_ITS_PASSWORD").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText(".env.local").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator("input[value='<set locally>']").first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.locator('[data-import-action="XML_DUMP:check"]').click();
|
||||
await page.locator("[data-import-check-panel]").waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.getByText("Source preflight").waitFor({ state: "visible", timeout: 15000 });
|
||||
|
||||
assertNoFatalRuntimeErrors();
|
||||
console.log("ok project setup runtime");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
async function prepareIndexedProject() {
|
||||
await postJson(`/api/sfera/projects/${encodeURIComponent(projectId)}/settings`, {
|
||||
name: "SFERA Smoke Project",
|
||||
structure_source: "XML_DUMP",
|
||||
platform_version: "8.3.24",
|
||||
compatibility_mode: "8.3.20"
|
||||
});
|
||||
await postJson(`/api/sfera/projects/${encodeURIComponent(projectId)}/imports/XML_DUMP`, {
|
||||
source: "XML_DUMP",
|
||||
metadata: {
|
||||
platform_version: "8.3.24",
|
||||
compatibility_mode: "8.3.20"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function postJson(path, body) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function assertNoFatalRuntimeErrors() {
|
||||
const fatalErrors = runtimeErrors.filter((message) => {
|
||||
return (
|
||||
message.includes("Runtime TypeError") ||
|
||||
message.includes("Cannot read properties") ||
|
||||
message.includes("Unhandled Runtime Error")
|
||||
);
|
||||
});
|
||||
if (fatalErrors.length > 0) {
|
||||
throw new Error(`project setup runtime smoke failed:\n${fatalErrors.join("\n")}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { resolveApiUrl } from "@/lib/api";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ path: string[] }>;
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
async function proxy(request: Request, context: RouteContext) {
|
||||
const { path } = await context.params;
|
||||
const apiUrl = resolveApiUrl(request.headers.get("host"));
|
||||
const sourceUrl = new URL(request.url);
|
||||
const target = `${apiUrl}/${path.join("/")}${sourceUrl.search}`;
|
||||
const method = request.method;
|
||||
const hasBody = method !== "GET" && method !== "HEAD" && request.body !== null;
|
||||
const body = hasBody ? await request.arrayBuffer() : undefined;
|
||||
const contentType = request.headers.get("Content-Type");
|
||||
const accept = request.headers.get("Accept") ?? "application/json";
|
||||
const publicHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
||||
const publicProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||
const publicOrigin = publicHost ? `${publicProto}://${publicHost}` : sourceUrl.origin;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: accept,
|
||||
"X-Sfera-Public-Origin": publicOrigin
|
||||
};
|
||||
if (hasBody) {
|
||||
headers["Content-Type"] = contentType ?? "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(target, {
|
||||
method,
|
||||
cache: "no-store",
|
||||
headers,
|
||||
body
|
||||
});
|
||||
const responseHeaders: Record<string, string> = {
|
||||
"Content-Type": response.headers.get("Content-Type") ?? "application/json"
|
||||
};
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
if (contentDisposition) {
|
||||
responseHeaders["Content-Disposition"] = contentDisposition;
|
||||
}
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
if (cacheControl) {
|
||||
responseHeaders["Cache-Control"] = cacheControl;
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { IdeWorkspace } from "@/components/editor/ide-workspace";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
getApiHealth,
|
||||
getProjects,
|
||||
getProjectWorkspaceData,
|
||||
resolveApiUrl,
|
||||
type ProjectSummary
|
||||
} from "@/lib/api";
|
||||
import { messages, normalizeLanguage, type UiLanguage } from "@/lib/i18n";
|
||||
|
||||
export default async function EditorPage({
|
||||
searchParams
|
||||
}: Readonly<{
|
||||
searchParams?: Promise<{ lang?: string; mode?: string; project?: string; routine?: string }>;
|
||||
}>) {
|
||||
const params = await searchParams;
|
||||
const language = normalizeLanguage(params?.lang);
|
||||
const requestHeaders = await headers();
|
||||
const apiUrl = resolveApiUrl(requestHeaders.get("host"));
|
||||
const bootstrap = await loadEditorBootstrap(apiUrl, language, params?.project);
|
||||
const projectData =
|
||||
bootstrap.status === "ok" && bootstrap.projectId ? await getProjectWorkspaceData(bootstrap.projectId, apiUrl, params?.routine, params?.mode) : null;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
apiStatus={bootstrap.status}
|
||||
language={language}
|
||||
projectId={bootstrap.status === "ok" ? bootstrap.projectId : undefined}
|
||||
projectOptions={bootstrap.status === "ok" ? bootstrap.projects : []}
|
||||
>
|
||||
<main className="pb-8">
|
||||
{bootstrap.status === "error" ? (
|
||||
<ErrorState language={language} message={bootstrap.error} />
|
||||
) : projectData ? (
|
||||
<IdeWorkspace
|
||||
key={projectData.projectId}
|
||||
data={projectData}
|
||||
initialMode={params?.mode}
|
||||
language={language}
|
||||
routineName={params?.routine}
|
||||
/>
|
||||
) : (
|
||||
<ErrorState language={language} message={messages[language].noProjectDataDescription} />
|
||||
)}
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEditorBootstrap(apiUrl: string, language: UiLanguage, projectId?: string): Promise<
|
||||
| { status: "ok"; projectId: string | undefined; projects: ProjectSummary[] }
|
||||
| { status: "error"; error: string }
|
||||
> {
|
||||
try {
|
||||
const [projectsResponse] = await Promise.all([getProjects(apiUrl), getApiHealth(apiUrl)]);
|
||||
const projects = uniqueProjects(projectsResponse);
|
||||
return { status: "ok", projectId: projectId || pickDefaultProject(projectsResponse), projects };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : messages[language].unknownApiError
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorState({
|
||||
message,
|
||||
language
|
||||
}: Readonly<{
|
||||
message: string;
|
||||
language: UiLanguage;
|
||||
}>) {
|
||||
const t = messages[language];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-xl items-center justify-center">
|
||||
<Card className="w-full">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-1 h-5 w-5 text-destructive" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">{t.apiUnavailable}</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pickDefaultProject(projects: ProjectSummary[]) {
|
||||
return (
|
||||
projects.find((project) => project.project_id === "ifcm-upo")?.project_id ??
|
||||
projects.find((project) => project.project_id !== "demo")?.project_id ??
|
||||
projects.at(-1)?.project_id ??
|
||||
projects.find((project) => project.project_id === "demo")?.project_id
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueProjects(projects: ProjectSummary[]) {
|
||||
const byId = new Map<string, ProjectSummary>();
|
||||
for (const project of projects) {
|
||||
const projectId = project.project_id.trim();
|
||||
if (projectId) {
|
||||
byId.set(projectId, { ...project, project_id: projectId, name: project.name || projectId });
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values()).sort((left, right) => (left.name || left.project_id).localeCompare(right.name || right.project_id, "ru"));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--muted: 210 17% 94%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--border: 214 20% 88%;
|
||||
--input: 214 20% 88%;
|
||||
--primary: 198 93% 32%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 168 42% 39%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 45%;
|
||||
--warning: 38 92% 48%;
|
||||
--success: 142 54% 36%;
|
||||
--info: 214 84% 56%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 220 24% 10%;
|
||||
--foreground: 210 20% 96%;
|
||||
--card: 220 20% 14%;
|
||||
--card-foreground: 210 20% 96%;
|
||||
--muted: 220 16% 20%;
|
||||
--muted-foreground: 215 15% 66%;
|
||||
--border: 220 16% 24%;
|
||||
--input: 220 16% 24%;
|
||||
--primary: 194 80% 46%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 164 52% 43%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 51%;
|
||||
--warning: 38 92% 52%;
|
||||
--success: 142 54% 42%;
|
||||
--info: 214 84% 62%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.18);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function GET() {
|
||||
return Response.json({ status: "ok", service: "sfera-web" });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type React from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SFERA",
|
||||
description: "Semantic 1C operating workspace"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams
|
||||
}: Readonly<{
|
||||
searchParams?: Promise<{ lang?: string; mode?: string; project?: string; routine?: string }>;
|
||||
}>) {
|
||||
const params = await searchParams;
|
||||
const nextParams = new URLSearchParams();
|
||||
if (params?.lang === "en") {
|
||||
nextParams.set("lang", "en");
|
||||
}
|
||||
for (const key of ["project", "mode", "routine"] as const) {
|
||||
const value = params?.[key];
|
||||
if (value) {
|
||||
nextParams.set(key, value);
|
||||
}
|
||||
}
|
||||
const suffix = nextParams.size > 0 ? `?${nextParams.toString()}` : "";
|
||||
|
||||
redirect(`/editor${suffix}`);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { ProjectSetupClient, type ProjectSetup } from "@/components/project-setup/project-setup-client";
|
||||
import { getProjects, resolveApiUrl } from "@/lib/api";
|
||||
|
||||
export default async function ProjectSettingsPage({
|
||||
searchParams
|
||||
}: Readonly<{ searchParams?: Promise<{ project?: string; new?: string }> }>) {
|
||||
const requestHeaders = await headers();
|
||||
const params = searchParams ? await searchParams : {};
|
||||
if (params.new === "1") {
|
||||
return <ProjectSetupClient initialSetup={newProjectSetup()} />;
|
||||
}
|
||||
const apiUrl = resolveApiUrl(requestHeaders.get("host"));
|
||||
const projectId = normalizeProjectId(params.project);
|
||||
const setup = await loadSetup(apiUrl, projectId);
|
||||
|
||||
return <ProjectSetupClient initialSetup={setup} />;
|
||||
}
|
||||
|
||||
function newProjectSetup(): ProjectSetup {
|
||||
return {
|
||||
project_id: "__new__",
|
||||
status: "NOT_CONFIGURED",
|
||||
message: "Новый проект. Заполните основные параметры и сохраните.",
|
||||
settings: defaultProjectSettings(""),
|
||||
current_source: null,
|
||||
last_import: null,
|
||||
import_history: [],
|
||||
import_sources: []
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSetup(apiUrl: string, projectId: string): Promise<ProjectSetup> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/projects/${encodeURIComponent(projectId)}/setup`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch {
|
||||
const projects = await getProjects(apiUrl).catch(() => []);
|
||||
const fallbackProjectId = projects.at(0)?.project_id ?? "default";
|
||||
if (fallbackProjectId !== projectId) {
|
||||
return loadSetup(apiUrl, fallbackProjectId);
|
||||
}
|
||||
return {
|
||||
project_id: fallbackProjectId,
|
||||
status: "NOT_CONFIGURED",
|
||||
message: "Проект не проиндексирован. Выберите способ получения структуры конфигурации.",
|
||||
settings: defaultProjectSettings("SFERA Project"),
|
||||
current_source: null,
|
||||
last_import: null,
|
||||
import_history: [],
|
||||
import_sources: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProjectSettings(name = "SFERA Project") {
|
||||
return {
|
||||
name,
|
||||
configuration_source: null,
|
||||
structure_source: null,
|
||||
platform_version: null,
|
||||
compatibility_mode: null,
|
||||
extensions: [],
|
||||
environments: {},
|
||||
agent: {},
|
||||
server_import: {},
|
||||
privacy_mode: "METADATA_ONLY",
|
||||
knowledge_sources: [],
|
||||
task_session_policy: {}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectId(projectId?: string) {
|
||||
const trimmed = projectId?.trim();
|
||||
return trimmed || "default";
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Filter, Loader2, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getMetadataTreeChildren, getMetadataTreePath, searchMetadataTree, type MetadataTreeNode } from "@/lib/api";
|
||||
import type { UiLanguage } from "@/lib/i18n";
|
||||
|
||||
type TreeMode = "overview" | "module" | "form" | "properties" | "events" | "versions" | "documentation" | "knowledge" | "learning" | "flowchart";
|
||||
type TreeFilter = "all" | "onec" | "sfera" | "environments";
|
||||
|
||||
type LazyMetadataTreeProps = {
|
||||
apiUrl?: string;
|
||||
className?: string;
|
||||
language: UiLanguage;
|
||||
projectId: string;
|
||||
root: MetadataTreeNode;
|
||||
selectedNode?: MetadataTreeNode | null;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 80;
|
||||
|
||||
const iconFiles: Record<string, string> = {
|
||||
attribute: "attribute.svg",
|
||||
"business-process": "businessProcess.svg",
|
||||
catalog: "catalog.svg",
|
||||
command: "command.svg",
|
||||
common: "common.svg",
|
||||
configuration: "common.svg",
|
||||
constant: "constant.svg",
|
||||
document: "document.svg",
|
||||
enum: "enum.svg",
|
||||
environment: "folder.svg",
|
||||
environments: "folder.svg",
|
||||
event: "eventSubscription.svg",
|
||||
"external-source": "externalDataSource.svg",
|
||||
extension: "folder.svg",
|
||||
form: "form.svg",
|
||||
folder: "folder.svg",
|
||||
"exchange-plan": "exchangePlan.svg",
|
||||
"scheduled-job": "scheduledJob.svg",
|
||||
journal: "documentJournal.svg",
|
||||
layout: "template.svg",
|
||||
metadata: "folder.svg",
|
||||
module: "commonModule.svg",
|
||||
movement: "operation.svg",
|
||||
plan: "chartsOfAccount.svg",
|
||||
processing: "dataProcessor.svg",
|
||||
project: "folder.svg",
|
||||
register: "accumulationRegister.svg",
|
||||
report: "report.svg",
|
||||
role: "role.svg",
|
||||
service: "http.svg",
|
||||
tabular: "tabularSection.svg",
|
||||
table: "tabularSection.svg",
|
||||
task: "task.svg",
|
||||
web: "http.svg"
|
||||
};
|
||||
|
||||
export function LazyMetadataTree({
|
||||
apiUrl,
|
||||
className = "",
|
||||
language,
|
||||
projectId,
|
||||
root,
|
||||
selectedNode,
|
||||
title
|
||||
}: LazyMetadataTreeProps) {
|
||||
const router = useRouter();
|
||||
const ROW_HEIGHT = 24;
|
||||
const OVERSCAN = 8;
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<TreeFilter>("all");
|
||||
const [activeRoutine, setActiveRoutine] = useState<string | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(["main-configuration", "common"]));
|
||||
const [nodesById, setNodesById] = useState(() => {
|
||||
const cachedRoot = typeof window === "undefined" ? null : readCachedTree(projectId);
|
||||
return reindexTree(mergeTreeRoots(root, cachedRoot));
|
||||
});
|
||||
const [searchResults, setSearchResults] = useState<MetadataTreeNode[]>([]);
|
||||
const [searchTotal, setSearchTotal] = useState(0);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: MetadataTreeNode } | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const treeViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const autoExpandedPathKey = useRef<string | null>(null);
|
||||
const selectedScrollKey = useRef<string | null>(null);
|
||||
const lastProjectId = useRef(projectId);
|
||||
const restoredScrollRef = useRef(false);
|
||||
const rootNode = nodesById[root.id] ?? root;
|
||||
const serverSearchActive = query.trim().length >= 2;
|
||||
const visibleChildren = useMemo(() => {
|
||||
const source = serverSearchActive ? searchResults : filterTreeChildren(rootNode.children, query);
|
||||
return applyTreeFilter(source, activeFilter);
|
||||
}, [activeFilter, query, rootNode.children, searchResults, serverSearchActive]);
|
||||
const visibleCount = visibleChildren.length;
|
||||
const virtualStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||
const virtualEnd = Math.min(visibleCount, Math.ceil((scrollTop + Math.max(viewportHeight, ROW_HEIGHT)) / ROW_HEIGHT) + OVERSCAN);
|
||||
const virtualItems = visibleChildren.slice(virtualStart, virtualEnd);
|
||||
const topSpacer = virtualStart * ROW_HEIGHT;
|
||||
const bottomSpacer = Math.max(0, (visibleCount - virtualEnd) * ROW_HEIGHT);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setActiveRoutine(params.get("routine"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setNodesById((current) => {
|
||||
const cachedRoot = readCachedTree(projectId);
|
||||
const currentRoot = current[root.id];
|
||||
const mergedRoot = mergeTreeRoots(root, cachedRoot ?? currentRoot);
|
||||
return reindexTree(mergedRoot);
|
||||
});
|
||||
autoExpandedPathKey.current = null;
|
||||
selectedScrollKey.current = null;
|
||||
if (lastProjectId.current !== projectId) {
|
||||
setScrollTop(0);
|
||||
lastProjectId.current = projectId;
|
||||
restoredScrollRef.current = false;
|
||||
}
|
||||
}, [projectId, root]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaults = new Set(["main-configuration", "common"]);
|
||||
try {
|
||||
const saved = window.localStorage.getItem(expandedStorageKey(projectId));
|
||||
setExpandedIds(saved ? new Set(JSON.parse(saved) as string[]) : defaults);
|
||||
} catch {
|
||||
setExpandedIds(defaults);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = Number(window.localStorage.getItem(scrollStorageKey(projectId)) ?? "0");
|
||||
if (!Number.isFinite(saved) || saved <= 0) {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
const viewport = treeViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
viewport.scrollTop = saved;
|
||||
setScrollTop(saved);
|
||||
restoredScrollRef.current = true;
|
||||
});
|
||||
} catch {
|
||||
// Ignore local storage failures; the tree remains usable without persisted scroll.
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(expandedStorageKey(projectId), JSON.stringify([...expandedIds]));
|
||||
}, [expandedIds, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const rootForCache = nodesById[root.id];
|
||||
if (rootForCache) {
|
||||
writeCachedTree(projectId, rootForCache);
|
||||
}
|
||||
}, [nodesById, projectId, root.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("keydown", close);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("keydown", close);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = treeViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const syncViewport = () => {
|
||||
setViewportHeight(viewport.clientHeight);
|
||||
setScrollTop(viewport.scrollTop);
|
||||
try {
|
||||
window.localStorage.setItem(scrollStorageKey(projectId), String(viewport.scrollTop));
|
||||
} catch {
|
||||
// Ignore local storage failures; scroll persistence is a convenience.
|
||||
}
|
||||
};
|
||||
syncViewport();
|
||||
const observer = new ResizeObserver(syncViewport);
|
||||
observer.observe(viewport);
|
||||
viewport.addEventListener("scroll", syncViewport, { passive: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
viewport.removeEventListener("scroll", syncViewport);
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNode?.id) {
|
||||
return;
|
||||
}
|
||||
const pathKey = `${projectId}:${selectedNode.id}`;
|
||||
if (autoExpandedPathKey.current === pathKey) {
|
||||
return;
|
||||
}
|
||||
autoExpandedPathKey.current = pathKey;
|
||||
let cancelled = false;
|
||||
const expandSelectedPath = async () => {
|
||||
try {
|
||||
const pathResponse = await getMetadataTreePath(projectId, selectedNode.id, { apiUrl });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setExpandedIds((current) => new Set([...current, ...pathResponse.path.slice(0, -1)]));
|
||||
let localRoot = nodesById[root.id] ?? root;
|
||||
let localIndex = reindexTree(localRoot);
|
||||
for (const step of pathResponse.steps) {
|
||||
const parentId = step.parent_id;
|
||||
const childId = step.child_id;
|
||||
const pageOffset = Math.floor(step.offset / PAGE_SIZE) * PAGE_SIZE;
|
||||
let parent = localIndex[parentId];
|
||||
if (parent && !parent.children.some((child) => child.id === childId) && parent.has_more) {
|
||||
const response = await getMetadataTreeChildren(projectId, parentId, {
|
||||
apiUrl,
|
||||
offset: pageOffset,
|
||||
limit: PAGE_SIZE
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
localRoot = mergeTreeChildren(localRoot, parentId, response.children, response.has_more, response.total, response.offset);
|
||||
localIndex = reindexTree(localRoot);
|
||||
parent = localIndex[parentId];
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setNodesById(reindexTree(localRoot));
|
||||
}
|
||||
} catch {
|
||||
// The pinned current object still gives a stable navigation target if a path cannot be resolved.
|
||||
}
|
||||
};
|
||||
void expandSelectedPath();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [apiUrl, nodesById, projectId, root, selectedNode?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNode?.id || selectedScrollKey.current === selectedNode.id) {
|
||||
return;
|
||||
}
|
||||
if (restoredScrollRef.current) {
|
||||
selectedScrollKey.current = selectedNode.id;
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const selectedElement = document.querySelector(`[data-tree-node-id="${escapeCssAttribute(selectedNode.id)}"]`);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
selectedScrollKey.current = selectedNode.id;
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [nodesById, selectedNode?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedQuery = query.trim();
|
||||
if (normalizedQuery.length < 2) {
|
||||
setSearchResults([]);
|
||||
setSearchTotal(0);
|
||||
setSearchError(null);
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
setSearchLoading(true);
|
||||
setSearchError(null);
|
||||
try {
|
||||
const response = await searchMetadataTree(projectId, normalizedQuery, { apiUrl, limit: 80 });
|
||||
if (!controller.signal.aborted) {
|
||||
setSearchResults(response.results);
|
||||
setSearchTotal(response.total);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setSearchError(error instanceof Error ? error.message : "Search failed");
|
||||
setSearchResults([]);
|
||||
setSearchTotal(0);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [apiUrl, projectId, query]);
|
||||
|
||||
const setNodeExpanded = (nodeId: string, expanded: boolean) => {
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) {
|
||||
next.add(nodeId);
|
||||
} else {
|
||||
next.delete(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateNode = (nodeId: string, updater: (node: MetadataTreeNode) => MetadataTreeNode) => {
|
||||
setNodesById((current) => {
|
||||
const target = current[nodeId];
|
||||
if (!target) {
|
||||
return current;
|
||||
}
|
||||
const updated = updater(target);
|
||||
return reindexTree(replaceTreeNode(current[root.id] ?? rootNode, updated));
|
||||
});
|
||||
};
|
||||
|
||||
const mergeChildren = (nodeId: string, children: MetadataTreeNode[], hasMore: boolean, total: number, offset?: number) => {
|
||||
updateNode(nodeId, (node) => {
|
||||
return mergeNodeChildren(node, children, hasMore, total, offset);
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToNode = (href: string) => {
|
||||
if (href !== "#") {
|
||||
persistViewportState(projectId, treeViewportRef.current, expandedIds);
|
||||
const [, query = ""] = href.split("?");
|
||||
setActiveRoutine(new URLSearchParams(query).get("routine"));
|
||||
router.push(href, { scroll: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={["flex min-h-0 flex-col", className].join(" ")}>
|
||||
<div className="border-b border-border bg-[#efeddc] px-2 py-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<TreeIcon icon="configuration" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{projectId}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex h-7 items-center gap-1 border-y border-border/70 px-1 text-muted-foreground">
|
||||
{["+", "✎", "×", "↑", "↓", "⟳", "⚙"].map((item) => (
|
||||
<button className="flex h-5 w-5 items-center justify-center rounded border border-transparent text-xs hover:border-border hover:bg-card" key={item} type="button">
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="mt-2 flex min-w-0 items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<Search className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={language === "ru" ? "Поиск в дереве" : "Search tree"}
|
||||
value={query}
|
||||
/>
|
||||
{searchLoading ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden="true" /> : <Filter className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
|
||||
</label>
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px]">
|
||||
<FilterButton active={activeFilter === "all"} label={language === "ru" ? "Все" : "All"} onClick={() => setActiveFilter("all")} value="all" />
|
||||
<FilterButton active={activeFilter === "onec"} label="1C" onClick={() => setActiveFilter("onec")} value="onec" />
|
||||
<FilterButton active={activeFilter === "sfera"} label="SFERA" onClick={() => setActiveFilter("sfera")} value="sfera" />
|
||||
<FilterButton
|
||||
active={activeFilter === "environments"}
|
||||
label={language === "ru" ? "Среды" : "Environments"}
|
||||
onClick={() => setActiveFilter("environments")}
|
||||
value="environments"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedNode ? (
|
||||
<div className="border-b border-border bg-[#fffceb] px-2 py-2">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{language === "ru" ? "Текущий объект" : "Current Object"}
|
||||
</div>
|
||||
<a
|
||||
className="mt-1 flex min-h-7 min-w-0 items-center gap-1.5 border border-border bg-background px-1.5 text-sm text-primary hover:bg-[#e9f1ff]"
|
||||
href={hrefForNode(language, projectId, selectedNode)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToNode(hrefForNode(language, projectId, selectedNode));
|
||||
}}
|
||||
title={selectedNode.qualified_name ?? selectedNode.label}
|
||||
>
|
||||
<TreeIcon icon={selectedNode.icon} />
|
||||
<span className="min-w-0 flex-1 truncate">{selectedNode.qualified_name ?? selectedNode.label}</span>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto px-1 py-2" data-virtual-scroll ref={treeViewportRef}>
|
||||
{searchError ? <div className="px-2 py-1 text-xs text-destructive">{searchError}</div> : null}
|
||||
{topSpacer > 0 ? <div aria-hidden="true" style={{ height: `${topSpacer}px` }} /> : null}
|
||||
{virtualItems.map((node) => (
|
||||
<LazyTreeNode
|
||||
activeRoutine={activeRoutine}
|
||||
apiUrl={apiUrl}
|
||||
key={node.id}
|
||||
language={language}
|
||||
mergeChildren={mergeChildren}
|
||||
node={node}
|
||||
projectId={projectId}
|
||||
query={query}
|
||||
expandedIds={expandedIds}
|
||||
openContextMenu={setContextMenu}
|
||||
navigateToNode={navigateToNode}
|
||||
searchMode={serverSearchActive}
|
||||
selectedNodeId={selectedNode?.id}
|
||||
setNodeExpanded={setNodeExpanded}
|
||||
/>
|
||||
))}
|
||||
{bottomSpacer > 0 ? <div aria-hidden="true" style={{ height: `${bottomSpacer}px` }} /> : null}
|
||||
</div>
|
||||
{contextMenu ? (
|
||||
<TreeContextMenu
|
||||
language={language}
|
||||
navigateToNode={navigateToNode}
|
||||
node={contextMenu.node}
|
||||
projectId={projectId}
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
/>
|
||||
) : null}
|
||||
<div className="border-t border-border bg-[#f7f5e7] px-2 py-1 text-[11px] text-muted-foreground">
|
||||
{query
|
||||
? language === "ru"
|
||||
? serverSearchActive
|
||||
? `Найдено во всем проекте: ${formatCount(searchTotal, language)}`
|
||||
: `Найдено в загруженных узлах: ${visibleChildren.length}`
|
||||
: serverSearchActive
|
||||
? `Matches in project: ${formatCount(searchTotal, language)}`
|
||||
: `Matches in loaded nodes: ${visibleChildren.length}`
|
||||
: language === "ru"
|
||||
? "Ветки загружаются при раскрытии"
|
||||
: "Branches load on expand"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LazyTreeNode({
|
||||
activeRoutine,
|
||||
apiUrl,
|
||||
expandedIds,
|
||||
language,
|
||||
mergeChildren,
|
||||
navigateToNode,
|
||||
node,
|
||||
openContextMenu,
|
||||
projectId,
|
||||
query,
|
||||
searchMode,
|
||||
selectedNodeId,
|
||||
setNodeExpanded
|
||||
}: {
|
||||
activeRoutine: string | null;
|
||||
apiUrl?: string;
|
||||
expandedIds: Set<string>;
|
||||
language: UiLanguage;
|
||||
mergeChildren: (nodeId: string, children: MetadataTreeNode[], hasMore: boolean, total: number, offset?: number) => void;
|
||||
navigateToNode: (href: string) => void;
|
||||
node: MetadataTreeNode;
|
||||
openContextMenu: (menu: { x: number; y: number; node: MetadataTreeNode }) => void;
|
||||
projectId: string;
|
||||
query: string;
|
||||
searchMode: boolean;
|
||||
selectedNodeId?: string;
|
||||
setNodeExpanded: (nodeId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const expanded = expandedIds.has(node.id);
|
||||
const childNodes = useMemo(() => filterTreeChildren(node.children, query), [node.children, query]);
|
||||
const canExpand = node.children.length > 0 || node.has_more;
|
||||
const title = node.count > 0 ? `${node.label} (${formatCount(node.count, language)})` : node.label;
|
||||
const isActive = node.id === selectedNodeId || Boolean(activeRoutine && (node.qualified_name === activeRoutine || node.label === activeRoutine));
|
||||
const loadedLabel =
|
||||
node.has_more && node.count > 0
|
||||
? `${formatCount(node.loaded_count || node.children.length, language)}/${formatCount(node.count, language)}`
|
||||
: null;
|
||||
|
||||
async function loadMore() {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await getMetadataTreeChildren(projectId, node.id, {
|
||||
apiUrl,
|
||||
offset: node.loaded_count || node.children.length,
|
||||
limit: PAGE_SIZE
|
||||
});
|
||||
mergeChildren(node.id, response.children, response.has_more, response.total, response.offset);
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "Не удалось загрузить ветку");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (searchMode) {
|
||||
return;
|
||||
}
|
||||
const nextExpanded = !expanded;
|
||||
setNodeExpanded(node.id, nextExpanded);
|
||||
if (nextExpanded && node.children.length === 0 && node.has_more) {
|
||||
await loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
const row = (
|
||||
<div
|
||||
className={[
|
||||
"flex min-h-6 min-w-0 items-center gap-1.5 px-1.5 text-sm text-foreground hover:bg-[#e9f1ff]",
|
||||
isActive ? "bg-[#dbeafe] text-primary" : ""
|
||||
].join(" ")}
|
||||
data-tree-node-id={node.id}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
openContextMenu({ x: event.clientX, y: event.clientY, node });
|
||||
}}
|
||||
onClick={(event) => {
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (target?.closest("button,a")) {
|
||||
return;
|
||||
}
|
||||
const href = hrefForNode(language, projectId, node);
|
||||
navigateToNode(href);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
const href = hrefForNode(language, projectId, node);
|
||||
if (href !== "#") {
|
||||
event.preventDefault();
|
||||
navigateToNode(href);
|
||||
}
|
||||
}}
|
||||
title={node.qualified_name ?? node.label}
|
||||
>
|
||||
<button
|
||||
aria-label={expanded ? "Свернуть" : "Развернуть"}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center"
|
||||
disabled={!canExpand || searchMode}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void toggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{canExpand ? (
|
||||
loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronRight className={["h-3.5 w-3.5 transition", expanded ? "rotate-90" : ""].join(" ")} />
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<TreeIcon icon={node.icon} />
|
||||
<a
|
||||
className="min-w-0 flex-1 truncate"
|
||||
href={hrefForNode(language, projectId, node)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToNode(hrefForNode(language, projectId, node));
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
{loadedLabel ? <span className="rounded border border-border bg-background px-1 text-[10px] text-muted-foreground">{loadedLabel}</span> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{row}
|
||||
{expanded ? (
|
||||
<div className="ml-4 border-l border-border/70 pl-1">
|
||||
{childNodes.map((child) => (
|
||||
<LazyTreeNode
|
||||
activeRoutine={activeRoutine}
|
||||
apiUrl={apiUrl}
|
||||
key={child.id}
|
||||
language={language}
|
||||
mergeChildren={mergeChildren}
|
||||
navigateToNode={navigateToNode}
|
||||
node={child}
|
||||
openContextMenu={openContextMenu}
|
||||
projectId={projectId}
|
||||
query={query}
|
||||
expandedIds={expandedIds}
|
||||
searchMode={searchMode}
|
||||
selectedNodeId={selectedNodeId}
|
||||
setNodeExpanded={setNodeExpanded}
|
||||
/>
|
||||
))}
|
||||
{node.has_more ? (
|
||||
<button className="ml-6 mt-1 h-6 px-2 text-xs text-primary hover:bg-[#e9f1ff]" disabled={loading} onClick={loadMore} type="button">
|
||||
{loading ? (language === "ru" ? "Загрузка..." : "Loading...") : (language === "ru" ? "Загрузить еще" : "Load more")}
|
||||
</button>
|
||||
) : null}
|
||||
{loadError ? <div className="ml-6 px-2 py-1 text-xs text-destructive">{loadError}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeContextMenu({
|
||||
language,
|
||||
navigateToNode,
|
||||
node,
|
||||
projectId,
|
||||
x,
|
||||
y
|
||||
}: {
|
||||
language: UiLanguage;
|
||||
navigateToNode: (href: string) => void;
|
||||
node: MetadataTreeNode;
|
||||
projectId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
const openHref = hrefForNode(language, projectId, node);
|
||||
const items = contextMenuItemsForNode(node, openHref, language);
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 w-56 border border-border bg-[#3f3f3f] py-1 text-sm text-white shadow-xl"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<div className="border-b border-white/10 px-3 py-2 text-xs text-white/70">{node.qualified_name ?? node.label}</div>
|
||||
{items.map((item) => (
|
||||
<a
|
||||
className="block px-3 py-2 hover:bg-white/10"
|
||||
href={item.href}
|
||||
key={item.label}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToNode(item.href);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function contextMenuItemsForNode(node: MetadataTreeNode, openHref: string, language: UiLanguage) {
|
||||
const labels = {
|
||||
open: language === "ru" ? "Открыть" : "Open",
|
||||
openModule: language === "ru" ? "Открыть код" : "Open code",
|
||||
openHandler: language === "ru" ? "Открыть обработчик" : "Open handler",
|
||||
openForm: language === "ru" ? "Открыть форму" : "Open form",
|
||||
properties: language === "ru" ? "Свойства" : "Properties",
|
||||
versions: language === "ru" ? "Версии" : "Versions",
|
||||
impact: language === "ru" ? "Связи и влияние" : "Impact",
|
||||
knowledge: language === "ru" ? "Знания объекта" : "Object knowledge"
|
||||
};
|
||||
const value = `${node.kind} ${node.label} ${node.icon}`.toLocaleLowerCase("ru-RU");
|
||||
const items = [{ label: labels.open, href: openHref }];
|
||||
if (value.includes("module") || value.includes("модул") || value.includes("procedure") || value.includes("function") || value.includes("процед") || value.includes("функц")) {
|
||||
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.versions, href: withMode(openHref, "versions") });
|
||||
return items;
|
||||
}
|
||||
if (value.includes("command") || value.includes("команд") || value.includes("event") || value.includes("событ")) {
|
||||
items.push({ label: labels.openHandler, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
return items;
|
||||
}
|
||||
if (value.includes("form") || value.includes("форма")) {
|
||||
items.push({ label: labels.openForm, href: withMode(openHref, "form") });
|
||||
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
return items;
|
||||
}
|
||||
if (node.qualified_name) {
|
||||
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
items.push({ label: labels.versions, href: withMode(openHref, "versions") });
|
||||
items.push({ label: labels.impact, href: withMode(openHref, "documentation") });
|
||||
items.push({ label: labels.knowledge, href: withMode(openHref, "knowledge") });
|
||||
return items;
|
||||
}
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
return items;
|
||||
}
|
||||
|
||||
function TreeIcon({ icon }: { icon: string }) {
|
||||
const file = iconFiles[icon] ?? iconFiles.folder;
|
||||
return <img alt="" aria-hidden="true" className="h-4 w-4 shrink-0" src={`/icons/1c-metadata/light/${file}`} />;
|
||||
}
|
||||
|
||||
function FilterButton({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
value
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
value: TreeFilter;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={[
|
||||
"h-6 border px-2",
|
||||
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-card"
|
||||
].join(" ")}
|
||||
data-lazy-tree-filter={value}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function hrefForNode(language: UiLanguage, projectId: string, node: MetadataTreeNode) {
|
||||
if (!node.qualified_name && node.kind !== "SFERA_SECTION") {
|
||||
return "#";
|
||||
}
|
||||
if (node.kind === "SFERA_SECTION" && node.label === "Обзор проекта") {
|
||||
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=overview`;
|
||||
}
|
||||
if (node.kind === "SFERA_SECTION" && node.label === "Блок-схема") {
|
||||
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=flowchart`;
|
||||
}
|
||||
const mode = modeForNode(node);
|
||||
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=${mode}&routine=${encodeURIComponent(node.qualified_name ?? node.label)}`;
|
||||
}
|
||||
|
||||
function withMode(href: string, mode: TreeMode) {
|
||||
if (href === "#") {
|
||||
return href;
|
||||
}
|
||||
const [path, query = ""] = href.split("?");
|
||||
const params = new URLSearchParams(query);
|
||||
params.set("mode", mode);
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function modeForNode(node: MetadataTreeNode): TreeMode {
|
||||
const value = `${node.kind} ${node.label} ${node.icon}`.toLocaleLowerCase("ru-RU");
|
||||
if (value.includes("form") || value.includes("форма")) {
|
||||
return "form";
|
||||
}
|
||||
if (value.includes("event") || value.includes("событ")) {
|
||||
return "events";
|
||||
}
|
||||
if (value.includes("version") || value.includes("верс")) {
|
||||
return "versions";
|
||||
}
|
||||
if (value.includes("knowledge") || value.includes("знан")) {
|
||||
return "knowledge";
|
||||
}
|
||||
if (value.includes("flowchart") || value.includes("блок-схем")) {
|
||||
return "flowchart";
|
||||
}
|
||||
return "module";
|
||||
}
|
||||
|
||||
function filterTreeChildren(children: MetadataTreeNode[], query: string) {
|
||||
const normalized = query.trim().toLocaleLowerCase("ru-RU");
|
||||
if (!normalized) {
|
||||
return children;
|
||||
}
|
||||
return children
|
||||
.map((child) => filterTreeNode(child, normalized))
|
||||
.filter((child): child is MetadataTreeNode => Boolean(child));
|
||||
}
|
||||
|
||||
function filterTreeNode(node: MetadataTreeNode, normalizedQuery: string): MetadataTreeNode | null {
|
||||
const selfMatches =
|
||||
node.label.toLocaleLowerCase("ru-RU").includes(normalizedQuery) ||
|
||||
(node.qualified_name?.toLocaleLowerCase("ru-RU").includes(normalizedQuery) ?? false);
|
||||
const children = node.children
|
||||
.map((child) => filterTreeNode(child, normalizedQuery))
|
||||
.filter((child): child is MetadataTreeNode => Boolean(child));
|
||||
if (!selfMatches && children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { ...node, children };
|
||||
}
|
||||
|
||||
function indexTree(root: MetadataTreeNode) {
|
||||
return reindexTree(root);
|
||||
}
|
||||
|
||||
function reindexTree(root: MetadataTreeNode) {
|
||||
const result: Record<string, MetadataTreeNode> = {};
|
||||
const visit = (node: MetadataTreeNode) => {
|
||||
result[node.id] = node;
|
||||
node.children.forEach(visit);
|
||||
};
|
||||
visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeTreeRoots(base: MetadataTreeNode, preserved?: MetadataTreeNode | null): MetadataTreeNode {
|
||||
if (!preserved || base.id !== preserved.id) {
|
||||
return base;
|
||||
}
|
||||
const preservedById = reindexTree(preserved);
|
||||
const mergeNode = (node: MetadataTreeNode): MetadataTreeNode => {
|
||||
const cached = preservedById[node.id];
|
||||
const cachedChildren = cached?.children ?? [];
|
||||
const mergedChildrenById = new Map<string, MetadataTreeNode>();
|
||||
for (const child of node.children) {
|
||||
mergedChildrenById.set(child.id, mergeNode(child));
|
||||
}
|
||||
for (const child of cachedChildren) {
|
||||
if (!mergedChildrenById.has(child.id)) {
|
||||
mergedChildrenById.set(child.id, child);
|
||||
}
|
||||
}
|
||||
const nextChildren = [...mergedChildrenById.values()];
|
||||
const cachedLoaded = cached?.loaded_count ?? cachedChildren.length;
|
||||
const nodeLoaded = node.loaded_count ?? node.children.length;
|
||||
return {
|
||||
...node,
|
||||
children: nextChildren,
|
||||
loaded_count: Math.max(nodeLoaded, cachedLoaded, nextChildren.length),
|
||||
has_more: cached ? node.has_more && cached.has_more : node.has_more,
|
||||
count: Math.max(node.count ?? 0, cached?.count ?? 0)
|
||||
};
|
||||
};
|
||||
return mergeNode(base);
|
||||
}
|
||||
|
||||
function replaceTreeNode(root: MetadataTreeNode, replacement: MetadataTreeNode): MetadataTreeNode {
|
||||
if (root.id === replacement.id) {
|
||||
return replacement;
|
||||
}
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map((child) => replaceTreeNode(child, replacement))
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTreeChildren(
|
||||
root: MetadataTreeNode,
|
||||
nodeId: string,
|
||||
children: MetadataTreeNode[],
|
||||
hasMore: boolean,
|
||||
total: number,
|
||||
offset = 0
|
||||
): MetadataTreeNode {
|
||||
const target = reindexTree(root)[nodeId];
|
||||
if (!target) {
|
||||
return root;
|
||||
}
|
||||
return replaceTreeNode(root, mergeNodeChildren(target, children, hasMore, total, offset));
|
||||
}
|
||||
|
||||
function mergeNodeChildren(
|
||||
node: MetadataTreeNode,
|
||||
children: MetadataTreeNode[],
|
||||
hasMore: boolean,
|
||||
total: number,
|
||||
offset = node.children.length
|
||||
): MetadataTreeNode {
|
||||
const existingIds = new Set(node.children.map((child) => child.id));
|
||||
const nextChildren = [...node.children, ...children.filter((child) => !existingIds.has(child.id))];
|
||||
return {
|
||||
...node,
|
||||
children: nextChildren,
|
||||
count: total || node.count,
|
||||
loaded_count: Math.max(node.loaded_count || 0, offset + children.length, nextChildren.length),
|
||||
has_more: hasMore
|
||||
};
|
||||
}
|
||||
|
||||
function formatCount(value: number, language: UiLanguage) {
|
||||
return new Intl.NumberFormat(language === "ru" ? "ru-RU" : "en-US").format(value);
|
||||
}
|
||||
|
||||
function expandedStorageKey(projectId: string) {
|
||||
return `sfera.metadata-tree.expanded.${projectId}`;
|
||||
}
|
||||
|
||||
function scrollStorageKey(projectId: string) {
|
||||
return `sfera.metadata-tree.scroll.${projectId}`;
|
||||
}
|
||||
|
||||
function treeCacheStorageKey(projectId: string) {
|
||||
return `sfera.metadata-tree.cache.${projectId}`;
|
||||
}
|
||||
|
||||
function readCachedTree(projectId: string) {
|
||||
try {
|
||||
const payload = window.sessionStorage.getItem(treeCacheStorageKey(projectId));
|
||||
return payload ? (JSON.parse(payload) as MetadataTreeNode) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedTree(projectId: string, root: MetadataTreeNode) {
|
||||
try {
|
||||
window.sessionStorage.setItem(treeCacheStorageKey(projectId), JSON.stringify(root));
|
||||
} catch {
|
||||
try {
|
||||
window.sessionStorage.removeItem(treeCacheStorageKey(projectId));
|
||||
} catch {
|
||||
// Ignore storage failures; server paging still works.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistViewportState(projectId: string, viewport: HTMLDivElement | null, expandedIds: Set<string>) {
|
||||
try {
|
||||
window.localStorage.setItem(expandedStorageKey(projectId), JSON.stringify([...expandedIds]));
|
||||
if (viewport) {
|
||||
window.localStorage.setItem(scrollStorageKey(projectId), String(viewport.scrollTop));
|
||||
}
|
||||
} catch {
|
||||
// Ignore local storage failures; this only affects visual state restoration.
|
||||
}
|
||||
}
|
||||
|
||||
function applyTreeFilter(children: MetadataTreeNode[], filter: TreeFilter) {
|
||||
if (filter === "all") {
|
||||
return children;
|
||||
}
|
||||
if (filter === "sfera") {
|
||||
return children.filter((child) => child.kind === "SFERA_ROOT" || child.kind === "SFERA_SECTION" || child.label === "SFERA");
|
||||
}
|
||||
if (filter === "environments") {
|
||||
return children.filter((child) => child.kind === "ENVIRONMENTS" || child.label === "Среды");
|
||||
}
|
||||
return children.filter((child) => {
|
||||
if (child.kind === "MAIN_CONFIGURATION" || child.kind === "EXTENSION" || child.kind === "CONTEXT_CONFIGURATION" || child.kind === "REFERENCE_CONFIGURATION") {
|
||||
return true;
|
||||
}
|
||||
const lower = child.label.toLocaleLowerCase("ru-RU");
|
||||
return lower.includes("конфигурац") || lower.includes("расширение");
|
||||
});
|
||||
}
|
||||
|
||||
function escapeCssAttribute(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
FolderPlus,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UserCircle
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { messages, type UiLanguage } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const environmentProjectStorageKey = "sfera.environment.selected-project";
|
||||
|
||||
export type ProjectOption = {
|
||||
project_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const languageOptions = [
|
||||
{ language: "ru", labelKey: "languageRu" },
|
||||
{ language: "en", labelKey: "languageEn" }
|
||||
] as const;
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
apiStatus,
|
||||
language = "ru",
|
||||
projectId,
|
||||
projectOptions = []
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
apiStatus: "ok" | "error";
|
||||
language?: UiLanguage;
|
||||
projectId?: string;
|
||||
projectOptions?: ProjectOption[];
|
||||
}>) {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const urlProjectId = url.searchParams.get("project")?.trim();
|
||||
const savedProjectId = window.localStorage.getItem(environmentProjectStorageKey)?.trim();
|
||||
if (urlProjectId) {
|
||||
window.localStorage.setItem(environmentProjectStorageKey, urlProjectId);
|
||||
return;
|
||||
}
|
||||
if (savedProjectId && savedProjectId !== projectId) {
|
||||
url.searchParams.set("project", savedProjectId);
|
||||
if (language === "en") {
|
||||
url.searchParams.set("lang", "en");
|
||||
}
|
||||
window.location.replace(url.toString());
|
||||
return;
|
||||
}
|
||||
if (projectId) {
|
||||
window.localStorage.setItem(environmentProjectStorageKey, projectId);
|
||||
}
|
||||
}, [language, projectId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TopEnvironmentBar apiStatus={apiStatus} language={language} projectId={projectId} projectOptions={projectOptions} />
|
||||
{children}
|
||||
<EnvironmentStatusBar apiStatus={apiStatus} language={language} projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopEnvironmentBar({
|
||||
agentStatusLabel,
|
||||
agentStatusTone = "info",
|
||||
apiStatus,
|
||||
language = "ru",
|
||||
onProjectChange,
|
||||
projectId,
|
||||
projectOptions = [],
|
||||
statusLabel
|
||||
}: Readonly<{
|
||||
agentStatusLabel?: string;
|
||||
agentStatusTone?: "success" | "warning" | "danger" | "info" | "neutral";
|
||||
apiStatus: "ok" | "error";
|
||||
language?: UiLanguage;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
projectId?: string;
|
||||
projectOptions?: ProjectOption[];
|
||||
statusLabel?: string;
|
||||
}>) {
|
||||
const t = messages[language];
|
||||
const [globalSearch, setGlobalSearch] = useState("");
|
||||
const projectIds = projectOptions.map((project) => project.project_id);
|
||||
const projects = projectIds.includes(projectId ?? "")
|
||||
? projectOptions
|
||||
: ([projectId ? { project_id: projectId, name: projectId } : null, ...projectOptions].filter(Boolean) as ProjectOption[]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="flex h-14 items-center gap-2 px-3 sm:px-4" data-top-project-bar>
|
||||
<a className="flex h-9 shrink-0 items-center gap-2 rounded-md px-2 text-sm font-semibold text-foreground" data-top-bar-logo href={editorHref(projectId, language)}>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
SFERA
|
||||
</a>
|
||||
<TopSelector className="hidden xl:flex" dataMarker="workspace" label={t.workspaceSelector} value="Рабочее пространство" />
|
||||
<ProjectSelector
|
||||
currentProjectId={projectId}
|
||||
language={language}
|
||||
label={t.projectSelector}
|
||||
onProjectChange={onProjectChange}
|
||||
projects={projects}
|
||||
/>
|
||||
<TopActionLink className="hidden lg:inline-flex" dataMarker="project-settings" href={projectSettingsHref(projectId)} icon={Settings} label={t.projectSettings} />
|
||||
<TopActionLink className="hidden xl:inline-flex" dataMarker="create-project" href="/project-settings?new=1" icon={FolderPlus} label={t.createProject} />
|
||||
<TopSelector className="hidden lg:flex" dataMarker="environment" label={t.environmentSelector} value="Dev" />
|
||||
<TopSelector className="hidden 2xl:flex" dataMarker="active-task" label={t.activeTaskSelector} value={t.none} />
|
||||
<label className="flex min-w-[220px] flex-1 items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground" data-global-search>
|
||||
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
aria-label={t.searchPlaceholder}
|
||||
className="h-5 min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground"
|
||||
data-global-search-input
|
||||
onChange={(event) => setGlobalSearch(event.target.value)}
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={globalSearch}
|
||||
/>
|
||||
<span className="hidden rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground lg:inline">Ctrl+K</span>
|
||||
</label>
|
||||
{statusLabel ? <Badge data-top-bar-badge="work-status">{statusLabel}</Badge> : null}
|
||||
<Badge data-top-bar-badge="api-status" tone={apiStatus === "ok" ? "success" : "danger"}>
|
||||
{apiStatus === "ok" ? t.apiOnline : t.apiOffline}
|
||||
</Badge>
|
||||
<Badge data-top-bar-badge="agent-status" tone={agentStatusTone} className="hidden xl:inline-flex">
|
||||
<Bot className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{agentStatusLabel ?? t.agentOnline}
|
||||
</Badge>
|
||||
<Button aria-label={t.notifications} className="hidden w-9 px-0 lg:inline-flex" data-top-bar-button="notifications" variant="ghost">
|
||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="hidden h-9 items-center rounded-md border border-border bg-card p-1 sm:flex" data-top-bar-language>
|
||||
{languageOptions.map((option) => (
|
||||
<a
|
||||
className={cn(
|
||||
"flex h-7 items-center rounded px-2 text-xs font-medium transition",
|
||||
language === option.language
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
href={editorHref(projectId, option.language)}
|
||||
key={option.language}
|
||||
>
|
||||
{t[option.labelKey]}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<Button data-top-bar-button="profile" variant="primary">
|
||||
<UserCircle className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">{t.profile}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentStatusBar({
|
||||
apiStatus,
|
||||
language = "ru",
|
||||
projectId
|
||||
}: Readonly<{ apiStatus: "ok" | "error"; language?: UiLanguage; projectId?: string }>) {
|
||||
const t = messages[language];
|
||||
return (
|
||||
<footer className="fixed bottom-0 left-0 right-0 z-20 flex h-8 items-center gap-4 overflow-x-auto border-t border-border bg-card px-4 text-xs text-muted-foreground" data-status-bar>
|
||||
<StatusItem label={language === "ru" ? "Проект" : "Project"} value={projectId ?? "не выбран"} marker="project" />
|
||||
<StatusItem label={t.snapshotStatus} value="2026.05.09.001" marker="snapshot" />
|
||||
<StatusItem label={t.agentStatus} value={apiStatus === "ok" ? t.online : t.offline} marker="agent" />
|
||||
<StatusItem label={t.parserStatus} value="OK" marker="parser" />
|
||||
<StatusItem label={t.diagnosticsStatus} value="3" marker="diagnostics" />
|
||||
<StatusItem label={t.taskStatus} value={t.none} marker="active-task" />
|
||||
<StatusItem label={t.privacyStatus} value={t.metadataOnly} marker="privacy" />
|
||||
<StatusItem className="lg:ml-auto" label={t.aiTokens} value="12k/100k" marker="ai-tokens" />
|
||||
<StatusItem label={language === "ru" ? "Пользователь" : "Current user"} value="current-user" marker="current-user" />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSelector({
|
||||
currentProjectId,
|
||||
label,
|
||||
language,
|
||||
onProjectChange,
|
||||
projects
|
||||
}: Readonly<{ currentProjectId?: string; label: string; language: UiLanguage; onProjectChange?: (projectId: string) => void; projects: ProjectOption[] }>) {
|
||||
function switchProject(projectId: string) {
|
||||
if (!projectId || projectId === currentProjectId) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(environmentProjectStorageKey, projectId);
|
||||
if (onProjectChange) {
|
||||
onProjectChange(projectId);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("project", projectId);
|
||||
if (language === "en") {
|
||||
params.set("lang", "en");
|
||||
}
|
||||
window.location.href = `${window.location.pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex h-9 shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground"
|
||||
data-top-bar-selector="project"
|
||||
>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<select
|
||||
aria-label={language === "ru" ? "Выбор проекта" : "Project selector"}
|
||||
className="h-7 max-w-[190px] bg-transparent font-semibold outline-none"
|
||||
onChange={(event) => switchProject(event.target.value)}
|
||||
value={currentProjectId ?? ""}
|
||||
>
|
||||
{projects.length ? null : <option value="">Нет проектов</option>}
|
||||
{projects.map((project) => (
|
||||
<option key={project.project_id} value={project.project_id}>
|
||||
{project.name || project.project_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function projectSettingsHref(projectId?: string) {
|
||||
return projectId ? `/project-settings?project=${encodeURIComponent(projectId)}` : "/project-settings";
|
||||
}
|
||||
|
||||
function editorHref(projectId?: string, language: UiLanguage = "ru") {
|
||||
const params = new URLSearchParams();
|
||||
if (language === "en") {
|
||||
params.set("lang", "en");
|
||||
}
|
||||
if (projectId) {
|
||||
params.set("project", projectId);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/editor?${query}` : "/editor";
|
||||
}
|
||||
|
||||
function StatusItem({
|
||||
className,
|
||||
label,
|
||||
marker,
|
||||
value
|
||||
}: Readonly<{ className?: string; label: string; marker: string; value: string }>) {
|
||||
return (
|
||||
<span className={cn("shrink-0", className)} data-status-item={marker}>
|
||||
{label}: {value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TopSelector({ className, dataMarker, label, value }: Readonly<{ className?: string; dataMarker: string; label: string; value: string }>) {
|
||||
return (
|
||||
<button className={cn("h-9 shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground hover:bg-muted", className)} data-top-bar-selector={dataMarker} type="button">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span>{value}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TopActionLink({
|
||||
className,
|
||||
dataMarker,
|
||||
href,
|
||||
icon: Icon,
|
||||
label
|
||||
}: Readonly<{
|
||||
className?: string;
|
||||
dataMarker: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}>) {
|
||||
return (
|
||||
<a
|
||||
className={cn("h-9 shrink-0 items-center gap-2 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground hover:bg-muted", className)}
|
||||
data-top-bar-action={dataMarker}
|
||||
href={href}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BadgeTone = "neutral" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
neutral: "border-border bg-muted text-muted-foreground",
|
||||
success: "border-success/25 bg-success/10 text-success",
|
||||
warning: "border-warning/30 bg-warning/10 text-warning",
|
||||
danger: "border-destructive/25 bg-destructive/10 text-destructive",
|
||||
info: "border-info/25 bg-info/10 text-info"
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = "neutral",
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & {
|
||||
tone?: BadgeTone;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center rounded-full border px-2.5 text-xs font-medium leading-none",
|
||||
tones[tone],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost";
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "border border-border bg-card text-foreground hover:bg-muted",
|
||||
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className,
|
||||
variant = "secondary",
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: Readonly<React.HTMLAttributes<HTMLElement>>) {
|
||||
return (
|
||||
<section className={cn("rounded-2xl border border-border bg-card p-4 shadow-soft", className)} {...props}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
export type UiLanguage = "ru" | "en";
|
||||
|
||||
export function normalizeLanguage(value: string | undefined): UiLanguage {
|
||||
return value === "en" ? "en" : "ru";
|
||||
}
|
||||
|
||||
export const messages = {
|
||||
ru: {
|
||||
productSubtitle: "Семантическое пространство 1С",
|
||||
nav: {
|
||||
overview: "Обзор",
|
||||
projects: "Проекты 1С",
|
||||
graph: "Граф",
|
||||
objects: "Объекты",
|
||||
review: "Проверка",
|
||||
knowledge: "Знания",
|
||||
patterns: "Паттерны",
|
||||
privacy: "Приватность",
|
||||
aiUsage: "Расход ИИ",
|
||||
operations: "Операции",
|
||||
settings: "Настройки"
|
||||
},
|
||||
searchPlaceholder: "Поиск по 1С объектам, процедурам, знаниям",
|
||||
rights: "Права",
|
||||
projectSettings: "Настройки",
|
||||
createProject: "+ Проект",
|
||||
notifications: "Уведомления",
|
||||
profile: "Профиль",
|
||||
workspaceSelector: "Компания",
|
||||
projectSelector: "Проект",
|
||||
environmentSelector: "Среда",
|
||||
activeTaskSelector: "Задача",
|
||||
languageRu: "Русский",
|
||||
languageEn: "English",
|
||||
commandLanguageNote: "Команды: русский по умолчанию, английский доступен",
|
||||
apiOnline: "API доступен",
|
||||
apiOffline: "API недоступен",
|
||||
overview: "Обзор",
|
||||
projects: "Проекты",
|
||||
objects: "Объекты",
|
||||
configurationTree: "Дерево конфигурации",
|
||||
openWindows: "Открытые окна",
|
||||
projectDashboard: "Обзор проекта 1С",
|
||||
projectDashboardDescription: "Рабочая сводка по выбранной 1С-конфигурации: состояние, проверки, снимки и быстрый переход в открытые объекты.",
|
||||
contextPanel: "Контекст",
|
||||
contextInspector: "Контекстный инспектор",
|
||||
owner: "Владелец",
|
||||
subsystem: "Подсистема",
|
||||
criticality: "Критичность",
|
||||
activeTask: "Активная задача",
|
||||
calls: "Вызовы",
|
||||
riskContext: "Риски и изменения",
|
||||
runtimeIncidents: "Инциденты выполнения",
|
||||
heroBadge: "Семантическое ядро 1С",
|
||||
title: "Операционный контур 1С",
|
||||
subtitle: "Семантический граф, проверка, знания, приватность и управление ИИ в одном рабочем представлении.",
|
||||
review: "Проверка",
|
||||
graph: "Граф 1С",
|
||||
knowledge: "Знания",
|
||||
governance: "Управление",
|
||||
aiPolicy: "Политика ИИ",
|
||||
aiUsage: "Расход ИИ",
|
||||
projectWorkspace: "Рабочее пространство 1С",
|
||||
projectWorkspaceDescription: "Снимок, проверка, знания, UI-формы, интеграции и регламентные задания выбранного 1С-проекта.",
|
||||
selectedProject: "Выбранный проект",
|
||||
openProject: "Открыть проект",
|
||||
openInEditor: "Открыть в редакторе",
|
||||
nodes: "Узлы",
|
||||
edges: "Связи",
|
||||
procedures: "Процедуры",
|
||||
queries: "Запросы",
|
||||
writes: "Записи",
|
||||
reviewFindings: "Замечания проверки",
|
||||
noReviewFindings: "Замечаний нет",
|
||||
severity: "Уровень",
|
||||
finding: "Замечание",
|
||||
source: "Источник",
|
||||
forms: "Формы",
|
||||
commands: "Команды",
|
||||
elements: "Элементы",
|
||||
integrations: "Интеграции",
|
||||
scheduledJobs: "Регламентные задания",
|
||||
knowledgeCoverage: "Покрытие знаниями",
|
||||
covered: "Покрыто",
|
||||
uncovered: "Не покрыто",
|
||||
unsecuredObjects: "Без прав ролей",
|
||||
unownedObjects: "Без владельца",
|
||||
sensitiveFields: "Чувствительные поля",
|
||||
permissionState: "Права доступа",
|
||||
permissionStateDescription: "Текущий экран показывает только чтение семантического состояния; действия изменения будут требовать роли владельца проекта.",
|
||||
commandPalette: "Командная строка",
|
||||
commandPlaceholder: "Найти объект 1С, процедуру или команду",
|
||||
savedView: "Представление",
|
||||
auditTrail: "Аудит",
|
||||
authoringHistory: "История изменений",
|
||||
noAuthoringChanges: "Пока нет сохранённых authoring change-set",
|
||||
version: "Версия",
|
||||
approvedBy: "Подтвердил",
|
||||
aiContext: "Контекст ИИ",
|
||||
model: "Модель",
|
||||
tokenImpact: "Влияние на токены",
|
||||
noProjectData: "Нет данных выбранного проекта",
|
||||
noProjectDataDescription: "Снимок сохранён, но проектные данные ещё не доступны для панели.",
|
||||
ideWorkspace: "IDE 1С",
|
||||
ideWorkspaceDescription: "Современная рабочая среда 1С: модуль, форма, свойства, события, версии, документация, знания, обучение, AI-подсказки и semantic diff.",
|
||||
objectTree: "Дерево объектов",
|
||||
bslEditor: "Редактор BSL",
|
||||
procedureOutline: "Outline процедур",
|
||||
findUsages: "Использования",
|
||||
quickFixes: "Быстрые исправления",
|
||||
insertGuardClause: "Вставить проверку отказа",
|
||||
extractProcedure: "Выделить процедуру",
|
||||
addKnowledgeLink: "Связать со знанием",
|
||||
moduleMode: "Модуль",
|
||||
formMode: "Форма",
|
||||
propertiesMode: "Свойства",
|
||||
eventsMode: "События",
|
||||
versionsMode: "Версии",
|
||||
documentationMode: "Документация",
|
||||
knowledgeMode: "Знания",
|
||||
learningMode: "Обучение",
|
||||
formDesigner: "Дизайнер формы",
|
||||
eventsInspector: "Инспектор событий",
|
||||
knowledgeLearning: "Знания и обучение",
|
||||
knowledgeLearningDescription: "AI связывает текущий объект с документацией, паттернами, историей решений и учебными материалами команды.",
|
||||
postAndClose: "Провести и закрыть",
|
||||
saveAndClose: "Записать и закрыть",
|
||||
save: "Записать",
|
||||
create: "Создать",
|
||||
search: "Поиск",
|
||||
emptyList: "Список пуст",
|
||||
mainSection: "Основное",
|
||||
nameField: "Наименование",
|
||||
code: "Код",
|
||||
comment: "Комментарий",
|
||||
client: "Клиент",
|
||||
agent: "Агент",
|
||||
sites: "Сайты",
|
||||
compensationTerms: "Условия возмещения",
|
||||
agencyAgreements: "Агентские соглашения",
|
||||
telegram: "Телеграм",
|
||||
mail: "Почта",
|
||||
sentToBankCompanyName: "Наименование компании, отправленное в банк",
|
||||
mergeProject: "Мерч проект",
|
||||
legalEntity: "Юр лицо",
|
||||
result: "Результат",
|
||||
author: "Автор",
|
||||
editor: "Редактор",
|
||||
creationDate: "Дата создания",
|
||||
editDate: "Дата редактирования",
|
||||
supplier: "Поставщик",
|
||||
contract: "Договор",
|
||||
documentNumber: "Номер",
|
||||
operation: "Операция",
|
||||
goods: "Товары",
|
||||
services: "Услуги",
|
||||
additional: "Дополнительно",
|
||||
numberSign: "N",
|
||||
item: "Номенклатура",
|
||||
quantity: "Количество",
|
||||
price: "Цена",
|
||||
amount: "Сумма",
|
||||
eventName: "Событие",
|
||||
objectVersioningDescription: "История SFERA хранится на уровне объекта 1С: каждое изменение связано с diff, задачей, автором и rollback-точкой.",
|
||||
documentationModeDescription: "Документация открывается рядом с объектом: назначение, бизнес-правила, связи с формами, отчётами, командами и регламентами.",
|
||||
knowledgeModeDescription: "База знаний связывает текущий объект с паттернами команды, BSP/vendor docs, обсуждениями, решениями и известными рисками.",
|
||||
learningModeDescription: "Обучение показывает, что можно безопасно написать в текущем контексте, какие переменные доступны и какие стандарты команды применимы.",
|
||||
problemsPanel: "Проблемы",
|
||||
outputPanel: "Вывод",
|
||||
testsPanel: "Тесты",
|
||||
referencesPanel: "Ссылки",
|
||||
propertiesInspector: "Инспектор свойств",
|
||||
semanticDiff: "Семантический diff",
|
||||
aiPairProgrammer: "AI-помощник",
|
||||
currentContext: "Текущий контекст",
|
||||
availableVariables: "Доступные переменные",
|
||||
localVariables: "Локальные переменные",
|
||||
objectAttributes: "Реквизиты объекта",
|
||||
tabularSections: "Табличные части",
|
||||
formElements: "Элементы формы",
|
||||
metadataDraft: "Черновик объекта 1С",
|
||||
metadataDraftDescription: "SFERA создаёт объект как версионированный черновик workspace: реквизиты, табличные части, формы и diff без записи в production 1С.",
|
||||
objectKind: "Тип объекта",
|
||||
objectName: "Имя объекта",
|
||||
attributeName: "Имя реквизита",
|
||||
attributeType: "Тип",
|
||||
tabularSectionName: "Имя табличной части",
|
||||
formName: "Имя формы",
|
||||
commandName: "Имя команды",
|
||||
commandHandler: "Обработчик",
|
||||
synonym: "Синоним",
|
||||
requiredFlag: "Обязательный",
|
||||
addAttribute: "Добавить реквизит",
|
||||
addTabularColumn: "Добавить колонку",
|
||||
addForm: "Добавить форму",
|
||||
addCommand: "Добавить команду",
|
||||
applyMetadataDraft: "Создать черновик",
|
||||
suggestedCompletion: "Предложение продолжения",
|
||||
guardedApply: "Безопасное применение",
|
||||
versionPreview: "Preview версии",
|
||||
affectedNodes: "Затронутые узлы",
|
||||
applyBlocked: "Применение заблокировано",
|
||||
workspaceApplyReady: "Можно сохранить в workspace-историю SFERA",
|
||||
productionApplyDisabled: "Запись в production 1С отключена",
|
||||
previewRequired: "Требуется preview",
|
||||
applyToSfera: "Применить в SFERA",
|
||||
rollbackPlan: "План отката",
|
||||
authoringMode: "Режим разработки",
|
||||
workspaceHistoryOnly: "только workspace-история SFERA",
|
||||
impactBeforeApply: "Анализ влияния до применения",
|
||||
reviewBeforeApply: "Проверка до применения",
|
||||
versionKind: "Тип версии",
|
||||
lineage: "Линия версий",
|
||||
parentVersion: "Родительская версия",
|
||||
versionDiff: "Diff версии",
|
||||
taskLabel: "Задача",
|
||||
sessionLabel: "Сессия",
|
||||
fullPayload: "Полные данные",
|
||||
summaryOnly: "Кратко",
|
||||
loading: "Загрузка",
|
||||
rootVersion: "корневая",
|
||||
agentOnline: "Агент онлайн",
|
||||
online: "онлайн",
|
||||
offline: "офлайн",
|
||||
snapshotStatus: "Снимок",
|
||||
agentStatus: "Агент",
|
||||
parserStatus: "Парсер",
|
||||
diagnosticsStatus: "Диагностика",
|
||||
taskStatus: "Задача",
|
||||
privacyStatus: "Приватность",
|
||||
metadataOnly: "только метаданные",
|
||||
sferaProjectTree: "Дерево проекта SFERA",
|
||||
sirObjects: "Объекты SIR",
|
||||
aiHandlers: "AI-обработчики",
|
||||
semanticRules: "Семантические правила",
|
||||
reviewPolicies: "Политики проверки",
|
||||
knowledgeBindings: "Связи знаний",
|
||||
agentCommands: "Команды агентов",
|
||||
rollbackTemplates: "Шаблоны отката",
|
||||
highReviewFindings: "Критичные замечания проверки",
|
||||
policyGated: "по политике доступа",
|
||||
graphEdges: "Связи графа",
|
||||
aiShort: "ИИ",
|
||||
appliedToWorkspace: "Записано в workspace",
|
||||
rolledBackToWorkspace: "Откат записан в workspace",
|
||||
blocked: "заблокировано",
|
||||
ready: "готово",
|
||||
required: "требуется",
|
||||
checked: "проверено",
|
||||
applying: "Применяется...",
|
||||
rollingBack: "Откатываем...",
|
||||
building: "Строим...",
|
||||
savedToSfera: "Записано в SFERA",
|
||||
guardedApplyNote: "Безопасная запись из рабочего места SFERA IDE",
|
||||
rollbackApplyNote: "План отката проверен в SFERA IDE",
|
||||
aiSuggestion: "AI предлагает код с учётом текущей процедуры, переменных, регистра и прав доступа.",
|
||||
editorDirty: "Есть несохранённые изменения",
|
||||
readOnlyPrototype: "Preview-режим: запись только в workspace-историю SFERA.",
|
||||
addLine: "Добавить строку",
|
||||
removeLine: "Удалить строку",
|
||||
selectedObject: "Выбранный объект",
|
||||
objectOverview: "Обзор объекта",
|
||||
objectOverviewDescription: "Сводка выбранного объекта 1С: структура, формы, команды, связи, риски, знания и версии.",
|
||||
line: "Строка",
|
||||
snapshots: "Снимки",
|
||||
activeProjects: "активных проектов",
|
||||
relations: "связей",
|
||||
packages: "пакетов",
|
||||
aiTokens: "AI токены",
|
||||
requests: "запросов",
|
||||
current: "актуально",
|
||||
latestSnapshots: "Последние снимки SIR",
|
||||
snapshotsDescription: "Проекты, доступные для проверки, графа и анализа влияния.",
|
||||
open: "Открыть",
|
||||
project: "Проект",
|
||||
snapshot: "Снимок",
|
||||
hash: "Хэш",
|
||||
status: "Статус",
|
||||
stored: "сохранено",
|
||||
none: "нет",
|
||||
noSnapshots: "Нет сохранённых снимков",
|
||||
noSnapshotsDescription: "После индексации 1С-конфигурации список появится здесь.",
|
||||
governanceDescription: "Контроль владельцев, приватности, расхода ИИ и покрытия знаниями.",
|
||||
owners: "Владельцы",
|
||||
tasks: "Задачи",
|
||||
privacy: "Приватность",
|
||||
tokenLimit: "Лимит токенов",
|
||||
used: "Использовано",
|
||||
remaining: "Осталось",
|
||||
unlimited: "без лимита",
|
||||
apiUnavailable: "API недоступен",
|
||||
unknownApiError: "Неизвестная ошибка API"
|
||||
},
|
||||
en: {
|
||||
productSubtitle: "1C Semantic Workspace",
|
||||
nav: {
|
||||
overview: "Overview",
|
||||
projects: "1C Projects",
|
||||
graph: "Graph",
|
||||
objects: "Objects",
|
||||
review: "Review",
|
||||
knowledge: "Knowledge",
|
||||
patterns: "Patterns",
|
||||
privacy: "Privacy",
|
||||
aiUsage: "AI Usage",
|
||||
operations: "Operations",
|
||||
settings: "Settings"
|
||||
},
|
||||
searchPlaceholder: "Search 1C objects, routines, knowledge",
|
||||
rights: "Access",
|
||||
projectSettings: "Settings",
|
||||
createProject: "+ Project",
|
||||
notifications: "Notifications",
|
||||
profile: "Profile",
|
||||
workspaceSelector: "Workspace",
|
||||
projectSelector: "Project",
|
||||
environmentSelector: "Env",
|
||||
activeTaskSelector: "Task",
|
||||
languageRu: "Русский",
|
||||
languageEn: "English",
|
||||
commandLanguageNote: "Commands: Russian by default, English available",
|
||||
apiOnline: "API online",
|
||||
apiOffline: "API offline",
|
||||
overview: "Overview",
|
||||
projects: "Projects",
|
||||
objects: "Objects",
|
||||
configurationTree: "Configuration tree",
|
||||
openWindows: "Open windows",
|
||||
projectDashboard: "1C project overview",
|
||||
projectDashboardDescription: "Working summary for the selected 1C configuration: state, checks, snapshots, and quick jumps to open objects.",
|
||||
contextPanel: "Context",
|
||||
contextInspector: "Context inspector",
|
||||
owner: "Owner",
|
||||
subsystem: "Subsystem",
|
||||
criticality: "Criticality",
|
||||
activeTask: "Active task",
|
||||
calls: "Calls",
|
||||
riskContext: "Risks and changes",
|
||||
runtimeIncidents: "Runtime incidents",
|
||||
heroBadge: "1C semantic core",
|
||||
title: "1C Operational Workspace",
|
||||
subtitle: "Semantic graph, review, knowledge, privacy, and AI governance in one working view.",
|
||||
review: "Review",
|
||||
graph: "1C Graph",
|
||||
knowledge: "Knowledge",
|
||||
governance: "Governance",
|
||||
aiPolicy: "AI policy",
|
||||
aiUsage: "AI usage",
|
||||
projectWorkspace: "1C Workspace",
|
||||
projectWorkspaceDescription: "Snapshot, review, knowledge, UI forms, integrations, and scheduled jobs for the selected 1C project.",
|
||||
selectedProject: "Selected project",
|
||||
openProject: "Open project",
|
||||
openInEditor: "Open in editor",
|
||||
nodes: "Nodes",
|
||||
edges: "Edges",
|
||||
procedures: "Procedures",
|
||||
queries: "Queries",
|
||||
writes: "Writes",
|
||||
reviewFindings: "Review findings",
|
||||
noReviewFindings: "No findings",
|
||||
severity: "Severity",
|
||||
finding: "Finding",
|
||||
source: "Source",
|
||||
forms: "Forms",
|
||||
commands: "Commands",
|
||||
elements: "Elements",
|
||||
integrations: "Integrations",
|
||||
scheduledJobs: "Scheduled jobs",
|
||||
knowledgeCoverage: "Knowledge coverage",
|
||||
covered: "Covered",
|
||||
uncovered: "Uncovered",
|
||||
unsecuredObjects: "No role access",
|
||||
unownedObjects: "No owner",
|
||||
sensitiveFields: "Sensitive fields",
|
||||
permissionState: "Permissions",
|
||||
permissionStateDescription: "This screen currently exposes read-only semantic state; mutation actions will require the project owner role.",
|
||||
commandPalette: "Command line",
|
||||
commandPlaceholder: "Find a 1C object, routine, or command",
|
||||
savedView: "View",
|
||||
auditTrail: "Audit",
|
||||
authoringHistory: "Change history",
|
||||
noAuthoringChanges: "No saved authoring change sets yet",
|
||||
version: "Version",
|
||||
approvedBy: "Approved by",
|
||||
aiContext: "AI context",
|
||||
model: "Model",
|
||||
tokenImpact: "Token impact",
|
||||
noProjectData: "No selected project data",
|
||||
noProjectDataDescription: "The snapshot is stored, but project details are not yet available to the panel.",
|
||||
ideWorkspace: "1C IDE",
|
||||
ideWorkspaceDescription: "A modern 1C workspace: module, form, properties, events, versions, docs, knowledge, training, AI suggestions, and semantic diff.",
|
||||
objectTree: "Object tree",
|
||||
bslEditor: "BSL editor",
|
||||
procedureOutline: "Procedure outline",
|
||||
findUsages: "Find usages",
|
||||
quickFixes: "Quick fixes",
|
||||
insertGuardClause: "Insert guard clause",
|
||||
extractProcedure: "Extract procedure",
|
||||
addKnowledgeLink: "Link knowledge",
|
||||
moduleMode: "Module",
|
||||
formMode: "Form",
|
||||
propertiesMode: "Properties",
|
||||
eventsMode: "Events",
|
||||
versionsMode: "Versions",
|
||||
documentationMode: "Docs",
|
||||
knowledgeMode: "Knowledge",
|
||||
learningMode: "Training",
|
||||
formDesigner: "Form designer",
|
||||
eventsInspector: "Events inspector",
|
||||
knowledgeLearning: "Knowledge and training",
|
||||
knowledgeLearningDescription: "AI links the current object with docs, patterns, decision history, and team learning material.",
|
||||
postAndClose: "Post and close",
|
||||
saveAndClose: "Save and close",
|
||||
save: "Save",
|
||||
create: "Create",
|
||||
search: "Search",
|
||||
emptyList: "List is empty",
|
||||
mainSection: "Main",
|
||||
nameField: "Name",
|
||||
code: "Code",
|
||||
comment: "Comment",
|
||||
client: "Client",
|
||||
agent: "Agent",
|
||||
sites: "Sites",
|
||||
compensationTerms: "Compensation terms",
|
||||
agencyAgreements: "Agency agreements",
|
||||
telegram: "Telegram",
|
||||
mail: "Mail",
|
||||
sentToBankCompanyName: "Company name sent to bank",
|
||||
mergeProject: "Merge project",
|
||||
legalEntity: "Legal entity",
|
||||
result: "Result",
|
||||
author: "Author",
|
||||
editor: "Editor",
|
||||
creationDate: "Creation date",
|
||||
editDate: "Edit date",
|
||||
supplier: "Supplier",
|
||||
contract: "Contract",
|
||||
documentNumber: "Number",
|
||||
operation: "Operation",
|
||||
goods: "Goods",
|
||||
services: "Services",
|
||||
additional: "Additional",
|
||||
numberSign: "No.",
|
||||
item: "Item",
|
||||
quantity: "Quantity",
|
||||
price: "Price",
|
||||
amount: "Amount",
|
||||
eventName: "Event",
|
||||
objectVersioningDescription: "SFERA history is stored at the 1C object level: every change is linked to a diff, task, author, and rollback point.",
|
||||
documentationModeDescription: "Documentation opens next to the object: purpose, business rules, links with forms, reports, commands, and jobs.",
|
||||
knowledgeModeDescription: "The knowledge base links the current object with team patterns, BSP/vendor docs, discussions, decisions, and known risks.",
|
||||
learningModeDescription: "Training shows what can be safely written in the current context, which variables are available, and which team standards apply.",
|
||||
problemsPanel: "Problems",
|
||||
outputPanel: "Output",
|
||||
testsPanel: "Tests",
|
||||
referencesPanel: "References",
|
||||
propertiesInspector: "Properties inspector",
|
||||
semanticDiff: "Semantic diff",
|
||||
aiPairProgrammer: "AI pair programmer",
|
||||
currentContext: "Current context",
|
||||
availableVariables: "Available variables",
|
||||
localVariables: "Local variables",
|
||||
objectAttributes: "Object attributes",
|
||||
tabularSections: "Tabular sections",
|
||||
formElements: "Form elements",
|
||||
metadataDraft: "1C object draft",
|
||||
metadataDraftDescription: "SFERA creates the object as a versioned workspace draft: attributes, tabular sections, forms, and diff without writing to production 1C.",
|
||||
objectKind: "Object kind",
|
||||
objectName: "Object name",
|
||||
attributeName: "Attribute name",
|
||||
attributeType: "Type",
|
||||
tabularSectionName: "Tabular section name",
|
||||
formName: "Form name",
|
||||
commandName: "Command name",
|
||||
commandHandler: "Handler",
|
||||
synonym: "Synonym",
|
||||
requiredFlag: "Required",
|
||||
addAttribute: "Add attribute",
|
||||
addTabularColumn: "Add column",
|
||||
addForm: "Add form",
|
||||
addCommand: "Add command",
|
||||
applyMetadataDraft: "Create draft",
|
||||
suggestedCompletion: "Suggested completion",
|
||||
guardedApply: "Guarded apply",
|
||||
versionPreview: "Version preview",
|
||||
affectedNodes: "Affected nodes",
|
||||
applyBlocked: "Apply blocked",
|
||||
workspaceApplyReady: "Ready to save into SFERA workspace history",
|
||||
productionApplyDisabled: "Production 1C write is disabled",
|
||||
previewRequired: "Preview required",
|
||||
applyToSfera: "Apply to SFERA",
|
||||
rollbackPlan: "Rollback plan",
|
||||
authoringMode: "Authoring mode",
|
||||
workspaceHistoryOnly: "SFERA workspace history only",
|
||||
impactBeforeApply: "Impact before apply",
|
||||
reviewBeforeApply: "Review before apply",
|
||||
versionKind: "Version kind",
|
||||
lineage: "Lineage",
|
||||
parentVersion: "Parent version",
|
||||
versionDiff: "Version diff",
|
||||
taskLabel: "Task",
|
||||
sessionLabel: "Session",
|
||||
fullPayload: "Full payload",
|
||||
summaryOnly: "Summary",
|
||||
loading: "Loading",
|
||||
rootVersion: "root",
|
||||
agentOnline: "Agent online",
|
||||
online: "online",
|
||||
offline: "offline",
|
||||
snapshotStatus: "Snapshot",
|
||||
agentStatus: "Agent",
|
||||
parserStatus: "Parser",
|
||||
diagnosticsStatus: "Diagnostics",
|
||||
taskStatus: "Task",
|
||||
privacyStatus: "Privacy",
|
||||
metadataOnly: "metadata-only",
|
||||
sferaProjectTree: "SFERA Project",
|
||||
sirObjects: "SIR objects",
|
||||
aiHandlers: "AI handlers",
|
||||
semanticRules: "Semantic rules",
|
||||
reviewPolicies: "Review policies",
|
||||
knowledgeBindings: "Knowledge bindings",
|
||||
agentCommands: "Agent commands",
|
||||
rollbackTemplates: "Rollback templates",
|
||||
highReviewFindings: "Review HIGH findings",
|
||||
policyGated: "policy-gated",
|
||||
graphEdges: "Graph edges",
|
||||
aiShort: "AI",
|
||||
appliedToWorkspace: "Applied to workspace",
|
||||
rolledBackToWorkspace: "Rolled back to workspace",
|
||||
blocked: "blocked",
|
||||
ready: "ready",
|
||||
required: "required",
|
||||
checked: "checked",
|
||||
applying: "Applying...",
|
||||
rollingBack: "Rolling back...",
|
||||
building: "Loading...",
|
||||
savedToSfera: "Saved to SFERA",
|
||||
guardedApplyNote: "Guarded apply from SFERA IDE workbench",
|
||||
rollbackApplyNote: "Rollback preview checked in SFERA IDE",
|
||||
aiSuggestion: "AI suggests code using the current routine, variables, register, and access context.",
|
||||
editorDirty: "Unsaved changes",
|
||||
readOnlyPrototype: "Preview mode: writes only to SFERA workspace history.",
|
||||
addLine: "Add line",
|
||||
removeLine: "Remove line",
|
||||
selectedObject: "Selected object",
|
||||
objectOverview: "Object overview",
|
||||
objectOverviewDescription: "Summary of the selected 1C object: structure, forms, commands, links, risks, knowledge, and versions.",
|
||||
line: "Line",
|
||||
snapshots: "Snapshots",
|
||||
activeProjects: "active projects",
|
||||
relations: "relations",
|
||||
packages: "packs",
|
||||
aiTokens: "AI tokens",
|
||||
requests: "requests",
|
||||
current: "current",
|
||||
latestSnapshots: "Latest SIR snapshots",
|
||||
snapshotsDescription: "Projects available for review, graph, and impact analysis.",
|
||||
open: "Open",
|
||||
project: "Project",
|
||||
snapshot: "Snapshot",
|
||||
hash: "Hash",
|
||||
status: "Status",
|
||||
stored: "stored",
|
||||
none: "none",
|
||||
noSnapshots: "No stored snapshots",
|
||||
noSnapshotsDescription: "Indexed 1C configurations will appear here.",
|
||||
governanceDescription: "Owners, privacy, AI usage, and knowledge coverage controls.",
|
||||
owners: "Owners",
|
||||
tasks: "Tasks",
|
||||
privacy: "Privacy",
|
||||
tokenLimit: "Token limit",
|
||||
used: "Used",
|
||||
remaining: "Remaining",
|
||||
unlimited: "unlimited",
|
||||
apiUnavailable: "API unavailable",
|
||||
unknownApiError: "Unknown API error"
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: "hsl(var(--card))",
|
||||
"card-foreground": "hsl(var(--card-foreground))",
|
||||
muted: "hsl(var(--muted))",
|
||||
"muted-foreground": "hsl(var(--muted-foreground))",
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
primary: "hsl(var(--primary))",
|
||||
"primary-foreground": "hsl(var(--primary-foreground))",
|
||||
secondary: "hsl(var(--secondary))",
|
||||
"secondary-foreground": "hsl(var(--secondary-foreground))",
|
||||
destructive: "hsl(var(--destructive))",
|
||||
warning: "hsl(var(--warning))",
|
||||
success: "hsl(var(--success))",
|
||||
info: "hsl(var(--info))"
|
||||
},
|
||||
boxShadow: {
|
||||
soft: "0 12px 32px rgba(15, 23, 42, 0.08)"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||