Compare commits
44 Commits
db1417332d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cc11e5704e | |||
| bb499ebf23 | |||
| 8753e84a03 | |||
| eb37476bdb | |||
| 63323c1ca4 | |||
| b39a676e1a | |||
| b2cd4b53fc | |||
| 273a4f3acf | |||
| 7bd186eace | |||
| d200260497 | |||
| 840f06e68e | |||
| e577c02eb3 | |||
| 79bf7de4f2 | |||
| 607209b35f | |||
| 836e4a7eea | |||
| a3468518b3 | |||
| 87c6b55e81 | |||
| 2a32465050 | |||
| b7d572ea8e | |||
| 3251c76478 | |||
| 2e669f396b | |||
| fd2c1f10ad | |||
| de379e28bf | |||
| d056b63e1b | |||
| 2056333529 | |||
| a1bd11afee | |||
| bc8ec9aeea | |||
| 874d9099de | |||
| bc20deac23 | |||
| fd825ddfd7 | |||
| 755d22cf48 | |||
| 72f973bbd1 | |||
| 190cca7e6b | |||
| 61da5b6d3a | |||
| da3fcbb6b0 | |||
| 8adf982587 | |||
| b7fb56b8cc | |||
| 96fb44c770 | |||
| 4b8069ad74 | |||
| b30607df3f | |||
| 9ff894988f | |||
| 9deaa34a6e | |||
| 7a2467370a | |||
| 28404eb7be |
+4
-3
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_BASE_URL=https://admin-api.dev.eventhub.local
|
# Same-origin через Vite proxy (см. vite.config.ts) — иначе CORS с localhost → admin-api.
|
||||||
VITE_WS_URL=wss://admin-ws.dev.eventhub.local
|
VITE_API_BASE_URL=
|
||||||
VITE_APP_TITLE=EventHub Admin
|
VITE_WS_URL=
|
||||||
|
VITE_APP_TITLE=EventHub Admin
|
||||||
|
|||||||
+308
-13
@@ -1,14 +1,29 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
|
# PR / push: test + push образа (на push).
|
||||||
|
# push master/main: test → push-image → deploy IFT → E2E IFT → deploy stage → E2E stage (один run).
|
||||||
|
# workflow_dispatch: выбор фазы (см. input pipeline).
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, main]
|
branches: [master, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
pipeline:
|
||||||
|
description: "Режим (push master = full автоматически)"
|
||||||
|
required: false
|
||||||
|
default: full
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- full
|
||||||
|
- test-only
|
||||||
|
- deploy-ift
|
||||||
|
- e2e-ift
|
||||||
|
- deploy-stage
|
||||||
|
- e2e-stage
|
||||||
|
|
||||||
# Один runner (DESKTOP-SIR8B3R): параллельные workflow ломают кэш act/actions.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: eventhub-frontadmin-ci-${{ github.ref }}
|
group: eventhub-frontadmin-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -18,6 +33,10 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && (github.event.inputs.pipeline == 'full' || github.event.inputs.pipeline == 'test-only'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
outputs:
|
outputs:
|
||||||
@@ -25,23 +44,56 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# Без cache: npm — act_runner отменяет шаг на распаковке ~546MB (exit -1).
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Bake build identity
|
||||||
|
id: build_meta
|
||||||
|
env:
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
APP_VERSION="$(bash scripts/resolve-product-version.sh)"
|
||||||
|
APP_BUILD="${GITHUB_RUN_NUMBER:-0}"
|
||||||
|
GIT_SHA="${GITHUB_SHA:0:12}"
|
||||||
|
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
echo "Bake: version=${APP_VERSION} build=${APP_BUILD} git_sha=${GIT_SHA}"
|
||||||
|
echo "VITE_APP_VERSION=${APP_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
echo "VITE_APP_BUILD=${APP_BUILD}" >> "$GITHUB_ENV"
|
||||||
|
echo "VITE_GIT_SHA=${GIT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
echo "VITE_BUILT_AT=${BUILT_AT}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: bash scripts/install-playwright-chromium.sh
|
||||||
|
|
||||||
|
- name: E2E mock suite
|
||||||
|
run: |
|
||||||
|
# act hostexecutor: зомби vite preview (cwd deleted) держит порт → HTTP 404 → webServer timeout
|
||||||
|
bash scripts/free-e2e-port.sh
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
- name: Upload dist for docker job
|
- name: Upload dist for docker job
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: admin-ui-dist
|
name: admin-ui-dist
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Mark test complete
|
- name: Mark test complete
|
||||||
id: mark_test
|
id: mark_test
|
||||||
if: success()
|
if: success()
|
||||||
@@ -49,7 +101,14 @@ jobs:
|
|||||||
|
|
||||||
push-image:
|
push-image:
|
||||||
needs: test
|
needs: test
|
||||||
if: github.event_name == 'push' && needs.test.outputs.ci_test_ok == '1'
|
if: |
|
||||||
|
always() &&
|
||||||
|
needs.test.result == 'success' &&
|
||||||
|
needs.test.outputs.ci_test_ok == '1' &&
|
||||||
|
(
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
@@ -90,13 +149,22 @@ jobs:
|
|||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
- name: Push admin-ui image
|
- name: Push admin-ui image
|
||||||
|
env:
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
APP_VERSION="$(bash scripts/resolve-product-version.sh)"
|
||||||
|
APP_BUILD="${GITHUB_RUN_NUMBER:-0}"
|
||||||
TAG="sha-${GITHUB_SHA:0:12}"
|
TAG="sha-${GITHUB_SHA:0:12}"
|
||||||
|
VER_TAG="${APP_VERSION}.${APP_BUILD}"
|
||||||
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
||||||
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:${VER_TAG}"
|
||||||
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:ift"
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:ift"
|
||||||
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:dev"
|
docker tag eventhub-admin-ui:local "${REGISTRY}/eventhub-admin-ui:dev"
|
||||||
|
echo "Push tags: ${TAG} ${VER_TAG} ift dev"
|
||||||
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:${TAG}"
|
||||||
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:${VER_TAG}"
|
||||||
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:ift"
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:ift"
|
||||||
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:dev"
|
bash scripts/retry-docker-build.sh 5 30 -- docker push "${REGISTRY}/eventhub-admin-ui:dev"
|
||||||
|
|
||||||
@@ -112,27 +180,254 @@ jobs:
|
|||||||
if: success()
|
if: success()
|
||||||
run: echo "ok=1" >> "$GITHUB_OUTPUT"
|
run: echo "ok=1" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
deploy-ift:
|
||||||
|
needs: push-image
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'deploy-ift') ||
|
||||||
|
(
|
||||||
|
needs.push-image.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute image tag
|
||||||
|
id: meta
|
||||||
|
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Wait for Docker daemon
|
||||||
|
run: bash scripts/ensure-docker-daemon.sh 240
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: git.sabilin.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Verify admin-ui image from CI
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
if ! docker buildx imagetools inspect "${REGISTRY}/eventhub-admin-ui:${TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "Образ eventhub-admin-ui:${TAG} не найден — test/push-image не завершились."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: eventhub-admin-ui:${TAG}"
|
||||||
|
|
||||||
|
- name: Deploy admin on IFT via SSH
|
||||||
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.IFT_SSH_HOST }}
|
||||||
|
username: ${{ secrets.IFT_SSH_USER }}
|
||||||
|
key: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
||||||
|
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
||||||
|
bash /opt/eventhub-ift/devops/scripts/deploy-service.sh ift admin ${{ steps.meta.outputs.tag }}
|
||||||
|
|
||||||
|
e2e-ift:
|
||||||
|
needs: deploy-ift
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'e2e-ift') ||
|
||||||
|
(
|
||||||
|
needs.deploy-ift.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: bash scripts/install-playwright-chromium.sh
|
||||||
|
|
||||||
|
- name: Load smoke credentials from IFT .env
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.IFT_SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.IFT_SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
|
||||||
|
run: bash scripts/load-stand-smoke-env.sh ift
|
||||||
|
|
||||||
|
- name: E2E IFT (integration, live API)
|
||||||
|
env:
|
||||||
|
E2E_TARGET: ift
|
||||||
|
E2E_IFT_BASE_URL: ${{ secrets.E2E_IFT_BASE_URL }}
|
||||||
|
run: npm run test:e2e:ift
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: playwright-ift-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
deploy-stage:
|
||||||
|
needs: e2e-ift
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'deploy-stage') ||
|
||||||
|
(
|
||||||
|
needs.e2e-ift.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute image tag
|
||||||
|
id: meta
|
||||||
|
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Wait for Docker daemon
|
||||||
|
run: bash scripts/ensure-docker-daemon.sh 240
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: git.sabilin.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Verify admin-ui image from CI
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.meta.outputs.tag }}"
|
||||||
|
if ! docker buildx imagetools inspect "${REGISTRY}/eventhub-admin-ui:${TAG}" >/dev/null 2>&1; then
|
||||||
|
echo "Образ eventhub-admin-ui:${TAG} не найден — test push не завершился."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: eventhub-admin-ui:${TAG}"
|
||||||
|
|
||||||
|
- name: Promote sha → :stage (no rebuild)
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
bash scripts/clone-devops-run.sh scripts/promote-images.sh \
|
||||||
|
"${{ steps.meta.outputs.tag }}" \
|
||||||
|
"${{ steps.meta.outputs.tag }}" \
|
||||||
|
stage \
|
||||||
|
eventhub-admin-ui
|
||||||
|
|
||||||
|
- name: Deploy admin on stage via SSH
|
||||||
|
uses: appleboy/ssh-action@v0.1.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.STAGE_SSH_HOST }}
|
||||||
|
username: ${{ secrets.STAGE_SSH_USER }}
|
||||||
|
key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
||||||
|
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
||||||
|
bash /opt/eventhub-stage/devops/scripts/deploy-service.sh stage admin ${{ steps.meta.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Prune old registry images
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: bash scripts/clone-devops-run.sh scripts/prune-registry-images.sh
|
||||||
|
|
||||||
|
e2e-stage:
|
||||||
|
needs: deploy-stage
|
||||||
|
if: |
|
||||||
|
always() && (
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'e2e-stage') ||
|
||||||
|
(
|
||||||
|
needs.deploy-stage.result == 'success' &&
|
||||||
|
(
|
||||||
|
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.pipeline == 'full')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: bash scripts/install-playwright-chromium.sh
|
||||||
|
|
||||||
|
- name: Load smoke credentials from stage .env
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.STAGE_SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.STAGE_SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||||
|
run: bash scripts/load-stand-smoke-env.sh stage
|
||||||
|
|
||||||
|
- name: E2E stage (integration, live API)
|
||||||
|
env:
|
||||||
|
E2E_TARGET: stage
|
||||||
|
E2E_STAGE_BASE_URL: ${{ secrets.E2E_STAGE_BASE_URL }}
|
||||||
|
run: npm run test:e2e:stage
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: playwright-stage-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
ci-done:
|
ci-done:
|
||||||
needs: [test, push-image]
|
needs: [test, push-image]
|
||||||
if: always()
|
if: |
|
||||||
|
always() && (
|
||||||
|
github.event_name == 'pull_request' ||
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && (github.event.inputs.pipeline == 'full' || github.event.inputs.pipeline == 'test-only'))
|
||||||
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Enforce CI completion
|
- name: Enforce CI gate (test / push on push & PR)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TEST_OK="${{ needs.test.outputs.ci_test_ok }}"
|
if [ "${{ needs.test.outputs.ci_test_ok }}" != "1" ]; then
|
||||||
if [ "${TEST_OK}" != "1" ]; then
|
echo "::error::test job incomplete"
|
||||||
echo "::error::test job incomplete (runner reported success without output)"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
if [ "${{ github.event_name }}" = "push" ] || ( [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.pipeline }}" = "full" ] ); then
|
||||||
if [ "${{ needs.push-image.result }}" != "success" ]; then
|
if [ "${{ needs.push-image.result }}" != "success" ]; then
|
||||||
echo "::error::push-image must succeed, got: ${{ needs.push-image.result }}"
|
echo "::error::push-image must succeed, got: ${{ needs.push-image.result }}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "${{ needs.push-image.outputs.ci_push_ok }}" != "1" ]; then
|
if [ "${{ needs.push-image.outputs.ci_push_ok }}" != "1" ]; then
|
||||||
echo "::error::push-image incomplete (output missing)"
|
echo "::error::push-image incomplete"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
name: Deploy IFT (admin)
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [CI]
|
|
||||||
types: [completed]
|
|
||||||
branches: [master, main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: eventhub-frontadmin-deploy-ift
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: git.sabilin.com/eventhub
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-ift-admin:
|
|
||||||
# Gitea: workflow_run.conclusion часто пустой → job skipped. Проверка образа ниже.
|
|
||||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Compute image tag
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
|
||||||
SHA="${{ github.event.workflow_run.head_sha }}"
|
|
||||||
else
|
|
||||||
SHA="${GITHUB_SHA}"
|
|
||||||
fi
|
|
||||||
echo "tag=sha-${SHA:0:12}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Wait for Docker daemon
|
|
||||||
run: bash scripts/ensure-docker-daemon.sh 240
|
|
||||||
|
|
||||||
- name: Login to registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: git.sabilin.com
|
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Verify admin-ui image from CI
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
if ! docker buildx imagetools inspect "${REGISTRY}/eventhub-admin-ui:${TAG}" >/dev/null 2>&1; then
|
|
||||||
echo "Образ eventhub-admin-ui:${TAG} не найден — CI push не завершился (ложный success runner?)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "OK: eventhub-admin-ui:${TAG}"
|
|
||||||
|
|
||||||
- name: Deploy admin on IFT via SSH
|
|
||||||
uses: appleboy/ssh-action@v0.1.5
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.IFT_SSH_HOST }}
|
|
||||||
username: ${{ secrets.IFT_SSH_USER }}
|
|
||||||
key: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
|
|
||||||
script: |
|
|
||||||
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
|
||||||
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
|
||||||
bash /opt/eventhub-ift/devops/scripts/deploy-service.sh ift admin ${{ steps.meta.outputs.tag }}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
name: Deploy stage (admin)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
release_tag:
|
|
||||||
description: 'Release tag (например v0.1.1)'
|
|
||||||
required: true
|
|
||||||
default: 'v0.1.1'
|
|
||||||
sha_tag:
|
|
||||||
description: 'Образ sha-* (например sha-3f5dde498652)'
|
|
||||||
required: true
|
|
||||||
default: 'sha-3f5dde498652'
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: git.sabilin.com/eventhub
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-stage-admin:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Compute image tags
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
echo "sha_tag=${{ github.event.inputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release_tag=${{ github.event.inputs.release_tag }}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "sha_tag=sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: git.sabilin.com
|
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Promote admin-ui image (no rebuild)
|
|
||||||
env:
|
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
bash scripts/clone-devops-run.sh scripts/promote-images.sh \
|
|
||||||
"${{ steps.meta.outputs.sha_tag }}" \
|
|
||||||
"${{ steps.meta.outputs.release_tag }}" \
|
|
||||||
stage \
|
|
||||||
eventhub-admin-ui
|
|
||||||
|
|
||||||
- name: Deploy admin on stage via SSH
|
|
||||||
uses: appleboy/ssh-action@v0.1.5
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.STAGE_SSH_HOST }}
|
|
||||||
username: ${{ secrets.STAGE_SSH_USER }}
|
|
||||||
key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
|
||||||
script: |
|
|
||||||
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
|
|
||||||
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
|
|
||||||
bash /opt/eventhub-stage/devops/scripts/deploy-service.sh stage admin ${{ steps.meta.outputs.release_tag }}
|
|
||||||
|
|
||||||
- name: Prune old registry images
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
run: bash scripts/clone-devops-run.sh scripts/prune-registry-images.sh
|
|
||||||
@@ -11,6 +11,10 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
blob-report
|
||||||
|
.playwright
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# E2E testid contract (EventHubFrontAdmin)
|
||||||
|
|
||||||
|
Селекторы: **`data-testid`** + a11y (`getByRole` / `getByLabel`) где имя стабильно.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
| Pattern | Example |
|
||||||
|
|---------|---------|
|
||||||
|
| `page-{name}` | `page-login`, `page-users`, `page-inbox-reports` |
|
||||||
|
| `nav-{slug}` | `nav-users` |
|
||||||
|
| `row-{id}` | dense list / DataTable rows |
|
||||||
|
| `row-activate-{id}` | dense list main click target |
|
||||||
|
| `{entity}-row-{id}` | inbox-specific (`report-row-*`) |
|
||||||
|
| `btn-{action}` | `btn-login`, `btn-save-profile` |
|
||||||
|
| `dialog-{name}` | `dialog-edit-user` |
|
||||||
|
| `filter-{name}` | `filter-users-search` |
|
||||||
|
| `menu-user` / `lang-ru` | avatar dropdown |
|
||||||
|
|
||||||
|
Helper: `src/lib/testid.ts`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- kebab-case; dynamic id = API entity id (or banned word)
|
||||||
|
- Prefer a11y for login fields
|
||||||
|
- Do not decorate decorative wrappers
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
| Команда | Назначение |
|
||||||
|
|---------|------------|
|
||||||
|
| `npm run test:e2e` | mock-suite (CI / PR), API через Playwright route |
|
||||||
|
| `npm run test:e2e:ift` | IFT: полный UI-сьют на живом SPA + smoke API |
|
||||||
|
| `npm run test:e2e:stage` | stage: полный UI-сьют на живом SPA + smoke API |
|
||||||
|
|
||||||
|
### IFT / stage учётки
|
||||||
|
|
||||||
|
Источник истины — env стенда (`EventHubDevOps/ift/.env`, `EventHubDevOps/stage/.env` → на хосте `/opt/eventhub-{ift,stage}/.env`):
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|------------|------------|
|
||||||
|
| `SMOKE_ADMIN_EMAIL` / `SMOKE_ADMIN_PASSWORD` | предпочтительно для smoke/E2E |
|
||||||
|
| `ADMIN_SUPER_EMAIL` / `ADMIN_SUPER_PASSWORD` | fallback, если `SMOKE_ADMIN_*` пустые |
|
||||||
|
|
||||||
|
Локально: `set -a && source /path/to/ift/.env && set +a && npm run test:e2e:ift`.
|
||||||
|
|
||||||
|
Workflow `e2e-ift.yml` / `e2e-stage.yml` читают те же ключи по SSH с `/opt/eventhub-{ift,stage}/.env`. Опц. secrets `E2E_IFT_BASE_URL`, `E2E_STAGE_BASE_URL`.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { MOCK_ADMIN } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
export async function loginAsE2EAdmin(page: Page) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
|
||||||
|
await page.getByTestId('btn-login').click();
|
||||||
|
await page.waitForURL(/\/(dashboard|inbox)/);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../../src/utils/constants';
|
||||||
|
import { installAdminApiMocks, MOCK_ADMIN, type MockAdmin } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
export async function seedAuthenticatedSession(page: Page, admin: Partial<MockAdmin> = {}) {
|
||||||
|
const user: MockAdmin = { ...MOCK_ADMIN, ...admin };
|
||||||
|
await installAdminApiMocks(page, { user });
|
||||||
|
// Origin + localStorage before app boot (addInitScript alone races under load).
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([accessKey, refreshKey]) => {
|
||||||
|
localStorage.setItem(accessKey, 'e2e-access-token');
|
||||||
|
localStorage.setItem(refreshKey, 'e2e-refresh-token');
|
||||||
|
},
|
||||||
|
[ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY] as [string, string]
|
||||||
|
);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Учётки superadmin со стенда (CI: load-stand-smoke-env.sh). */
|
||||||
|
export function standAdminEmail(): string | undefined {
|
||||||
|
return process.env.SMOKE_ADMIN_EMAIL || process.env.ADMIN_SUPER_EMAIL || process.env.E2E_ADMIN_EMAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function standAdminPassword(): string | undefined {
|
||||||
|
return (
|
||||||
|
process.env.SMOKE_ADMIN_PASSWORD ||
|
||||||
|
process.env.ADMIN_SUPER_PASSWORD ||
|
||||||
|
process.env.E2E_ADMIN_PASSWORD
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStandCredentials(): boolean {
|
||||||
|
return Boolean(standAdminEmail() && standAdminPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реальный login против admin-api стенда (без Playwright route mocks). */
|
||||||
|
export async function loginAsStandAdmin(page: Page) {
|
||||||
|
const email = standAdminEmail();
|
||||||
|
const password = standAdminPassword();
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error('Stand admin credentials missing (SMOKE_ADMIN_* / ADMIN_SUPER_*)');
|
||||||
|
}
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/email/i).fill(email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill(password);
|
||||||
|
await page.getByTestId('btn-login').click();
|
||||||
|
await page.getByTestId('layout-shell').waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import type { Page, Route } from '@playwright/test';
|
||||||
|
import { createModerationSeed, type ModerationState } from './moderationSeed';
|
||||||
|
import { createContentSeed } from './contentSeed';
|
||||||
|
import { tryHandleContentApi } from './handleContentApi';
|
||||||
|
|
||||||
|
export type MockAdmin = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
nickname: string;
|
||||||
|
role: string;
|
||||||
|
language: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_ADMIN: MockAdmin = {
|
||||||
|
id: 'admin-e2e-1',
|
||||||
|
email: 'e2e@eventhub.local',
|
||||||
|
nickname: 'E2E Admin',
|
||||||
|
role: 'superadmin',
|
||||||
|
language: 'ru',
|
||||||
|
avatar_url: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function json(route: Route, status: number, body: unknown, headers: Record<string, string> = {}) {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSlice<T>(items: T[], url: URL): { rows: T[]; total: number } {
|
||||||
|
const limit = Number(url.searchParams.get('limit') || 50);
|
||||||
|
const offset = Number(url.searchParams.get('offset') || 0);
|
||||||
|
return { rows: items.slice(offset, offset + limit), total: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockOptions = {
|
||||||
|
user?: MockAdmin;
|
||||||
|
moderation?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Default admin API mocks for Control Center shell / login / moderation. */
|
||||||
|
export async function installAdminApiMocks(page: Page, options: MockOptions = {}) {
|
||||||
|
let currentUser: MockAdmin = { ...MOCK_ADMIN, ...options.user };
|
||||||
|
const moderation: ModerationState | null = options.moderation === false ? null : createModerationSeed();
|
||||||
|
const content = createContentSeed();
|
||||||
|
|
||||||
|
await page.route('**/v1/admin/**', async (route) => {
|
||||||
|
const req = route.request();
|
||||||
|
const method = req.method();
|
||||||
|
const url = new URL(req.url());
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (method === 'POST' && path.endsWith('/v1/admin/login')) {
|
||||||
|
const payload = req.postDataJSON() as { email?: string; password?: string };
|
||||||
|
if (payload?.email === MOCK_ADMIN.email && payload?.password === 'e2e-pass') {
|
||||||
|
await json(route, 200, {
|
||||||
|
token: 'e2e-access-token',
|
||||||
|
refresh_token: 'e2e-refresh-token',
|
||||||
|
user: currentUser,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, 401, { error: 'invalid_credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/me')) {
|
||||||
|
await json(route, 200, currentUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'PUT' && path.endsWith('/v1/admin/me')) {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<MockAdmin>;
|
||||||
|
currentUser = { ...currentUser, ...payload };
|
||||||
|
await json(route, 200, currentUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/health')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'eventhub',
|
||||||
|
version: '0.1',
|
||||||
|
build: 1,
|
||||||
|
git_sha: 'e2etestsha000',
|
||||||
|
built_at: '2026-07-14T00:00:00Z',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
users_total: 10,
|
||||||
|
events_total: 5,
|
||||||
|
reviews_total: moderation?.reviews.length ?? 0,
|
||||||
|
calendars_total: 3,
|
||||||
|
tickets_total: moderation?.tickets.length ?? 0,
|
||||||
|
tickets_open: moderation?.tickets.filter((t) => t.status === 'open').length ?? 0,
|
||||||
|
reports_total: moderation?.reports.length ?? 0,
|
||||||
|
reports_pending: moderation?.reports.filter((r) => r.status === 'pending').length ?? 0,
|
||||||
|
reports_reviewed: moderation?.reports.filter((r) => r.status === 'reviewed').length ?? 0,
|
||||||
|
avg_report_resolution_h: 1.5,
|
||||||
|
avg_ticket_resolution_h: 2,
|
||||||
|
events_by_day: [],
|
||||||
|
registrations_by_day: [],
|
||||||
|
reviews_by_day: [],
|
||||||
|
reports_by_day: [],
|
||||||
|
tickets_by_day: [],
|
||||||
|
subscriptions_by_day: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
||||||
|
// Prefer content seed with sample node (monitoring)
|
||||||
|
if (
|
||||||
|
await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await json(route, 200, { nodes: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderation) {
|
||||||
|
// ---- reports ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reports/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_reports: moderation.reports.length,
|
||||||
|
reports_by_target_type: {},
|
||||||
|
reports_by_status: {
|
||||||
|
pending: moderation.reports.filter((r) => r.status === 'pending').length,
|
||||||
|
reviewed: moderation.reports.filter((r) => r.status === 'reviewed').length,
|
||||||
|
dismissed: moderation.reports.filter((r) => r.status === 'dismissed').length,
|
||||||
|
},
|
||||||
|
top_targets_by_reports: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reports')) {
|
||||||
|
let rows = [...moderation.reports];
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
if (status) rows = rows.filter((r) => r.status === status);
|
||||||
|
const pageRows = listSlice(rows, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportMatch = path.match(/\/v1\/admin\/reports\/([^/]+)$/);
|
||||||
|
if (reportMatch) {
|
||||||
|
const id = decodeURIComponent(reportMatch[1]);
|
||||||
|
const idx = moderation.reports.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.reports[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as { status?: MockReportStatus };
|
||||||
|
moderation.reports[idx] = {
|
||||||
|
...moderation.reports[idx],
|
||||||
|
...payload,
|
||||||
|
resolved_at: payload.status ? '2026-07-14T13:00:00Z' : moderation.reports[idx].resolved_at,
|
||||||
|
resolved_by: payload.status ? currentUser.id : moderation.reports[idx].resolved_by,
|
||||||
|
};
|
||||||
|
await json(route, 200, moderation.reports[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tickets ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_tickets: moderation.tickets.length,
|
||||||
|
open: moderation.tickets.filter((t) => t.status === 'open').length,
|
||||||
|
in_progress: moderation.tickets.filter((t) => t.status === 'in_progress').length,
|
||||||
|
resolved: moderation.tickets.filter((t) => t.status === 'resolved').length,
|
||||||
|
closed: moderation.tickets.filter((t) => t.status === 'closed').length,
|
||||||
|
total_errors: moderation.tickets.reduce((s, t) => s + t.count, 0),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/tickets')) {
|
||||||
|
const pageRows = listSlice(moderation.tickets, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketMatch = path.match(/\/v1\/admin\/tickets\/([^/]+)$/);
|
||||||
|
if (ticketMatch) {
|
||||||
|
const id = decodeURIComponent(ticketMatch[1]);
|
||||||
|
const idx = moderation.tickets.findIndex((t) => t.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.tickets[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.tickets)[0]>;
|
||||||
|
moderation.tickets[idx] = { ...moderation.tickets[idx], ...payload };
|
||||||
|
await json(route, 200, moderation.tickets[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reviews ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_reviews: moderation.reviews.length,
|
||||||
|
reviews_by_target_type: {},
|
||||||
|
reviews_by_status: {
|
||||||
|
visible: moderation.reviews.filter((r) => r.status === 'visible').length,
|
||||||
|
hidden: moderation.reviews.filter((r) => r.status === 'hidden').length,
|
||||||
|
deleted: moderation.reviews.filter((r) => r.status === 'deleted').length,
|
||||||
|
},
|
||||||
|
top_targets_by_reviews: [],
|
||||||
|
top_targets_by_positive_reviews: [],
|
||||||
|
top_targets_by_negative_reviews: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/reviews')) {
|
||||||
|
const pageRows = listSlice(moderation.reviews, url);
|
||||||
|
await json(route, 200, pageRows.rows, { 'X-Total-Count': String(pageRows.total) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'PATCH' && path.endsWith('/v1/admin/reviews')) {
|
||||||
|
const updates = (req.postDataJSON() || []) as Array<{ id: string; status: string; reason?: string }>;
|
||||||
|
for (const u of updates) {
|
||||||
|
const idx = moderation.reviews.findIndex((r) => r.id === u.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
moderation.reviews[idx] = {
|
||||||
|
...moderation.reviews[idx],
|
||||||
|
status: u.status as (typeof moderation.reviews)[0]['status'],
|
||||||
|
reason: u.reason ?? moderation.reviews[idx].reason,
|
||||||
|
updated_at: '2026-07-14T13:00:00Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await json(route, 200, updates.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewMatch = path.match(/\/v1\/admin\/reviews\/([^/]+)$/);
|
||||||
|
if (reviewMatch) {
|
||||||
|
const id = decodeURIComponent(reviewMatch[1]);
|
||||||
|
const idx = moderation.reviews.findIndex((r) => r.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, moderation.reviews[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const payload = (req.postDataJSON() || {}) as Partial<(typeof moderation.reviews)[0]>;
|
||||||
|
moderation.reviews[idx] = {
|
||||||
|
...moderation.reviews[idx],
|
||||||
|
...payload,
|
||||||
|
updated_at: '2026-07-14T13:00:00Z',
|
||||||
|
};
|
||||||
|
await json(route, 200, moderation.reviews[idx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await tryHandleContentApi(route, method, path, url, content, json, () => req.postDataJSON())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, [], { 'X-Total-Count': '0' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockReportStatus = 'pending' | 'reviewed' | 'dismissed';
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/** Content/system seed data for mock E2E (users, calendars, events, …). */
|
||||||
|
|
||||||
|
const now = '2026-07-14T12:00:00Z';
|
||||||
|
|
||||||
|
export function createContentSeed() {
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
id: 'user-e2e-1',
|
||||||
|
email: 'alice@eventhub.local',
|
||||||
|
nickname: 'Alice',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
last_login: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-e2e-2',
|
||||||
|
email: 'bob@eventhub.local',
|
||||||
|
nickname: 'Bob',
|
||||||
|
role: 'user',
|
||||||
|
status: 'frozen',
|
||||||
|
language: 'en',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
last_login: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const calendars = [
|
||||||
|
{
|
||||||
|
id: 'cal-e2e-1',
|
||||||
|
title: 'E2E Calendar',
|
||||||
|
description: 'for tests',
|
||||||
|
type: 'personal',
|
||||||
|
category: 'sport',
|
||||||
|
owner_id: 'user-e2e-1',
|
||||||
|
status: 'active',
|
||||||
|
reason: null,
|
||||||
|
tags: [],
|
||||||
|
color: '#3366ff',
|
||||||
|
image_url: '',
|
||||||
|
rating_avg: 4.5,
|
||||||
|
rating_count: 2,
|
||||||
|
short_name: 'e2ecal',
|
||||||
|
confirmation: 'none',
|
||||||
|
settings: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'evt-e2e-1',
|
||||||
|
calendar_id: 'cal-e2e-1',
|
||||||
|
title: 'E2E Workout',
|
||||||
|
description: 'test event',
|
||||||
|
event_type: 'single',
|
||||||
|
start_time: '2026-01-01T10:00:00Z',
|
||||||
|
duration: 60,
|
||||||
|
recurrence: null,
|
||||||
|
master_id: null,
|
||||||
|
is_instance: false,
|
||||||
|
specialist_id: null,
|
||||||
|
location: null,
|
||||||
|
tags: [],
|
||||||
|
capacity: 10,
|
||||||
|
online_link: null,
|
||||||
|
status: 'active',
|
||||||
|
reason: null,
|
||||||
|
rating_avg: 5,
|
||||||
|
rating_count: 1,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const subscriptions = [
|
||||||
|
{
|
||||||
|
id: 'sub-e2e-1',
|
||||||
|
user_id: 'user-e2e-1',
|
||||||
|
plan: 'monthly',
|
||||||
|
status: 'active',
|
||||||
|
trial_used: false,
|
||||||
|
started_at: now,
|
||||||
|
expires_at: '2027-01-01T00:00:00Z',
|
||||||
|
auto_renew: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bannedWords = [
|
||||||
|
{ word: 'spamword', added_by: 'admin-e2e-1', added_at: now },
|
||||||
|
{ word: 'badword', added_by: 'admin-e2e-1', added_at: now },
|
||||||
|
];
|
||||||
|
|
||||||
|
const admins = [
|
||||||
|
{
|
||||||
|
id: 'admin-e2e-1',
|
||||||
|
email: 'e2e@eventhub.local',
|
||||||
|
nickname: 'E2E Admin',
|
||||||
|
role: 'superadmin',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'Europe/Moscow',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_login: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-e2e-2',
|
||||||
|
email: 'mod@eventhub.local',
|
||||||
|
nickname: 'Mod Admin',
|
||||||
|
role: 'moderator',
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_login: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const audit = [
|
||||||
|
{
|
||||||
|
id: 'audit-e2e-1',
|
||||||
|
admin_id: 'admin-e2e-1',
|
||||||
|
email: 'e2e@eventhub.local',
|
||||||
|
role: 'superadmin',
|
||||||
|
action: 'update_user',
|
||||||
|
entity_type: 'user',
|
||||||
|
entity_id: 'user-e2e-1',
|
||||||
|
timestamp: now,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
reason: 'e2e',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { users, calendars, events, subscriptions, bannedWords, admins, audit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentState = ReturnType<typeof createContentSeed>;
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import type { Route } from '@playwright/test';
|
||||||
|
import type { ContentState } from './contentSeed';
|
||||||
|
|
||||||
|
type JsonFn = (
|
||||||
|
route: Route,
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
function listSlice<T>(items: T[], url: URL): { rows: T[]; total: number } {
|
||||||
|
const limit = Number(url.searchParams.get('limit') || 50);
|
||||||
|
const offset = Number(url.searchParams.get('offset') || 0);
|
||||||
|
const q = (url.searchParams.get('q') || '').toLowerCase();
|
||||||
|
let rows = items;
|
||||||
|
if (q) {
|
||||||
|
rows = items.filter((item) => JSON.stringify(item).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
if (status && rows.length && typeof (rows[0] as { status?: string }).status === 'string') {
|
||||||
|
rows = rows.filter((item) => (item as { status?: string }).status === status);
|
||||||
|
}
|
||||||
|
const plan = url.searchParams.get('plan');
|
||||||
|
if (plan) {
|
||||||
|
rows = rows.filter((item) => (item as { plan?: string }).plan === plan);
|
||||||
|
}
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
if (type) {
|
||||||
|
rows = rows.filter((item) => (item as { type?: string }).type === type);
|
||||||
|
}
|
||||||
|
return { rows: rows.slice(offset, offset + limit), total: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the request was handled. */
|
||||||
|
export async function tryHandleContentApi(
|
||||||
|
route: Route,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
url: URL,
|
||||||
|
content: ContentState,
|
||||||
|
json: JsonFn,
|
||||||
|
postData: () => unknown
|
||||||
|
): Promise<boolean> {
|
||||||
|
// ---- users ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/users/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_users: content.users.length,
|
||||||
|
users_by_role: {},
|
||||||
|
users_by_status: {},
|
||||||
|
pending_users: 0,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/users')) {
|
||||||
|
const page = listSlice(content.users, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/users\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.users.findIndex((u) => u.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.users[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.users[idx] = { ...content.users[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.users[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.users.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- calendars ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/calendars/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_calendars: content.calendars.length,
|
||||||
|
calendars_by_type: {},
|
||||||
|
calendars_by_status: {},
|
||||||
|
top_calendars_by_reviews: [],
|
||||||
|
top_calendars_by_positive_reviews: [],
|
||||||
|
top_calendars_by_negative_reviews: [],
|
||||||
|
top_calendars_by_rating: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/calendars')) {
|
||||||
|
const page = listSlice(content.calendars, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/calendars\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.calendars.findIndex((c) => c.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.calendars[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.calendars[idx] = { ...content.calendars[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.calendars[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.calendars.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- events ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/events/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_events: content.events.length,
|
||||||
|
events_by_type: {},
|
||||||
|
events_by_status: {},
|
||||||
|
top_events_by_rating: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/events')) {
|
||||||
|
const page = listSlice(content.events, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/events\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.events.findIndex((e) => e.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.events[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.events[idx] = { ...content.events[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.events[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.events.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- subscriptions ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions/stats')) {
|
||||||
|
await json(route, 200, {
|
||||||
|
total_subscriptions: content.subscriptions.length,
|
||||||
|
subscriptions_by_plan: {},
|
||||||
|
subscriptions_by_status: {},
|
||||||
|
trial_subscriptions: 0,
|
||||||
|
ending_paid_subscriptions: [],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/subscriptions')) {
|
||||||
|
const page = listSlice(content.subscriptions, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/subscriptions\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.subscriptions.findIndex((s) => s.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.subscriptions[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
const patch = (postData() || {}) as Record<string, unknown>;
|
||||||
|
delete patch.id;
|
||||||
|
content.subscriptions[idx] = { ...content.subscriptions[idx], ...patch, id };
|
||||||
|
await json(route, 200, content.subscriptions[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.subscriptions.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- banned words ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/banned-words')) {
|
||||||
|
const page = listSlice(
|
||||||
|
content.bannedWords.map((w) => ({ ...w, id: w.word })),
|
||||||
|
url
|
||||||
|
);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path.endsWith('/v1/admin/banned-words')) {
|
||||||
|
const payload = postData() as { word?: string };
|
||||||
|
const word = (payload.word || '').trim();
|
||||||
|
if (word) content.bannedWords.push({ word, added_by: 'admin-e2e-1', added_at: '2026-07-14T12:00:00Z' });
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/banned-words\/([^/]+)$/);
|
||||||
|
if (m && method === 'DELETE') {
|
||||||
|
const word = decodeURIComponent(m[1]);
|
||||||
|
content.bannedWords = content.bannedWords.filter((w) => w.word !== word);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- automod ----
|
||||||
|
if (!('_automod' in content)) {
|
||||||
|
(content as { _automod?: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod = {
|
||||||
|
settings: {
|
||||||
|
keyword_action: 'flag',
|
||||||
|
match_mode: 'word_boundary',
|
||||||
|
report_threshold: 3,
|
||||||
|
updated_at: '2026-07-14T12:00:00Z',
|
||||||
|
updated_by: 'admin-e2e-1',
|
||||||
|
},
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
id: 'hit-1',
|
||||||
|
entity_type: 'event',
|
||||||
|
entity_id: 'evt-1',
|
||||||
|
trigger: 'keyword',
|
||||||
|
matched_words: ['spam'],
|
||||||
|
action_taken: 'flag',
|
||||||
|
status: 'open',
|
||||||
|
created_at: '2026-07-14T12:00:00Z',
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const automod = (content as { _automod: { settings: Record<string, unknown>; hits: Record<string, unknown>[] } })._automod;
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/automod/settings')) {
|
||||||
|
await json(route, 200, automod.settings);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT' && path.endsWith('/v1/admin/automod/settings')) {
|
||||||
|
const payload = postData() as Record<string, unknown>;
|
||||||
|
automod.settings = { ...automod.settings, ...payload };
|
||||||
|
await json(route, 200, automod.settings);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/automod/hits')) {
|
||||||
|
const status = url.searchParams.get('status');
|
||||||
|
const rows = status
|
||||||
|
? automod.hits.filter((h) => h.status === status)
|
||||||
|
: automod.hits;
|
||||||
|
await json(route, 200, rows);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/automod\/hits\/([^/]+)$/);
|
||||||
|
if (m && method === 'PUT') {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const payload = postData() as { status?: string };
|
||||||
|
const idx = automod.hits.findIndex((h) => h.id === id);
|
||||||
|
if (idx >= 0 && payload.status) {
|
||||||
|
automod.hits[idx] = {
|
||||||
|
...automod.hits[idx],
|
||||||
|
status: payload.status,
|
||||||
|
resolved_at: '2026-07-14T13:00:00Z',
|
||||||
|
resolved_by: 'admin-e2e-1',
|
||||||
|
};
|
||||||
|
await json(route, 200, automod.hits[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await json(route, 404, { error: 'not found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- admins ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/admins')) {
|
||||||
|
const page = listSlice(content.admins, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path.endsWith('/v1/admin/admins')) {
|
||||||
|
const payload = postData() as Record<string, unknown>;
|
||||||
|
const created = {
|
||||||
|
id: `admin-new-${content.admins.length + 1}`,
|
||||||
|
email: String(payload.email || 'new@eventhub.local'),
|
||||||
|
nickname: String(payload.nickname || 'New Admin'),
|
||||||
|
role: String(payload.role || 'admin'),
|
||||||
|
language: 'ru',
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
timezone: 'UTC',
|
||||||
|
created_at: '2026-07-14T12:00:00Z',
|
||||||
|
updated_at: '2026-07-14T12:00:00Z',
|
||||||
|
last_login: null,
|
||||||
|
};
|
||||||
|
content.admins.push(created);
|
||||||
|
await json(route, 200, created);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const m = path.match(/\/v1\/admin\/admins\/([^/]+)$/);
|
||||||
|
if (m) {
|
||||||
|
const id = decodeURIComponent(m[1]);
|
||||||
|
const idx = content.admins.findIndex((a) => a.id === id);
|
||||||
|
if (idx < 0) {
|
||||||
|
await json(route, 404, { error: 'not_found' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
await json(route, 200, content.admins[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
content.admins[idx] = { ...content.admins[idx], ...(postData() as object) };
|
||||||
|
await json(route, 200, content.admins[idx]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
content.admins.splice(idx, 1);
|
||||||
|
await json(route, 200, { ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- audit ----
|
||||||
|
if (method === 'GET' && path.endsWith('/v1/admin/audit')) {
|
||||||
|
const page = listSlice(content.audit, url);
|
||||||
|
await json(route, 200, page.rows, { 'X-Total-Count': String(page.total) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- monitoring ----
|
||||||
|
if (method === 'GET' && path.includes('/v1/admin/nodes/metrics')) {
|
||||||
|
await json(route, 200, [
|
||||||
|
{
|
||||||
|
active_sessions: 3,
|
||||||
|
io_in: 10,
|
||||||
|
io_out: 20,
|
||||||
|
memory_atom: 1,
|
||||||
|
memory_binary: 2,
|
||||||
|
memory_ets: 3,
|
||||||
|
memory_processes: 4,
|
||||||
|
memory_total: 2048,
|
||||||
|
mnesia_commits: 1,
|
||||||
|
mnesia_failures: 0,
|
||||||
|
node: 'node-a',
|
||||||
|
process_count: 100,
|
||||||
|
run_queue: 0,
|
||||||
|
table_sizes: { users: 10 },
|
||||||
|
timestamp: '2026-07-14T12:00:00Z',
|
||||||
|
ws_connections: 5,
|
||||||
|
cpu_utilization: 12,
|
||||||
|
memory_available: 1024,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/** Stateful moderation fixtures for mock E2E. */
|
||||||
|
|
||||||
|
export type MockReport = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
target_type: 'calendar' | 'event' | 'review';
|
||||||
|
target_id: string;
|
||||||
|
reason: string;
|
||||||
|
status: 'pending' | 'reviewed' | 'dismissed';
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
resolved_by: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockTicket = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string;
|
||||||
|
error_hash: string;
|
||||||
|
error_message: string;
|
||||||
|
stacktrace: string;
|
||||||
|
context: string;
|
||||||
|
count: number;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
assigned_to: string | null;
|
||||||
|
resolution_note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MockReview = {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
target_type: 'calendar' | 'event';
|
||||||
|
target_id: string;
|
||||||
|
rating: number;
|
||||||
|
comment: string;
|
||||||
|
status: 'visible' | 'hidden' | 'deleted';
|
||||||
|
reason: string | null;
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = '2026-07-14T12:00:00Z';
|
||||||
|
|
||||||
|
export function createModerationSeed() {
|
||||||
|
const reports: MockReport[] = [
|
||||||
|
{
|
||||||
|
id: 'rpt-pending-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
target_type: 'event',
|
||||||
|
target_id: 'evt-1',
|
||||||
|
reason: 'Spam content',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rpt-pending-2',
|
||||||
|
reporter_id: 'user-2',
|
||||||
|
target_type: 'calendar',
|
||||||
|
target_id: 'cal-1',
|
||||||
|
reason: 'Offensive title',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: null,
|
||||||
|
resolved_by: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rpt-done-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
target_type: 'review',
|
||||||
|
target_id: 'rev-1',
|
||||||
|
reason: 'Duplicate',
|
||||||
|
status: 'reviewed',
|
||||||
|
created_at: now,
|
||||||
|
resolved_at: now,
|
||||||
|
resolved_by: 'admin-e2e-1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tickets: MockTicket[] = [
|
||||||
|
{
|
||||||
|
id: 'tkt-open-1',
|
||||||
|
reporter_id: 'user-1',
|
||||||
|
error_hash: 'hash-open-1',
|
||||||
|
error_message: 'NullPointer in join',
|
||||||
|
stacktrace: 'at joiner:42',
|
||||||
|
context: '{}',
|
||||||
|
count: 3,
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
status: 'open',
|
||||||
|
assigned_to: null,
|
||||||
|
resolution_note: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tkt-prog-1',
|
||||||
|
reporter_id: 'user-2',
|
||||||
|
error_hash: 'hash-prog-1',
|
||||||
|
error_message: 'Timeout on sync',
|
||||||
|
stacktrace: 'at sync:10',
|
||||||
|
context: '{}',
|
||||||
|
count: 1,
|
||||||
|
first_seen: now,
|
||||||
|
last_seen: now,
|
||||||
|
status: 'in_progress',
|
||||||
|
assigned_to: 'admin-e2e-1',
|
||||||
|
resolution_note: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const reviews: MockReview[] = [
|
||||||
|
{
|
||||||
|
id: 'rev-vis-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
target_type: 'event',
|
||||||
|
target_id: 'evt-1',
|
||||||
|
rating: 5,
|
||||||
|
comment: 'Great event',
|
||||||
|
status: 'visible',
|
||||||
|
reason: null,
|
||||||
|
likes: 2,
|
||||||
|
dislikes: 0,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rev-vis-2',
|
||||||
|
user_id: 'user-2',
|
||||||
|
target_type: 'calendar',
|
||||||
|
target_id: 'cal-1',
|
||||||
|
rating: 1,
|
||||||
|
comment: 'Bad calendar',
|
||||||
|
status: 'visible',
|
||||||
|
reason: null,
|
||||||
|
likes: 0,
|
||||||
|
dislikes: 1,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return { reports, tickets, reviews };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModerationState = ReturnType<typeof createModerationSeed>;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { installAdminApiMocks, MOCK_ADMIN } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
test.describe('auth / login (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installAdminApiMocks(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('успешный вход ведёт на dashboard', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill('e2e-pass');
|
||||||
|
await page.getByRole('button', { name: /войти|log in|sign in/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('неверный пароль показывает ошибку', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel(/email/i).fill(MOCK_ADMIN.email);
|
||||||
|
await page.getByLabel(/пароль|password/i).fill('wrong');
|
||||||
|
await page.getByTestId('btn-login').click();
|
||||||
|
await expect(page.getByText(/неверный email или пароль|invalid/i)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
async function openRowAction(page: Page, id: string, label: RegExp) {
|
||||||
|
await page.getByTestId(`row-actions-${id}`).click();
|
||||||
|
await page.getByRole('menuitem', { name: label }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radix Select in edit dialogs — ensure option chosen so Save enables. */
|
||||||
|
async function pickDialogOption(page: Page, dialogTestId: string, comboboxIndex: number, option: string) {
|
||||||
|
const dialog = page.getByTestId(dialogTestId);
|
||||||
|
await dialog.getByRole('combobox').nth(comboboxIndex).click();
|
||||||
|
await page.getByRole('option', { name: option, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('content explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users: список, поиск, detail, edit, delete', async ({ page }) => {
|
||||||
|
await page.goto('/users');
|
||||||
|
await expect(page.getByTestId('page-users')).toBeVisible();
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-user-e2e-1')).toBeVisible();
|
||||||
|
await page.getByTestId('filter-users-search-input').fill('Alice');
|
||||||
|
await page.getByTestId('filter-users-search').locator('button[type="submit"]').click();
|
||||||
|
await expect(page.getByTestId('row-user-e2e-1')).toBeVisible();
|
||||||
|
|
||||||
|
await openRowAction(page, 'user-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-user')).toBeVisible();
|
||||||
|
await page.getByLabel(/ник|nickname/i).fill('Alice E2E');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-user', 0, 'user');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-user', 1, 'active');
|
||||||
|
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-edit-user')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto('/users/user-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-user-detail')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/users');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await openRowAction(page, 'user-e2e-2', /удал|delete/i);
|
||||||
|
await expect(page.getByTestId('dialog-delete-user')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-delete-confirm').click();
|
||||||
|
await expect(page.getByTestId('row-user-e2e-2')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calendars: edit и detail', async ({ page }) => {
|
||||||
|
await page.goto('/calendars');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-cal-e2e-1')).toBeVisible();
|
||||||
|
await openRowAction(page, 'cal-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-calendar')).toBeVisible();
|
||||||
|
await page.getByTestId('dialog-edit-calendar').locator('input').first().fill('E2E Calendar');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-calendar', 0, 'personal');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-calendar', 1, 'active');
|
||||||
|
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-edit-calendar')).toHaveCount(0);
|
||||||
|
await page.goto('/calendars/cal-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-calendar-detail')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('events: edit и detail', async ({ page }) => {
|
||||||
|
await page.goto('/events');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-evt-e2e-1')).toBeVisible();
|
||||||
|
await openRowAction(page, 'evt-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-event')).toBeVisible();
|
||||||
|
await page.getByTestId('dialog-edit-event').locator('input').first().fill('E2E Workout');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-event', 0, 'single');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-event', 1, 'active');
|
||||||
|
await expect(page.getByTestId('btn-save')).toBeEnabled();
|
||||||
|
// Tall dialog: footer can sit outside viewport; submit via form API.
|
||||||
|
await page.getByTestId('dialog-edit-event').locator('form').evaluate((form) => {
|
||||||
|
(form as HTMLFormElement).requestSubmit();
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId('dialog-edit-event')).toHaveCount(0);
|
||||||
|
await page.goto('/events/evt-e2e-1');
|
||||||
|
await expect(page.getByTestId('page-event-detail')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscriptions: edit и delete', async ({ page }) => {
|
||||||
|
await page.goto('/subscriptions');
|
||||||
|
await page.getByTestId('view-rows').click();
|
||||||
|
await expect(page.getByTestId('row-sub-e2e-1')).toBeVisible();
|
||||||
|
await openRowAction(page, 'sub-e2e-1', /редакт|edit/i);
|
||||||
|
await expect(page.getByTestId('dialog-edit-subscription')).toBeVisible();
|
||||||
|
await pickDialogOption(page, 'dialog-edit-subscription', 0, 'monthly');
|
||||||
|
await pickDialogOption(page, 'dialog-edit-subscription', 1, 'active');
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-edit-subscription')).toHaveCount(0);
|
||||||
|
await openRowAction(page, 'sub-e2e-1', /удал|delete/i);
|
||||||
|
await expect(page.getByTestId('dialog-delete-subscription')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-delete-confirm').click();
|
||||||
|
await expect(page.getByTestId('row-sub-e2e-1')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('banned-words: список и add', async ({ page }) => {
|
||||||
|
await page.goto('/banned-words');
|
||||||
|
await expect(page.getByTestId('page-banned-words')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-spamword')).toBeVisible();
|
||||||
|
await page.getByTestId('input-banned-word').fill('newbad');
|
||||||
|
await page.getByTestId('btn-add-banned-word').click();
|
||||||
|
await expect(page.getByTestId('row-newbad')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
test.describe('inbox reports (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('очередь pending, select и reviewed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await expect(page.getByTestId('page-inbox-reports')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-done-1')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId('report-select-rpt-pending-1').click();
|
||||||
|
await expect(page.getByTestId('inbox-report-detail')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-report-reviewed').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filter all показывает reviewed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await page.getByTestId('filter-all').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-done-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bulk dismissed', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await page.getByTestId('report-check-rpt-pending-1').check();
|
||||||
|
await page.getByTestId('report-check-rpt-pending-2').check();
|
||||||
|
await page.getByTestId('btn-bulk-dismissed').click();
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-1')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('report-row-rpt-pending-2')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('inbox tickets (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open → in_progress → resolved', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/tickets');
|
||||||
|
await expect(page.getByTestId('page-inbox-tickets')).toBeVisible();
|
||||||
|
await page.getByTestId('ticket-row-tkt-open-1').click();
|
||||||
|
await page.getByTestId('btn-ticket-in-progress').click();
|
||||||
|
await expect(page.getByTestId('btn-ticket-resolved')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-ticket-resolved').click();
|
||||||
|
// resolved leaves active queue
|
||||||
|
await expect(page.getByTestId('ticket-row-tkt-open-1')).toHaveCount(0);
|
||||||
|
await page.getByTestId('filter-all').click();
|
||||||
|
await expect(page.getByTestId('ticket-row-tkt-open-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('reports explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('список и detail reviewed с confirm', async ({ page }) => {
|
||||||
|
await page.goto('/reports');
|
||||||
|
await expect(page.getByTestId('page-reports')).toBeVisible();
|
||||||
|
await page.goto('/reports/rpt-pending-1');
|
||||||
|
await expect(page.getByTestId('page-report-detail')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-report-reviewed').click();
|
||||||
|
await expect(page.getByTestId('dialog-confirm-report-status')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-confirm-status').click();
|
||||||
|
await expect(page.getByTestId('btn-report-reviewed')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('reviews explore (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('страница reviews и bulk hide', async ({ page }) => {
|
||||||
|
await page.goto('/reviews');
|
||||||
|
await expect(page.getByTestId('page-reviews')).toBeVisible();
|
||||||
|
await page.getByTestId('view-table').click();
|
||||||
|
await expect(page.getByText('Great event')).toBeVisible();
|
||||||
|
await page.getByTestId('review-check-rev-vis-1').check();
|
||||||
|
await page.getByTestId('btn-reviews-bulk-hidden').click();
|
||||||
|
await expect(page.getByTestId('dialog-reviews-bulk-status')).toBeVisible();
|
||||||
|
await page.getByTestId('input-bulk-reason').fill('e2e hide');
|
||||||
|
await page.getByTestId('btn-save').click();
|
||||||
|
await expect(page.getByTestId('dialog-reviews-bulk-status')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
import { loginAsE2EAdmin } from '../helpers/auth';
|
||||||
|
import { installAdminApiMocks } from '../mocks/adminApi';
|
||||||
|
|
||||||
|
test.describe('shell / layout (mock)', () => {
|
||||||
|
test('после логина видны sidebar, nav dashboard и user menu', async ({ page }) => {
|
||||||
|
await installAdminApiMocks(page);
|
||||||
|
await loginAsE2EAdmin(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('layout-sidebar')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-dashboard')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-users')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-admins')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await expect(page.getByTestId('menu-user-content')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('menu-profile')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('btn-logout')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command palette открывается и навигирует', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('btn-open-palette').click();
|
||||||
|
await expect(page.getByTestId('command-palette')).toBeVisible();
|
||||||
|
await page.getByTestId('palette-input').fill('Пользователи');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page).toHaveURL(/\/users/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout возвращает на login', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await page.getByTestId('btn-logout').click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('shell / roles (mock)', () => {
|
||||||
|
test('moderator: нет nav users, admins запрещён', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page, { role: 'moderator', nickname: 'Mod' });
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('nav-inbox-reports')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-users')).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId('nav-admins')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto('/admins');
|
||||||
|
await expect(page.getByTestId('page-forbidden')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-to-dashboard').click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('support: tickets inbox есть, reports inbox нет', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page, { role: 'support', nickname: 'Support' });
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('nav-inbox-tickets')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('nav-inbox-reports')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto('/inbox/reports');
|
||||||
|
await expect(page.getByTestId('page-forbidden')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { hasStandCredentials, loginAsStandAdmin } from '../../helpers/standAuth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Интеграция UI ↔ admin-api на IFT/stage после деплоя.
|
||||||
|
* Без page.route mocks — только живой SPA и API стенда.
|
||||||
|
*/
|
||||||
|
test.describe('stand integration (live API)', () => {
|
||||||
|
test.skip(!hasStandCredentials(), 'SMOKE_ADMIN_* / ADMIN_SUPER_* from stand .env required');
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAsStandAdmin(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard после login', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit: страница и таблица без ошибки рендера', async ({ page }) => {
|
||||||
|
await page.goto('/audit');
|
||||||
|
await expect(page.getByTestId('page-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('filter-audit')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Ошибка отображения|Minified React error/i)).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-testid="page-audit"] table tbody tr').first()).toBeVisible({
|
||||||
|
timeout: 20_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users: список загружается', async ({ page }) => {
|
||||||
|
await page.goto('/users');
|
||||||
|
await expect(page.getByTestId('page-users')).toBeVisible();
|
||||||
|
await expect(page.getByText(/Ошибка отображения|Minified React error/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admins: superadmin видит список', async ({ page }) => {
|
||||||
|
await page.goto('/admins');
|
||||||
|
await expect(page.getByTestId('page-admins')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('page-forbidden')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monitoring: страница загружается', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
await expect(page.getByTestId('page-monitoring')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout', async ({ page }) => {
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await page.getByTestId('btn-logout').click();
|
||||||
|
await expect(page.getByTestId('page-login')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
test.describe('system / overview (mock)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admins: список, create dialog, detail', async ({ page }) => {
|
||||||
|
await page.goto('/admins');
|
||||||
|
await expect(page.getByTestId('page-admins')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-admin-e2e-2')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-create-admin').click();
|
||||||
|
await expect(page.getByTestId('dialog-create-admin')).toBeVisible();
|
||||||
|
await page.goto('/admins/admin-e2e-2');
|
||||||
|
await expect(page.getByTestId('page-admin-detail')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('btn-edit-admin')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit: страница и фильтры', async ({ page }) => {
|
||||||
|
await page.goto('/audit');
|
||||||
|
await expect(page.getByTestId('page-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('filter-audit')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('row-audit-e2e-1')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monitoring: страница, range и node', async ({ page }) => {
|
||||||
|
await page.goto('/monitoring');
|
||||||
|
await expect(page.getByTestId('page-monitoring')).toBeVisible();
|
||||||
|
await page.getByTestId('filter-monitoring-range-30').click();
|
||||||
|
await page.getByTestId('filter-monitoring-node-node-a').click();
|
||||||
|
await expect(page).toHaveURL(/node=node-a/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile: view и edit', async ({ page }) => {
|
||||||
|
await page.goto('/profile');
|
||||||
|
await expect(page.getByTestId('page-profile')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-edit-profile').click();
|
||||||
|
await expect(page.getByTestId('btn-save-profile')).toBeVisible();
|
||||||
|
await page.getByTestId('btn-cancel-profile').click();
|
||||||
|
await expect(page.getByTestId('btn-edit-profile')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard остаётся доступен', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('page-dashboard')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('i18n (mock)', () => {
|
||||||
|
test('переключение языка через меню меняет UI', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await expect(page.getByTestId('menu-profile')).toHaveText(/Мой профиль/i);
|
||||||
|
await page.getByTestId('lang-en').click();
|
||||||
|
// Dropdown may close; reopen after store + i18n sync from PUT /me
|
||||||
|
await expect(page.getByText(/Профиль обновлён|Profile updated/i)).toBeVisible({ timeout: 15_000 });
|
||||||
|
await page.getByTestId('menu-user').click();
|
||||||
|
await expect(page.getByTestId('menu-profile')).toHaveText(/My profile/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авто-capture фронтовых ошибок Admin SPA → POST /v1/tickets (source=frontend).
|
||||||
|
* Ручной «сообщить о проблеме» — клиентский сценарий, не UI Control Center.
|
||||||
|
*/
|
||||||
|
test.describe('frontend error auto-report (mock)', () => {
|
||||||
|
test('reportClientError шлёт POST /v1/tickets с source=frontend', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
|
||||||
|
let reported: { error_message?: string; source?: string } | null = null;
|
||||||
|
await page.route('**/v1/tickets', async (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
reported = route.request().postDataJSON() as typeof reported;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 't-fe-1',
|
||||||
|
source: 'frontend',
|
||||||
|
status: 'open',
|
||||||
|
count: 1,
|
||||||
|
error_hash: 'abc',
|
||||||
|
error_message: reported?.error_message ?? 'err',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForFunction(() => typeof window.__ehReportClientError === 'function');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.__ehReportClientError!({
|
||||||
|
message: 'e2e-frontend-auto-report',
|
||||||
|
stack: 'Error: e2e-frontend-auto-report\n at e2e:1:1',
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(() => reported?.source, { timeout: 10_000 }).toBe('frontend');
|
||||||
|
expect(reported?.error_message).toContain('e2e-frontend-auto-report');
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-1
@@ -6,9 +6,10 @@ import tseslint from 'typescript-eslint'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'playwright-report', 'test-results', 'e2e']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
ignores: ['playwright.config.ts'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>eventhubfrontadmin</title>
|
<title>EventHub Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+4200
-926
File diff suppressed because it is too large
Load Diff
+26
-3
@@ -3,32 +3,55 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build:ift": "npm run build",
|
"build:ift": "npm run build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test --project=mock",
|
||||||
|
"test:e2e:ift": "E2E_TARGET=ift playwright test --project=ift-ui",
|
||||||
|
"test:e2e:stage": "E2E_TARGET=stage playwright test --project=stage-ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.2.3",
|
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.2.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.19",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||||
|
"@radix-ui/react-label": "^2.1.11",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||||
|
"@radix-ui/react-select": "^2.3.3",
|
||||||
|
"@radix-ui/react-separator": "^1.1.11",
|
||||||
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.17",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.12",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"antd": "^6.4.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"i18next": "^26.2.0",
|
"i18next": "^26.2.0",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.76.0",
|
"react-hook-form": "^7.76.0",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const port = Number(process.env.E2E_PORT || 4173);
|
||||||
|
const baseURL = process.env.E2E_BASE_URL || `http://127.0.0.1:${port}`;
|
||||||
|
const iftBaseURL = process.env.E2E_IFT_BASE_URL || 'https://admin-ui.ift.eventhub.local';
|
||||||
|
const stageBaseURL = process.env.E2E_STAGE_BASE_URL || 'https://admin-ui.stage.eventhub.local';
|
||||||
|
const liveTarget = process.env.E2E_TARGET;
|
||||||
|
const liveChrome = {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mock-suite (CI / локально): preview + page.route mocks. */
|
||||||
|
const mockProject = {
|
||||||
|
name: 'mock',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
testIgnore: [/stand\//, /ift\//],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** IFT/stage после деплоя: живой SPA + admin-api, без mocks. */
|
||||||
|
const standTestMatch = /stand\//;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e/tests',
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
|
timeout: 90_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
locale: 'ru-RU',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
mockProject,
|
||||||
|
{
|
||||||
|
name: 'ift-ui',
|
||||||
|
use: { ...liveChrome, baseURL: iftBaseURL },
|
||||||
|
testMatch: standTestMatch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stage-ui',
|
||||||
|
use: { ...liveChrome, baseURL: stageBaseURL },
|
||||||
|
testMatch: standTestMatch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: liveTarget
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
// strictPort: не молчать, если порт занят зомби act-preview
|
||||||
|
command: `npm run preview -- --host 127.0.0.1 --port ${port} --strictPort`,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Act hostexecutor делит сеть с хостом: после cancel/fail остаётся vite preview
|
||||||
|
# с cwd (deleted), порт занят, HTTP 404 → Playwright webServer ждёт 2xx до таймаута.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${E2E_PORT:-4173}"
|
||||||
|
|
||||||
|
free_port() {
|
||||||
|
local pids=""
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
fuser -k "${PORT}/tcp" >/dev/null 2>&1 || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
|
pids="$(lsof -t -iTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||||
|
elif command -v ss >/dev/null 2>&1; then
|
||||||
|
pids="$(ss -ltnp "sport = :${PORT}" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u || true)"
|
||||||
|
fi
|
||||||
|
if [[ -n "${pids}" ]]; then
|
||||||
|
echo "free-e2e-port: killing PIDs on :${PORT}: ${pids}"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
kill ${pids} 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
kill -9 ${pids} 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "free-e2e-port: ensuring :${PORT} is free"
|
||||||
|
free_port
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
if ss -ltn | grep -qE ":${PORT}\\s"; then
|
||||||
|
echo "WARN: :${PORT} still in use after free attempt" >&2
|
||||||
|
ss -ltnp | grep ":${PORT}" || true
|
||||||
|
else
|
||||||
|
echo "free-e2e-port: :${PORT} is free"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install Playwright Chromium for CI on self-hosted act_runner/WSL.
|
||||||
|
# --with-deps runs apt and can fail when openssh-server dpkg is half-configured
|
||||||
|
# (systemctl unavailable in WSL job container).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if npx playwright install --with-deps chromium; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::warning::playwright --with-deps failed; installing browser binary only (system libs expected on runner)"
|
||||||
|
npx playwright install chromium
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Load SMOKE_ADMIN_* (fallback ADMIN_SUPER_*) from stand .env via SSH into GITHUB_ENV.
|
||||||
|
# Usage: load-stand-smoke-env.sh <ift|stage>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STAND="${1:?stand: ift|stage}"
|
||||||
|
case "${STAND}" in
|
||||||
|
ift)
|
||||||
|
SSH_HOST="${SSH_HOST:?IFT_SSH_HOST required}"
|
||||||
|
SSH_USER="${SSH_USER:?IFT_SSH_USER required}"
|
||||||
|
SSH_KEY="${SSH_KEY:?IFT_SSH_PRIVATE_KEY required}"
|
||||||
|
ENV_FILE="/opt/eventhub-ift/.env"
|
||||||
|
;;
|
||||||
|
stage)
|
||||||
|
SSH_HOST="${SSH_HOST:?STAGE_SSH_HOST required}"
|
||||||
|
SSH_USER="${SSH_USER:?STAGE_SSH_USER required}"
|
||||||
|
SSH_KEY="${SSH_KEY:?STAGE_SSH_PRIVATE_KEY required}"
|
||||||
|
ENV_FILE="/opt/eventhub-stage/.env"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown stand: ${STAND}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
KEY="$(mktemp)"
|
||||||
|
printf '%s\n' "${SSH_KEY}" > "${KEY}"
|
||||||
|
chmod 600 "${KEY}"
|
||||||
|
|
||||||
|
REMOTE="$(ssh -i "${KEY}" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
||||||
|
"${SSH_USER}@${SSH_HOST}" \
|
||||||
|
"grep -E '^(SMOKE_ADMIN_EMAIL|SMOKE_ADMIN_PASSWORD|ADMIN_SUPER_EMAIL|ADMIN_SUPER_PASSWORD)=' ${ENV_FILE} || true")"
|
||||||
|
rm -f "${KEY}"
|
||||||
|
|
||||||
|
EMAIL="$(printf '%s\n' "${REMOTE}" | sed -n 's/^SMOKE_ADMIN_EMAIL=//p' | head -1)"
|
||||||
|
PASS="$(printf '%s\n' "${REMOTE}" | sed -n 's/^SMOKE_ADMIN_PASSWORD=//p' | head -1)"
|
||||||
|
if [[ -z "${EMAIL}" ]]; then
|
||||||
|
EMAIL="$(printf '%s\n' "${REMOTE}" | sed -n 's/^ADMIN_SUPER_EMAIL=//p' | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${PASS}" ]]; then
|
||||||
|
PASS="$(printf '%s\n' "${REMOTE}" | sed -n 's/^ADMIN_SUPER_PASSWORD=//p' | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ -z "${EMAIL}" || -z "${PASS}" ]]; then
|
||||||
|
echo "::error::В ${ENV_FILE} нет SMOKE_ADMIN_* / ADMIN_SUPER_*"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "SMOKE_ADMIN_EMAIL=${EMAIL}" >> "${GITHUB_ENV}"
|
||||||
|
echo "SMOKE_ADMIN_PASSWORD=${PASS}" >> "${GITHUB_ENV}"
|
||||||
|
fi
|
||||||
|
echo "Loaded smoke admin email: ${EMAIL}"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Product version (MAJOR.MINOR) из EventHubSpec/VERSION.
|
||||||
|
# Fallback: локальный VERSION (офлайн / без токена).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${GITEA_BASE_URL:-https://git.sabilin.com}"
|
||||||
|
TOKEN="${GITEA_TOKEN:-${REGISTRY_PASSWORD:-}}"
|
||||||
|
REF="${PRODUCT_VERSION_REF:-master}"
|
||||||
|
FALLBACK_FILE="${PRODUCT_VERSION_FILE:-VERSION}"
|
||||||
|
|
||||||
|
fetch_spec() {
|
||||||
|
local url="${BASE}/api/v1/repos/EventHub/EventHubSpec/raw/VERSION?ref=${REF}"
|
||||||
|
if [[ -n "${TOKEN}" ]]; then
|
||||||
|
curl -fsSL -H "Authorization: token ${TOKEN}" "${url}"
|
||||||
|
else
|
||||||
|
curl -fsSL "${url}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ver=""
|
||||||
|
if raw="$(fetch_spec 2>/dev/null)"; then
|
||||||
|
ver="$(printf '%s' "${raw}" | grep -Eo '[0-9]+\.[0-9]+' | head -1 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${ver}" && -f "${FALLBACK_FILE}" ]]; then
|
||||||
|
echo "WARN: EventHubSpec/VERSION недоступен — fallback ${FALLBACK_FILE}" >&2
|
||||||
|
ver="$(grep -Eo '[0-9]+\.[0-9]+' "${FALLBACK_FILE}" | head -1 || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${ver}" ]]; then
|
||||||
|
echo "ERROR: не удалось получить product version (Spec + local VERSION)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "${ver}" =~ ^[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "ERROR: некорректный VERSION '${ver}' (ожидается MAJOR.MINOR)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${ver}"
|
||||||
+21
-6
@@ -1,10 +1,9 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Spin } from 'antd';
|
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import RoleGuard from './components/RoleGuard';
|
import RoleGuard from './components/RoleGuard';
|
||||||
import AdminLayout from './layouts/AdminLayout';
|
import ControlCenterLayout from './layouts/ControlCenterLayout';
|
||||||
import LoginPage from './pages/auth/LoginPage';
|
import LoginPage from './pages/auth/LoginPage';
|
||||||
import ProfilePage from './pages/profile/ProfilePage';
|
import ProfilePage from './pages/profile/ProfilePage';
|
||||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||||
@@ -20,13 +19,18 @@ import ReportDetailPage from './pages/reports/ReportDetailPage';
|
|||||||
import ReviewListPage from './pages/reviews/ReviewListPage';
|
import ReviewListPage from './pages/reviews/ReviewListPage';
|
||||||
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
|
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
|
||||||
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
|
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
|
||||||
|
import AutomodSettingsPage from './pages/automod/AutomodSettingsPage';
|
||||||
|
import AutomodInboxPage from './pages/automod/AutomodInboxPage';
|
||||||
import TicketListPage from './pages/tickets/TicketListPage';
|
import TicketListPage from './pages/tickets/TicketListPage';
|
||||||
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
import TicketDetailPage from './pages/tickets/TicketDetailPage';
|
||||||
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
|
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
|
||||||
import AdminListPage from './pages/admins/AdminListPage';
|
import AdminListPage from './pages/admins/AdminListPage';
|
||||||
import AdminDetailPage from './pages/admins/AdminDetailPage';
|
import AdminDetailPage from './pages/admins/AdminDetailPage';
|
||||||
import AuditPage from './pages/audit/AuditPage';
|
import AuditPage from './pages/audit/AuditPage';
|
||||||
|
import ReportInboxPage from './pages/inbox/ReportInboxPage';
|
||||||
|
import TicketInboxPage from './pages/inbox/TicketInboxPage';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -35,12 +39,13 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
}, []);
|
}, [checkAuth]);
|
||||||
|
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<div className="flex min-h-screen items-center justify-center gap-3 p-8">
|
||||||
<Spin size="large" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,7 +56,7 @@ const App: React.FC = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<AdminLayout />}>
|
<Route element={<ControlCenterLayout />}>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|
||||||
<Route element={<RoleGuard />}>
|
<Route element={<RoleGuard />}>
|
||||||
@@ -60,6 +65,15 @@ const App: React.FC = () => {
|
|||||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'moderator']} />}>
|
||||||
|
<Route path="/inbox/reports" element={<ReportInboxPage />} />
|
||||||
|
<Route path="/inbox/automod" element={<AutomodInboxPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin', 'support']} />}>
|
||||||
|
<Route path="/inbox/tickets" element={<TicketInboxPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin']} />}>
|
<Route element={<RoleGuard allowedRoles={['superadmin', 'admin']} />}>
|
||||||
<Route path="/users" element={<UserListPage />} />
|
<Route path="/users" element={<UserListPage />} />
|
||||||
<Route path="/users/:id" element={<UserDetailPage />} />
|
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||||
@@ -68,6 +82,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="/events" element={<EventListPage />} />
|
<Route path="/events" element={<EventListPage />} />
|
||||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||||
<Route path="/banned-words" element={<BannedWordsPage />} />
|
<Route path="/banned-words" element={<BannedWordsPage />} />
|
||||||
|
<Route path="/automod-settings" element={<AutomodSettingsPage />} />
|
||||||
<Route path="/subscriptions" element={<SubscriptionListPage />} />
|
<Route path="/subscriptions" element={<SubscriptionListPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,8 @@ export const authApi = {
|
|||||||
const { data } = await apiClient.get('/v1/admin/me');
|
const { data } = await apiClient.get('/v1/admin/me');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
updateMe: async (payload: Partial<Admin>): Promise<Admin> => {
|
||||||
|
const { data } = await apiClient.put('/v1/admin/me', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type KeywordAction = 'reject' | 'flag' | 'censor';
|
||||||
|
export type MatchMode = 'word_boundary' | 'substring';
|
||||||
|
export type AutomodHitStatus = 'open' | 'approved' | 'rejected';
|
||||||
|
export type AutomodTrigger = 'keyword' | 'report_threshold';
|
||||||
|
|
||||||
|
export interface AutomodSettings {
|
||||||
|
keyword_action: KeywordAction;
|
||||||
|
match_mode: MatchMode;
|
||||||
|
report_threshold: number;
|
||||||
|
updated_at?: string;
|
||||||
|
updated_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomodHit {
|
||||||
|
id: string;
|
||||||
|
entity_type: 'calendar' | 'event' | 'review';
|
||||||
|
entity_id: string;
|
||||||
|
trigger: AutomodTrigger;
|
||||||
|
matched_words: string[];
|
||||||
|
action_taken: string;
|
||||||
|
status: AutomodHitStatus;
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
resolved_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomodHitListParams {
|
||||||
|
status?: AutomodHitStatus;
|
||||||
|
trigger?: AutomodTrigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const automodApi = {
|
||||||
|
getSettings: async (): Promise<AutomodSettings> => {
|
||||||
|
const { data } = await apiClient.get('/v1/admin/automod/settings');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
updateSettings: async (payload: Partial<AutomodSettings>): Promise<AutomodSettings> => {
|
||||||
|
const { data } = await apiClient.put('/v1/admin/automod/settings', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
getHits: async (params?: AutomodHitListParams): Promise<AutomodHit[]> => {
|
||||||
|
const { data } = await apiClient.get('/v1/admin/automod/hits', { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
resolveHit: async (id: string, status: 'approved' | 'rejected'): Promise<AutomodHit> => {
|
||||||
|
const { data } = await apiClient.put(`/v1/admin/automod/hits/${id}`, { status });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
+9
-1
@@ -10,6 +10,9 @@ const apiClient = axios.create({
|
|||||||
|
|
||||||
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
|
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
const isLoginRequest = (config?: InternalAxiosRequestConfig) =>
|
||||||
|
Boolean(config?.url?.includes('/v1/admin/login'));
|
||||||
|
|
||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
let pendingRequests: Array<{
|
let pendingRequests: Array<{
|
||||||
resolve: (token: string) => void;
|
resolve: (token: string) => void;
|
||||||
@@ -45,7 +48,12 @@ apiClient.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const originalRequest = error.config as RetryConfig | undefined;
|
const originalRequest = error.config as RetryConfig | undefined;
|
||||||
if (!originalRequest || error.response?.status !== 401 || originalRequest._retry) {
|
if (
|
||||||
|
!originalRequest ||
|
||||||
|
error.response?.status !== 401 ||
|
||||||
|
originalRequest._retry ||
|
||||||
|
isLoginRequest(originalRequest)
|
||||||
|
) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type AdminHealthInfo = {
|
||||||
|
status: string;
|
||||||
|
service?: string;
|
||||||
|
version?: string;
|
||||||
|
build?: number;
|
||||||
|
git_sha?: string;
|
||||||
|
built_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const healthApi = {
|
||||||
|
getAdminHealth: async (): Promise<AdminHealthInfo> => {
|
||||||
|
const { data } = await apiClient.get<AdminHealthInfo>('/v1/admin/health');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import { NodeMetric } from '../store/metricsStore';
|
||||||
|
|
||||||
|
export const monitoringApi = {
|
||||||
|
getNodeMetrics: async (from: string, to: string, node = 'all'): Promise<NodeMetric[]> => {
|
||||||
|
const { data } = await apiClient.get<NodeMetric[]>('/v1/admin/nodes/metrics', {
|
||||||
|
params: { from, to, node },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
+14
-1
@@ -1,6 +1,15 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
|
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
|
||||||
|
|
||||||
|
export type TicketSource = 'backend' | 'frontend' | 'manual';
|
||||||
|
|
||||||
|
export interface CreateTicketPayload {
|
||||||
|
error_message: string;
|
||||||
|
stacktrace?: string;
|
||||||
|
context?: Record<string, unknown> | string;
|
||||||
|
source?: TicketSource;
|
||||||
|
}
|
||||||
|
|
||||||
export const ticketsApi = {
|
export const ticketsApi = {
|
||||||
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
|
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
|
||||||
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
|
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
|
||||||
@@ -11,6 +20,10 @@ export const ticketsApi = {
|
|||||||
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
|
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
createTicket: async (payload: CreateTicketPayload): Promise<Ticket> => {
|
||||||
|
const { data } = await apiClient.post('/v1/tickets', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
|
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
|
||||||
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
|
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
|
||||||
},
|
},
|
||||||
@@ -21,4 +34,4 @@ export const ticketsApi = {
|
|||||||
const { data } = await apiClient.get('/v1/admin/tickets/stats');
|
const { data } = await apiClient.get('/v1/admin/tickets/stats');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
export interface AreaTrendSeries {
|
||||||
|
key: string;
|
||||||
|
color: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
xKey: string;
|
||||||
|
series: AreaTrendSeries[];
|
||||||
|
height?: number;
|
||||||
|
xMinTickGap?: number;
|
||||||
|
yAllowDecimals?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AreaTrendChart: React.FC<Props> = ({
|
||||||
|
data,
|
||||||
|
xKey,
|
||||||
|
series,
|
||||||
|
height = 280,
|
||||||
|
xMinTickGap = 30,
|
||||||
|
yAllowDecimals = true,
|
||||||
|
}) => (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
{series.map((s) => (
|
||||||
|
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={s.color} stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor={s.color} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey={xKey} minTickGap={xMinTickGap} />
|
||||||
|
<YAxis allowDecimals={yAllowDecimals} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
{series.map((s) => (
|
||||||
|
<Area
|
||||||
|
key={s.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={s.key}
|
||||||
|
stroke={s.color}
|
||||||
|
fill={`url(#grad-${s.key})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||||
|
name={s.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AreaTrendChart;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type BreadcrumbItem = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BreadcrumbsProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs({ items, className }: BreadcrumbsProps) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Хлебные крошки" className={cn('flex flex-wrap items-center gap-1 text-sm text-muted-foreground', className)}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={`${item.label}-${index}`} className="inline-flex items-center gap-1">
|
||||||
|
{index > 0 && <ChevronRight className="h-3.5 w-3.5 opacity-60" />}
|
||||||
|
{item.href && !isLast ? (
|
||||||
|
<Link to={item.href} className="hover:text-foreground hover:underline underline-offset-4">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={cn(isLast && 'font-medium text-foreground')}>{item.label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { NAV_GROUPS, filterNavGroupsByRole, canAccessRoute } from '@/config/adminAccess';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type PaletteItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks like EventHub entity ids (base64url-ish). */
|
||||||
|
const ENTITY_ID_RE = /^[A-Za-z0-9_-]{16,40}$/;
|
||||||
|
|
||||||
|
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
const items = useMemo<PaletteItem[]>(() => {
|
||||||
|
const groups = filterNavGroupsByRole(NAV_GROUPS, role);
|
||||||
|
const routes = groups.flatMap((g) =>
|
||||||
|
g.items.map((item) => ({
|
||||||
|
id: item.key,
|
||||||
|
label: t(item.label),
|
||||||
|
group: t(g.label),
|
||||||
|
path: item.key,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
routes.push(
|
||||||
|
{ id: '/profile', label: t('nav.profile'), group: t('navGroup.account'), path: '/profile' },
|
||||||
|
{ id: '/dashboard', label: t('nav.dashboard'), group: t('navGroup.overview'), path: '/dashboard' }
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return routes.filter((r) => {
|
||||||
|
if (seen.has(r.path)) return false;
|
||||||
|
seen.add(r.path);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [role, t]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
const qLower = q.toLowerCase();
|
||||||
|
const navHits = !qLower
|
||||||
|
? items
|
||||||
|
: items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.label.toLowerCase().includes(qLower) ||
|
||||||
|
item.group.toLowerCase().includes(qLower) ||
|
||||||
|
item.path.toLowerCase().includes(qLower)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ENTITY_ID_RE.test(q)) return navHits;
|
||||||
|
|
||||||
|
const jumps: PaletteItem[] = [];
|
||||||
|
const pushIf = (path: string, labelKey: string, id: string) => {
|
||||||
|
if (canAccessRoute(path.split('?')[0], role)) {
|
||||||
|
jumps.push({
|
||||||
|
id,
|
||||||
|
label: `${t(labelKey)} · ${q}`,
|
||||||
|
group: t('layout.paletteJumpGroup'),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pushIf(`/inbox/reports?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenReportInbox', `jump-report-inbox-${q}`);
|
||||||
|
pushIf(`/reports/${encodeURIComponent(q)}`, 'layout.paletteOpenReport', `jump-report-${q}`);
|
||||||
|
pushIf(`/inbox/tickets?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenTicketInbox', `jump-ticket-inbox-${q}`);
|
||||||
|
pushIf(`/tickets/${encodeURIComponent(q)}`, 'layout.paletteOpenTicket', `jump-ticket-${q}`);
|
||||||
|
pushIf(`/events/${encodeURIComponent(q)}`, 'layout.paletteOpenEvent', `jump-event-${q}`);
|
||||||
|
pushIf(`/users/${encodeURIComponent(q)}`, 'layout.paletteOpenUser', `jump-user-${q}`);
|
||||||
|
return [...jumps, ...navHits];
|
||||||
|
}, [items, query, role, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}, [query, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setQuery('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
onOpenChange(false);
|
||||||
|
navigate(path);
|
||||||
|
},
|
||||||
|
[navigate, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === 'Enter' && filtered[activeIndex]) {
|
||||||
|
e.preventDefault();
|
||||||
|
go(filtered[activeIndex].path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
data-testid="command-palette"
|
||||||
|
className="gap-0 overflow-hidden p-0 sm:max-w-lg"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
data-testid="palette-input"
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={t('layout.palettePlaceholder')}
|
||||||
|
className="h-12 border-0 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-72 overflow-auto py-2" role="listbox">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t('common.nothingFound')}
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((item, index) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === activeIndex}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between px-4 py-2 text-left text-sm',
|
||||||
|
index === activeIndex ? 'bg-accent' : 'hover:bg-muted/60'
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => setActiveIndex(index)}
|
||||||
|
onClick={() => go(item.path)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.group}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
|
||||||
|
{t('layout.paletteHints')}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { DayCount } from '../types/api';
|
||||||
|
import AreaTrendChart from './AreaTrendChart';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
data?: DayCount[];
|
||||||
|
color: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DailyLineChartCard: React.FC<Props> = ({ title, data, color, className }) => {
|
||||||
|
const chartData = (data ?? []).map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
count: item.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AreaTrendChart
|
||||||
|
data={chartData}
|
||||||
|
xKey="date"
|
||||||
|
series={[{ key: 'count', color, name: title }]}
|
||||||
|
height={280}
|
||||||
|
yAllowDecimals={false}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DailyLineChartCard;
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useUser } from '@/hooks/useUsers';
|
||||||
|
import { useEvent } from '@/hooks/useEvents';
|
||||||
|
import { useCalendar } from '@/hooks/useCalendars';
|
||||||
|
import { useReport } from '@/hooks/useReports';
|
||||||
|
import { formatDisplayValue } from '@/lib/utils';
|
||||||
|
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetBody,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
|
||||||
|
export type ExploreEntityType = 'user' | 'event' | 'calendar' | 'report';
|
||||||
|
|
||||||
|
export type ExplorePreviewTarget = {
|
||||||
|
type: ExploreEntityType;
|
||||||
|
id: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
interface EntitySlideOverProps {
|
||||||
|
target: ExplorePreviewTarget;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[110px_1fr] gap-2 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="min-w-0 break-words">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserPreview({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useUser(id);
|
||||||
|
if (isLoading) return <PreviewSkeleton />;
|
||||||
|
if (!data) return <p className="text-sm text-muted-foreground">{t('users.notFound')}</p>;
|
||||||
|
return (
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<Field label={t('common.email')}>{formatDisplayValue(data.email)}</Field>
|
||||||
|
<Field label={t('common.nickname')}>{formatDisplayValue(data.nickname)}</Field>
|
||||||
|
<Field label={t('common.role')}>
|
||||||
|
<Badge variant="secondary">{data.role}</Badge>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('common.status')}>
|
||||||
|
<Badge>{formatStatusLabel(data.status)}</Badge>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('common.lastLogin')}>
|
||||||
|
{data.last_login ? dayjs(data.last_login).format('DD.MM.YYYY HH:mm') : t('common.emDash')}
|
||||||
|
</Field>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventPreview({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useEvent(id);
|
||||||
|
if (isLoading) return <PreviewSkeleton />;
|
||||||
|
if (!data) return <p className="text-sm text-muted-foreground">{t('events.notFound')}</p>;
|
||||||
|
return (
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<Field label={t('common.title')}>{formatDisplayValue(data.title)}</Field>
|
||||||
|
<Field label={t('common.type')}>{formatDisplayValue(data.event_type)}</Field>
|
||||||
|
<Field label={t('common.status')}>
|
||||||
|
<Badge>{formatDisplayValue(data.status)}</Badge>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('common.start')}>
|
||||||
|
{data.start_time ? dayjs(data.start_time).format('DD.MM.YYYY HH:mm') : t('common.emDash')}
|
||||||
|
</Field>
|
||||||
|
<Field label={t('common.rating')}>
|
||||||
|
{data.rating_avg} ({data.rating_count})
|
||||||
|
</Field>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarPreview({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useCalendar(id);
|
||||||
|
if (isLoading) return <PreviewSkeleton />;
|
||||||
|
if (!data) return <p className="text-sm text-muted-foreground">{t('calendars.notFound')}</p>;
|
||||||
|
return (
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<Field label={t('common.title')}>{formatDisplayValue(data.title)}</Field>
|
||||||
|
<Field label={t('common.type')}>{formatDisplayValue(data.type)}</Field>
|
||||||
|
<Field label={t('common.status')}>
|
||||||
|
<Badge>{formatDisplayValue(data.status)}</Badge>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('common.owner')}>{formatDisplayValue(data.owner_id)}</Field>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportPreview({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useReport(id);
|
||||||
|
if (isLoading) return <PreviewSkeleton />;
|
||||||
|
if (!data) return <p className="text-sm text-muted-foreground">{t('reports.notFound')}</p>;
|
||||||
|
return (
|
||||||
|
<dl className="space-y-3">
|
||||||
|
<Field label={t('common.reason')}>{data.reason}</Field>
|
||||||
|
<Field label={t('common.target')}>
|
||||||
|
{data.target_type} · {data.target_id}
|
||||||
|
</Field>
|
||||||
|
<Field label={t('common.status')}>
|
||||||
|
<Badge>{formatStatusLabel(data.status)}</Badge>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('common.createdAt')}>{dayjs(data.created_at).format('DD.MM.YYYY HH:mm')}</Field>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailPath = (type: ExploreEntityType, id: string) => {
|
||||||
|
if (type === 'user') return `/users/${id}`;
|
||||||
|
if (type === 'event') return `/events/${id}`;
|
||||||
|
if (type === 'calendar') return `/calendars/${id}`;
|
||||||
|
return `/reports/${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EntitySlideOver({ target, onOpenChange }: EntitySlideOverProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const open = Boolean(target);
|
||||||
|
|
||||||
|
const titles: Record<ExploreEntityType, string> = {
|
||||||
|
user: t('common.user'),
|
||||||
|
event: t('events.entity'),
|
||||||
|
calendar: t('calendars.entity'),
|
||||||
|
report: t('reports.entity'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent>
|
||||||
|
{target && (
|
||||||
|
<>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{titles[target.type]}</SheetTitle>
|
||||||
|
<SheetDescription className="font-mono text-xs">{target.id}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<SheetBody>
|
||||||
|
{target.type === 'user' && <UserPreview id={target.id} />}
|
||||||
|
{target.type === 'event' && <EventPreview id={target.id} />}
|
||||||
|
{target.type === 'calendar' && <CalendarPreview id={target.id} />}
|
||||||
|
{target.type === 'report' && <ReportPreview id={target.id} />}
|
||||||
|
</SheetBody>
|
||||||
|
<SheetFooter>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={detailPath(target.type, target.id)}>{t('common.openFull')}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import { reportClientError } from '@/lib/errorReporter';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
type Props = { children: React.ReactNode };
|
||||||
|
type State = { error: Error | null };
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
void reportClientError({
|
||||||
|
message: error.message || 'React render error',
|
||||||
|
stack: error.stack || info.componentStack || undefined,
|
||||||
|
source: 'frontend',
|
||||||
|
context: { component_stack: info.componentStack || undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] items-center justify-center p-6">
|
||||||
|
<Card className="max-w-lg w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{i18n.t('errors.renderTitle')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{this.state.error.message || i18n.t('errors.renderFallback')}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={() => this.setState({ error: null })}>
|
||||||
|
{i18n.t('common.tryAgain')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => window.location.assign('/dashboard')}>
|
||||||
|
{i18n.t('common.toDashboard')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useMetricsStore, type NodeMetric } from '@/store/metricsStore';
|
||||||
|
import MetricIndicator from '@/components/MetricIndicator';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ONLINE_MINUTES = 5;
|
||||||
|
const RING_SIZE = 36;
|
||||||
|
|
||||||
|
function latestByNode(allHistory: NodeMetric[], current: NodeMetric | null): Map<string, NodeMetric> {
|
||||||
|
const map = new Map<string, NodeMetric>();
|
||||||
|
if (current) map.set(current.node, current);
|
||||||
|
allHistory.forEach((m) => {
|
||||||
|
const prev = map.get(m.node);
|
||||||
|
if (!prev || dayjs(m.timestamp).isAfter(dayjs(prev.timestamp))) {
|
||||||
|
map.set(m.node, m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact node CPU/RAM rings for the Control Center header. */
|
||||||
|
export function HeaderNodeMetrics({ className }: { className?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const allHistory = useMetricsStore((s) => s.allHistory);
|
||||||
|
const current = useMetricsStore((s) => s.current);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canScroll, setCanScroll] = useState({ left: false, right: false });
|
||||||
|
|
||||||
|
const nodes = useMemo(() => {
|
||||||
|
const cutoff = dayjs().subtract(ONLINE_MINUTES, 'minute');
|
||||||
|
const latest = latestByNode(allHistory, current);
|
||||||
|
return Array.from(latest.entries())
|
||||||
|
.filter(([, m]) => dayjs(m.timestamp).isAfter(cutoff))
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [allHistory, current]);
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) {
|
||||||
|
setCanScroll({ left: false, right: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCanScroll({
|
||||||
|
left: el.scrollLeft > 2,
|
||||||
|
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateScrollState();
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(updateScrollState);
|
||||||
|
ro.observe(el);
|
||||||
|
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
el.removeEventListener('scroll', updateScrollState);
|
||||||
|
};
|
||||||
|
}, [nodes.length, updateScrollState]);
|
||||||
|
|
||||||
|
const scrollBy = (dir: -1 | 1) => {
|
||||||
|
scrollerRef.current?.scrollBy({ left: dir * 120, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center text-xs text-muted-foreground', className)}>
|
||||||
|
{t('layout.nodesNoData')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showArrows = canScroll.left || canScroll.right;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-w-0 max-w-full items-center gap-1.5', className)}>
|
||||||
|
<span className="hidden shrink-0 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:inline">
|
||||||
|
{t('layout.nodesLabel')}
|
||||||
|
</span>
|
||||||
|
{showArrows && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
disabled={!canScroll.left}
|
||||||
|
onClick={() => scrollBy(-1)}
|
||||||
|
aria-label={t('layout.nodesPrev')}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-2 overflow-x-auto scrollbar-none py-0.5"
|
||||||
|
style={{ scrollbarWidth: 'none' }}
|
||||||
|
>
|
||||||
|
{nodes.map(([node, stats]) => (
|
||||||
|
<MetricIndicator
|
||||||
|
key={node}
|
||||||
|
node={node}
|
||||||
|
stats={stats}
|
||||||
|
size={RING_SIZE}
|
||||||
|
onClick={() => navigate(`/monitoring?node=${encodeURIComponent(node)}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showArrows && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
disabled={!canScroll.right}
|
||||||
|
onClick={() => scrollBy(1)}
|
||||||
|
aria-label={t('layout.nodesNext')}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export interface KpiItem {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListPageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
kpis?: KpiItem[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListPageHeader({ title, description, actions, kpis, className }: ListPageHeaderProps) {
|
||||||
|
const visibleKpis = kpis?.slice(0, 4) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
{visibleKpis.length > 0 && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{visibleKpis.map((kpi) => (
|
||||||
|
<Card key={kpi.label}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">{kpi.label}</CardTitle>
|
||||||
|
{kpi.icon && <div className="text-muted-foreground">{kpi.icon}</div>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{kpi.value}</div>
|
||||||
|
{kpi.hint && <p className="mt-1 text-xs text-muted-foreground">{kpi.hint}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||||
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/ru';
|
||||||
|
import 'dayjs/locale/en';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
/** i18n + dayjs locale sync. */
|
||||||
|
const LocaleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const language = useAuthStore((s) => s.user?.language);
|
||||||
|
const locale = language === 'en' ? 'en' : 'ru';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void i18n.changeLanguage(locale);
|
||||||
|
dayjs.locale(locale);
|
||||||
|
}, [i18n, locale]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocaleProvider;
|
||||||
@@ -1,14 +1,70 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip, Progress } from 'antd';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NodeMetric } from '../store/metricsStore';
|
import { NodeMetric } from '@/store/metricsStore';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: string;
|
node: string;
|
||||||
stats: NodeMetric;
|
stats: NodeMetric;
|
||||||
prevStats?: NodeMetric | null;
|
prevStats?: NodeMetric | null;
|
||||||
|
/** Outer ring diameter in px (default 48). */
|
||||||
|
size?: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MetricIndicator: React.FC<Props> = ({ node, stats, prevStats }) => {
|
const CircleProgress: React.FC<{
|
||||||
|
percent: number;
|
||||||
|
size: number;
|
||||||
|
strokeWidth: number;
|
||||||
|
color: string;
|
||||||
|
offset?: number;
|
||||||
|
}> = ({ percent, size, strokeWidth, color, offset = 0 }) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const clamped = Math.min(100, Math.max(0, percent));
|
||||||
|
const dashOffset = circumference - (clamped / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="absolute -rotate-90"
|
||||||
|
style={{ top: offset, left: offset }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="hsl(var(--muted))"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricIndicator: React.FC<Props> = ({
|
||||||
|
node,
|
||||||
|
stats,
|
||||||
|
prevStats,
|
||||||
|
size = 48,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const cpu = stats.cpu_utilization ?? 0;
|
const cpu = stats.cpu_utilization ?? 0;
|
||||||
const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0);
|
const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0);
|
||||||
const usedMemory = stats.memory_total ?? 0;
|
const usedMemory = stats.memory_total ?? 0;
|
||||||
@@ -19,48 +75,78 @@ const MetricIndicator: React.FC<Props> = ({ node, stats, prevStats }) => {
|
|||||||
const prevUsed = prevStats?.memory_total ?? 0;
|
const prevUsed = prevStats?.memory_total ?? 0;
|
||||||
const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null;
|
const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null;
|
||||||
|
|
||||||
const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a';
|
const cpuColor = cpu > 80 ? '#ef4444' : '#22c55e';
|
||||||
const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff';
|
const memColor = memoryPercent > 80 ? '#ef4444' : '#3b82f6';
|
||||||
|
|
||||||
return (
|
const stroke = Math.max(3, Math.round(size * 0.12));
|
||||||
<Tooltip
|
const innerSize = Math.round(size * (32 / 48));
|
||||||
title={
|
const innerOffset = Math.round((size - innerSize) / 2);
|
||||||
|
const fontSize = size <= 36 ? 8 : 10;
|
||||||
|
|
||||||
|
const shellClass = cn(
|
||||||
|
'group relative shrink-0',
|
||||||
|
onClick && 'cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<CircleProgress percent={memoryPercent} size={size} strokeWidth={stroke} color={memColor} />
|
||||||
|
<CircleProgress
|
||||||
|
percent={cpu}
|
||||||
|
size={innerSize}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
color={cpuColor}
|
||||||
|
offset={innerOffset}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center font-semibold tabular-nums"
|
||||||
|
style={{ fontSize }}
|
||||||
|
>
|
||||||
|
{cpu.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[240px] -translate-x-1/2 rounded-md border bg-popover px-2 py-1.5 text-left text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block">
|
||||||
|
<div className="font-medium">{t('layout.nodeTooltipTitle', { node })}</div>
|
||||||
<div>
|
<div>
|
||||||
<div>{node}</div>
|
{t('layout.nodeCpu', { value: cpu.toFixed(1) })}
|
||||||
<div>CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}</div>
|
{prevCpu !== undefined ? ` (${t('layout.nodeWas', { value: prevCpu.toFixed(1) })})` : ''}
|
||||||
<div>
|
|
||||||
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%)
|
|
||||||
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<div>
|
||||||
>
|
{t('layout.nodeMemory', {
|
||||||
<div style={{ position: 'relative', width: 48, height: 48, marginRight: 12 }}>
|
used: (usedMemory / 1048576).toFixed(0),
|
||||||
<Progress
|
total: (totalMemory / 1048576).toFixed(0),
|
||||||
type="circle"
|
percent: memoryPercent.toFixed(1),
|
||||||
percent={memoryPercent}
|
})}
|
||||||
format={() => ''}
|
{prevMemoryPercent !== null
|
||||||
size={48}
|
? ` (${t('layout.nodeWas', { value: prevMemoryPercent.toFixed(1) })})`
|
||||||
strokeColor={memColor}
|
: ''}
|
||||||
railColor="#f0f0f0"
|
|
||||||
strokeWidth={6}
|
|
||||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
|
||||||
/>
|
|
||||||
<Progress
|
|
||||||
type="circle"
|
|
||||||
percent={cpu}
|
|
||||||
format={() => ''}
|
|
||||||
size={32}
|
|
||||||
strokeColor={cpuColor}
|
|
||||||
railColor="#f0f0f0"
|
|
||||||
strokeWidth={6}
|
|
||||||
style={{ position: 'absolute', top: 8, left: 8 }}
|
|
||||||
/>
|
|
||||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: 10, fontWeight: 600 }}>
|
|
||||||
{cpu.toFixed(0)}%
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={shellClass}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={t('layout.nodeAria', {
|
||||||
|
node,
|
||||||
|
cpu: cpu.toFixed(0),
|
||||||
|
memory: memoryPercent.toFixed(0),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={shellClass} style={{ width: size, height: size }} title={node}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Breadcrumbs, type BreadcrumbItem } from '@/components/Breadcrumbs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
breadcrumbs?: BreadcrumbItem[];
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, breadcrumbs, actions, className }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('mb-6 space-y-2', className)}>
|
||||||
|
{breadcrumbs && breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
import { Spin } from 'antd';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import ForbiddenPage from '@/pages/errors/ForbiddenPage';
|
||||||
import ForbiddenPage from '../pages/errors/ForbiddenPage';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
allowedRoles?: string[];
|
allowedRoles?: string[];
|
||||||
@@ -13,8 +13,9 @@ const ProtectedRoute: React.FC<Props> = ({ allowedRoles }) => {
|
|||||||
|
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<div className="flex min-h-screen items-center justify-center gap-3 p-8">
|
||||||
<Spin size="large" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { AreaChart, Area, XAxis, Tooltip } from 'recharts';
|
||||||
|
|
||||||
|
interface SparklinePoint {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
data?: SparklinePoint[];
|
||||||
|
color?: string;
|
||||||
|
gradientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatisticSparklineCard: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
data,
|
||||||
|
color = 'hsl(221 83% 53%)',
|
||||||
|
gradientId,
|
||||||
|
}) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||||
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{value.toLocaleString('ru-RU')}</div>
|
||||||
|
{data && data.length > 0 && (
|
||||||
|
<AreaChart width={120} height={40} data={data} margin={{ top: 4, right: 0, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="date" hide />
|
||||||
|
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={color}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 3, strokeWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default StatisticSparklineCard;
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
SortingState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { TablePaginationBar } from '@/components/explore/TablePaginationBar';
|
||||||
|
import { DEFAULT_TABLE_PAGE_SIZE } from '@/utils/tablePagination';
|
||||||
|
|
||||||
|
export interface ServerTableParams {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
sort?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TParams extends ServerTableParams> {
|
||||||
|
columns: ColumnDef<TData, unknown>[];
|
||||||
|
data: TData[];
|
||||||
|
total?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
tableKey: string;
|
||||||
|
params: TParams;
|
||||||
|
onParamsChange: (params: TParams) => void;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TParams extends ServerTableParams>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
tableKey,
|
||||||
|
params,
|
||||||
|
onParamsChange,
|
||||||
|
emptyMessage,
|
||||||
|
}: DataTableProps<TData, TParams>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const empty = emptyMessage ?? t('common.noData');
|
||||||
|
const sorting: SortingState =
|
||||||
|
params.sort && params.order ? [{ id: params.sort, desc: params.order === 'desc' }] : [];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
pageCount: total != null ? Math.ceil(total / (params.limit || DEFAULT_TABLE_PAGE_SIZE)) : -1,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
pagination: {
|
||||||
|
pageIndex: Math.floor((params.offset || 0) / (params.limit || DEFAULT_TABLE_PAGE_SIZE)),
|
||||||
|
pageSize: params.limit || DEFAULT_TABLE_PAGE_SIZE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualPagination: true,
|
||||||
|
manualSorting: true,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
onSortingChange: (updater) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||||
|
const first = next[0];
|
||||||
|
onParamsChange({
|
||||||
|
...params,
|
||||||
|
sort: first?.id ?? params.sort,
|
||||||
|
order: first ? (first.desc ? 'desc' : 'asc') : params.order,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const canSort = header.column.getCanSort();
|
||||||
|
const sorted = header.column.getIsSorted();
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id} style={{ width: header.getSize() || undefined }}>
|
||||||
|
{header.isPlaceholder ? null : canSort ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 font-medium hover:text-foreground"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{sorted === 'asc' ? (
|
||||||
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<ArrowDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={`sk-${i}`}>
|
||||||
|
{columns.map((_, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => {
|
||||||
|
const original = row.original as { id?: string; word?: string };
|
||||||
|
const rowKey = original.id ?? original.word;
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id} data-testid={rowKey ? `row-${rowKey}` : undefined}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||||
|
{empty}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TablePaginationBar
|
||||||
|
tableKey={tableKey}
|
||||||
|
params={params}
|
||||||
|
total={total}
|
||||||
|
dataLength={data.length}
|
||||||
|
onParamsChange={onParamsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TablePaginationBar } from '@/components/explore/TablePaginationBar';
|
||||||
|
import type { ServerTableParams } from '@/components/data-table/DataTable';
|
||||||
|
import type { RowAction } from '@/components/explore/RowActionsMenu';
|
||||||
|
import { RowActionsMenu } from '@/components/explore/RowActionsMenu';
|
||||||
|
|
||||||
|
export interface DenseRowModel {
|
||||||
|
id: string;
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
badges?: ReactNode;
|
||||||
|
meta?: ReactNode;
|
||||||
|
onActivate?: () => void;
|
||||||
|
actions?: RowAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DenseRowListProps<TParams extends ServerTableParams> {
|
||||||
|
items: DenseRowModel[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
tableKey: string;
|
||||||
|
params: TParams;
|
||||||
|
total?: number;
|
||||||
|
onParamsChange: (params: TParams) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DenseRowList<TParams extends ServerTableParams>({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
emptyMessage,
|
||||||
|
tableKey,
|
||||||
|
params,
|
||||||
|
total,
|
||||||
|
onParamsChange,
|
||||||
|
className,
|
||||||
|
}: DenseRowListProps<TParams>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const empty = emptyMessage ?? t('common.noData');
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="divide-y">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="px-4 py-12 text-center text-sm text-muted-foreground">{empty}</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} data-testid={`row-${item.id}`}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex items-start gap-3 px-4 py-3 transition-colors',
|
||||||
|
item.onActivate && 'hover:bg-muted/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`row-activate-${item.id}`}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 text-left',
|
||||||
|
!item.onActivate && 'cursor-default'
|
||||||
|
)}
|
||||||
|
onClick={item.onActivate}
|
||||||
|
disabled={!item.onActivate}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate font-medium text-foreground group-hover:text-primary">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{item.badges}
|
||||||
|
</div>
|
||||||
|
{(item.subtitle || item.meta) && (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{item.subtitle && <span className="truncate">{item.subtitle}</span>}
|
||||||
|
{item.meta && <span className="tabular-nums">{item.meta}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{item.actions && item.actions.length > 0 && (
|
||||||
|
<div className="shrink-0 pt-0.5" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<RowActionsMenu
|
||||||
|
actions={item.actions}
|
||||||
|
data-testid={`row-actions-${item.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TablePaginationBar
|
||||||
|
tableKey={tableKey}
|
||||||
|
params={params}
|
||||||
|
total={total}
|
||||||
|
dataLength={items.length}
|
||||||
|
onParamsChange={onParamsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import type { ExploreViewMode } from '@/lib/exploreView';
|
||||||
|
import { DataTable, type ServerTableParams } from '@/components/data-table/DataTable';
|
||||||
|
import { DenseRowList, type DenseRowModel } from '@/components/explore/DenseRowList';
|
||||||
|
|
||||||
|
interface ExploreDataViewProps<TData, TParams extends ServerTableParams> {
|
||||||
|
viewMode: ExploreViewMode;
|
||||||
|
columns: ColumnDef<TData, unknown>[];
|
||||||
|
data: TData[];
|
||||||
|
total?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
tableKey: string;
|
||||||
|
params: TParams;
|
||||||
|
onParamsChange: (params: TParams) => void;
|
||||||
|
emptyMessage?: string;
|
||||||
|
denseRows: DenseRowModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExploreDataView<TData, TParams extends ServerTableParams>({
|
||||||
|
viewMode,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
tableKey,
|
||||||
|
params,
|
||||||
|
onParamsChange,
|
||||||
|
emptyMessage,
|
||||||
|
denseRows,
|
||||||
|
}: ExploreDataViewProps<TData, TParams>) {
|
||||||
|
if (viewMode === 'rows') {
|
||||||
|
return (
|
||||||
|
<DenseRowList
|
||||||
|
items={denseRows}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
tableKey={tableKey}
|
||||||
|
params={params}
|
||||||
|
total={total}
|
||||||
|
onParamsChange={onParamsChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
total={total}
|
||||||
|
isLoading={isLoading}
|
||||||
|
tableKey={tableKey}
|
||||||
|
params={params}
|
||||||
|
onParamsChange={onParamsChange}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ExploreInsightRow {
|
||||||
|
id: string;
|
||||||
|
primary: ReactNode;
|
||||||
|
secondary?: ReactNode;
|
||||||
|
value: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExploreInsightTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
rows: ExploreInsightRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExploreInsightsCollapseProps {
|
||||||
|
/** Header label when collapsed */
|
||||||
|
title?: string;
|
||||||
|
tabs: ExploreInsightTab[];
|
||||||
|
/** Max rows shown per tab (default 5) */
|
||||||
|
limit?: number;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExploreInsightsCollapse({
|
||||||
|
title,
|
||||||
|
tabs,
|
||||||
|
limit = 5,
|
||||||
|
defaultOpen = false,
|
||||||
|
className,
|
||||||
|
}: ExploreInsightsCollapseProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const headerTitle = title ?? t('explore.tops');
|
||||||
|
const nonEmpty = useMemo(() => tabs.filter((tab) => tab.rows.length > 0), [tabs]);
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (nonEmpty.length === 0) return null;
|
||||||
|
|
||||||
|
const active = nonEmpty.find((tab) => tab.id === activeId) ?? nonEmpty[0];
|
||||||
|
const visible = active.rows.slice(0, limit);
|
||||||
|
const totalRows = nonEmpty.reduce((sum, tab) => sum + tab.rows.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-md border border-border/70 bg-muted/20', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn('h-4 w-4 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-foreground">{headerTitle}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
· {nonEmpty.length > 1 ? t('explore.listsCount', { count: nonEmpty.length }) : totalRows}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border/60">
|
||||||
|
{nonEmpty.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 border-b border-border/50 px-2 py-1.5">
|
||||||
|
{nonEmpty.map((tab) => {
|
||||||
|
const isActive = tab.id === active.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveId(tab.id)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span className="ml-1 tabular-nums opacity-60">{tab.rows.length}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="divide-y divide-border/50">
|
||||||
|
{visible.map((row) => (
|
||||||
|
<li key={row.id} className="flex items-baseline gap-3 px-3 py-2 text-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">{row.primary}</div>
|
||||||
|
{row.secondary && (
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{row.secondary}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 tabular-nums text-muted-foreground">{row.value}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ExploreViewMode } from '@/lib/exploreView';
|
||||||
|
import { ExploreViewToggle } from '@/components/explore/ExploreViewToggle';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export interface ExploreStat {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExploreListShellProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
stats?: ExploreStat[];
|
||||||
|
actions?: ReactNode;
|
||||||
|
toolbar?: ReactNode;
|
||||||
|
/** Soft CTA strip (e.g. Reports → входящие) */
|
||||||
|
banner?: {
|
||||||
|
text: string;
|
||||||
|
to: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
};
|
||||||
|
viewMode: ExploreViewMode;
|
||||||
|
onViewModeChange: (mode: ExploreViewMode) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExploreListShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
stats,
|
||||||
|
actions,
|
||||||
|
toolbar,
|
||||||
|
banner,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
'data-testid': dataTestId,
|
||||||
|
}: ExploreListShellProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)} data-testid={dataTestId}>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||||
|
{stats && stats.length > 0 && (
|
||||||
|
<p className="pt-1 text-sm tabular-nums text-muted-foreground">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<span key={s.label}>
|
||||||
|
{i > 0 && <span className="mx-2 text-border">·</span>}
|
||||||
|
<span className="text-foreground/80">{s.label}</span>{' '}
|
||||||
|
<span className="font-medium text-foreground">{s.value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banner && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border/80 bg-muted/30 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">{banner.text}</p>
|
||||||
|
<Button asChild variant="outline" size="sm" className="shrink-0 gap-1.5">
|
||||||
|
<Link to={banner.to}>
|
||||||
|
{banner.actionLabel ?? t('common.openInbox')}
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">{toolbar}</div>
|
||||||
|
<ExploreViewToggle value={viewMode} onChange={onViewModeChange} className="shrink-0 self-end sm:self-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ExploreSearchProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExploreSearch({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
'data-testid': dataTestId,
|
||||||
|
}: ExploreSearchProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
data-testid={dataTestId}
|
||||||
|
className={cn('flex flex-wrap items-center gap-2', className)}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative min-w-[200px] max-w-md flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
data-testid={dataTestId ? `${dataTestId}-input` : undefined}
|
||||||
|
className="pl-8"
|
||||||
|
placeholder={placeholder ?? t('common.searchPlaceholder')}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<Button type="submit" variant="secondary" size="sm" className="h-9">
|
||||||
|
{t('common.find')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { LayoutList, Table2 } from 'lucide-react';
|
||||||
|
import type { ExploreViewMode } from '@/lib/exploreView';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ExploreViewToggleProps {
|
||||||
|
value: ExploreViewMode;
|
||||||
|
onChange: (mode: ExploreViewMode) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExploreViewToggle({ value, onChange, className }: ExploreViewToggleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('inline-flex rounded-md border bg-background p-0.5', className)}
|
||||||
|
role="group"
|
||||||
|
aria-label={t('explore.viewAria')}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
data-testid="view-table"
|
||||||
|
variant={value === 'table' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5"
|
||||||
|
onClick={() => onChange('table')}
|
||||||
|
aria-pressed={value === 'table'}
|
||||||
|
>
|
||||||
|
<Table2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">{t('explore.table')}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
data-testid="view-rows"
|
||||||
|
variant={value === 'rows' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5"
|
||||||
|
onClick={() => onChange('rows')}
|
||||||
|
aria-pressed={value === 'rows'}
|
||||||
|
>
|
||||||
|
<LayoutList className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">{t('explore.rows')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Fragment, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { MoreHorizontal } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface RowAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
separatorBefore?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowActionsMenuProps {
|
||||||
|
actions: RowAction[];
|
||||||
|
label?: string;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RowActionsMenu({ actions, label, 'data-testid': dataTestId }: RowActionsMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menuLabel = label ?? t('common.actions');
|
||||||
|
if (actions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={menuLabel}
|
||||||
|
title={menuLabel}
|
||||||
|
data-testid={dataTestId}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48" data-testid={dataTestId ? `${dataTestId}-menu` : undefined}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<Fragment key={action.label}>
|
||||||
|
{action.separatorBefore && <DropdownMenuSeparator />}
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={action.disabled}
|
||||||
|
className={cn(action.destructive && 'text-destructive focus:text-destructive')}
|
||||||
|
onClick={action.onClick}
|
||||||
|
>
|
||||||
|
{action.icon && <span className="mr-2 inline-flex shrink-0">{action.icon}</span>}
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
DEFAULT_TABLE_PAGE_SIZE,
|
||||||
|
TABLE_PAGE_SIZE_OPTIONS,
|
||||||
|
getTablePagination,
|
||||||
|
mergeTablePagination,
|
||||||
|
} from '@/utils/tablePagination';
|
||||||
|
import type { ServerTableParams } from '@/components/data-table/DataTable';
|
||||||
|
|
||||||
|
interface TablePaginationBarProps<TParams extends ServerTableParams> {
|
||||||
|
tableKey: string;
|
||||||
|
params: TParams;
|
||||||
|
total?: number;
|
||||||
|
dataLength: number;
|
||||||
|
onParamsChange: (params: TParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablePaginationBar<TParams extends ServerTableParams>({
|
||||||
|
tableKey,
|
||||||
|
params,
|
||||||
|
total,
|
||||||
|
dataLength,
|
||||||
|
onParamsChange,
|
||||||
|
}: TablePaginationBarProps<TParams>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pagination = getTablePagination(params, total);
|
||||||
|
const pageCount = total != null ? Math.max(1, Math.ceil(total / pagination.pageSize)) : 1;
|
||||||
|
const canPrev = pagination.current > 1;
|
||||||
|
const canNext = total != null ? pagination.current < pageCount : dataLength >= pagination.pageSize;
|
||||||
|
|
||||||
|
const handlePageSizeChange = (size: string) => {
|
||||||
|
const pageSize = Number(size);
|
||||||
|
onParamsChange(
|
||||||
|
mergeTablePagination({ ...params, offset: 0 }, { pageSize, current: 1 }, tableKey)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (nextPage: number) => {
|
||||||
|
onParamsChange(
|
||||||
|
mergeTablePagination(params, { current: nextPage, pageSize: pagination.pageSize }, tableKey)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const from = (params.offset ?? 0) + 1;
|
||||||
|
const to = Math.min((params.offset ?? 0) + (params.limit ?? DEFAULT_TABLE_PAGE_SIZE), total ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{total != null ? (
|
||||||
|
t('common.shownRange', { from, to, total })
|
||||||
|
) : (
|
||||||
|
t('common.pageLabel', { current: pagination.current })
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">{t('common.perPage')}</span>
|
||||||
|
<Select value={String(pagination.pageSize)} onValueChange={handlePageSizeChange}>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={!canPrev}
|
||||||
|
onClick={() => handlePageChange(pagination.current - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-[4rem] text-center text-sm tabular-nums">
|
||||||
|
{pagination.current} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={!canNext}
|
||||||
|
onClick={() => handlePageChange(pagination.current + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
info: 'border-primary/30 bg-primary/5 text-foreground [&>svg]:text-primary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||||
|
));
|
||||||
|
Alert.displayName = 'Alert';
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
AlertTitle.displayName = 'AlertTitle';
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
success: 'border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||||
|
warning: 'border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-4 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'flex touch-none select-none transition-colors',
|
||||||
|
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
|
||||||
|
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-sm font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Sheet = DialogPrimitive.Root;
|
||||||
|
const SheetTrigger = DialogPrimitive.Trigger;
|
||||||
|
const SheetClose = DialogPrimitive.Close;
|
||||||
|
const SheetPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/40', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 right-0 z-50 flex h-full w-full max-w-md flex-col border-l bg-background shadow-xl transition-transform duration-200',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
));
|
||||||
|
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 border-b p-4 pr-12', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
SheetDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex-1 overflow-auto p-4', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-wrap gap-2 border-t bg-muted/30 p-4', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
SheetBody,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
);
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
+115
-15
@@ -5,41 +5,93 @@ export const ROUTE_ACCESS: Record<string, AdminRole[] | undefined> = {
|
|||||||
'/dashboard': undefined,
|
'/dashboard': undefined,
|
||||||
'/profile': undefined,
|
'/profile': undefined,
|
||||||
'/monitoring': undefined,
|
'/monitoring': undefined,
|
||||||
|
'/inbox/reports': ['superadmin', 'admin', 'moderator'],
|
||||||
|
'/inbox/tickets': ['superadmin', 'admin', 'support'],
|
||||||
|
'/inbox/automod': ['superadmin', 'admin', 'moderator'],
|
||||||
'/users': ['superadmin', 'admin'],
|
'/users': ['superadmin', 'admin'],
|
||||||
'/calendars': ['superadmin', 'admin'],
|
'/calendars': ['superadmin', 'admin'],
|
||||||
'/events': ['superadmin', 'admin'],
|
'/events': ['superadmin', 'admin'],
|
||||||
'/reports': ['superadmin', 'admin', 'moderator'],
|
'/reports': ['superadmin', 'admin', 'moderator'],
|
||||||
'/reviews': ['superadmin', 'admin', 'moderator'],
|
'/reviews': ['superadmin', 'admin', 'moderator'],
|
||||||
'/banned-words': ['superadmin', 'admin'],
|
'/banned-words': ['superadmin', 'admin'],
|
||||||
|
'/automod-settings': ['superadmin', 'admin'],
|
||||||
'/tickets': ['superadmin', 'admin', 'support'],
|
'/tickets': ['superadmin', 'admin', 'support'],
|
||||||
'/subscriptions': ['superadmin', 'admin'],
|
'/subscriptions': ['superadmin', 'admin'],
|
||||||
'/admins': ['superadmin'],
|
'/admins': ['superadmin'],
|
||||||
'/audit': ['superadmin'],
|
'/audit': ['superadmin'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MENU_ITEMS: Array<{
|
export type NavItem = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
roles?: AdminRole[];
|
roles?: AdminRole[];
|
||||||
}> = [
|
};
|
||||||
{ key: '/dashboard', label: 'Дашборд' },
|
|
||||||
{ key: '/users', label: 'Пользователи', roles: ['superadmin', 'admin'] },
|
export type NavGroup = {
|
||||||
{ key: '/calendars', label: 'Календари', roles: ['superadmin', 'admin'] },
|
id: string;
|
||||||
{ key: '/events', label: 'События', roles: ['superadmin', 'admin'] },
|
label: string;
|
||||||
{ key: '/reports', label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] },
|
items: NavItem[];
|
||||||
{ key: '/reviews', label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] },
|
};
|
||||||
{ key: '/banned-words', label: 'Бан-слова', roles: ['superadmin', 'admin'] },
|
|
||||||
{ key: '/tickets', label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] },
|
/** Группы навигации Control Center (label = i18n key). */
|
||||||
{ key: '/subscriptions', label: 'Подписки', roles: ['superadmin', 'admin'] },
|
export const NAV_GROUPS: NavGroup[] = [
|
||||||
{ key: '/admins', label: 'Администраторы', roles: ['superadmin'] },
|
{
|
||||||
{ key: '/audit', label: 'Аудит', roles: ['superadmin'] },
|
id: 'work',
|
||||||
{ key: '/monitoring', label: 'Мониторинг' },
|
label: 'navGroup.work',
|
||||||
|
items: [
|
||||||
|
{ key: '/inbox/reports', label: 'nav.inboxReports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
|
{ key: '/inbox/tickets', label: 'nav.inboxTickets', roles: ['superadmin', 'admin', 'support'] },
|
||||||
|
{ key: '/inbox/automod', label: 'nav.inboxAutomod', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'navGroup.overview',
|
||||||
|
items: [
|
||||||
|
{ key: '/dashboard', label: 'nav.dashboard' },
|
||||||
|
{ key: '/monitoring', label: 'nav.monitoring' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'content',
|
||||||
|
label: 'navGroup.content',
|
||||||
|
items: [
|
||||||
|
{ key: '/users', label: 'nav.users', roles: ['superadmin', 'admin'] },
|
||||||
|
{ key: '/calendars', label: 'nav.calendars', roles: ['superadmin', 'admin'] },
|
||||||
|
{ key: '/events', label: 'nav.events', roles: ['superadmin', 'admin'] },
|
||||||
|
{ key: '/subscriptions', label: 'nav.subscriptions', roles: ['superadmin', 'admin'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moderation',
|
||||||
|
label: 'navGroup.moderation',
|
||||||
|
items: [
|
||||||
|
{ key: '/reports', label: 'nav.reports', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
|
{ key: '/reviews', label: 'nav.reviews', roles: ['superadmin', 'admin', 'moderator'] },
|
||||||
|
{ key: '/banned-words', label: 'nav.bannedWords', roles: ['superadmin', 'admin'] },
|
||||||
|
{ key: '/automod-settings', label: 'nav.automodSettings', roles: ['superadmin', 'admin'] },
|
||||||
|
{ key: '/tickets', label: 'nav.tickets', roles: ['superadmin', 'admin', 'support'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system',
|
||||||
|
label: 'navGroup.system',
|
||||||
|
items: [
|
||||||
|
{ key: '/admins', label: 'nav.admins', roles: ['superadmin'] },
|
||||||
|
{ key: '/audit', label: 'nav.audit', roles: ['superadmin'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Плоский список для совместимости и getMenuSelectedKey. */
|
||||||
|
export const MENU_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
||||||
|
|
||||||
export function canAccessRoute(pathname: string, role: string | undefined): boolean {
|
export function canAccessRoute(pathname: string, role: string | undefined): boolean {
|
||||||
if (!role) return false;
|
if (!role) return false;
|
||||||
const base = pathname.split('/').slice(0, 2).join('/') || '/';
|
const base = pathname.split('/').slice(0, 2).join('/') || '/';
|
||||||
const allowed = ROUTE_ACCESS[base];
|
const inboxBase = pathname.startsWith('/inbox/') ? pathname.split('/').slice(0, 3).join('/') : null;
|
||||||
|
const check = inboxBase ?? base;
|
||||||
|
const allowed = ROUTE_ACCESS[check] ?? ROUTE_ACCESS[base];
|
||||||
if (allowed === undefined) {
|
if (allowed === undefined) {
|
||||||
return ROUTE_ACCESS[pathname] === undefined || ROUTE_ACCESS[pathname]!.includes(role as AdminRole);
|
return ROUTE_ACCESS[pathname] === undefined || ROUTE_ACCESS[pathname]!.includes(role as AdminRole);
|
||||||
}
|
}
|
||||||
@@ -52,3 +104,51 @@ export function filterMenuByRole<T extends { roles?: AdminRole[] }>(
|
|||||||
): T[] {
|
): T[] {
|
||||||
return items.filter((item) => !item.roles || (role && item.roles.includes(role as AdminRole)));
|
return items.filter((item) => !item.roles || (role && item.roles.includes(role as AdminRole)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterNavGroupsByRole(groups: NavGroup[], role: string | undefined): NavGroup[] {
|
||||||
|
return groups
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: filterMenuByRole(group.items, role),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ пункта меню для detail-маршрутов (/users/:id → /users). */
|
||||||
|
export function getMenuSelectedKey(pathname: string): string {
|
||||||
|
if (MENU_ITEMS.some((item) => item.key === pathname)) {
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/inbox/')) {
|
||||||
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
|
if (parts.length >= 2) return `/inbox/${parts[1]}`;
|
||||||
|
}
|
||||||
|
const segment = pathname.split('/').filter(Boolean)[0];
|
||||||
|
if (segment) {
|
||||||
|
const parent = `/${segment}`;
|
||||||
|
if (MENU_ITEMS.some((item) => item.key === parent)) {
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created' | 'automod_hit', string> = {
|
||||||
|
report_created: '/inbox/reports',
|
||||||
|
ticket_created: '/inbox/tickets',
|
||||||
|
automod_hit: '/inbox/automod',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canReceiveWsNotification(
|
||||||
|
type: 'report_created' | 'ticket_created' | 'automod_hit',
|
||||||
|
role: string | undefined
|
||||||
|
): boolean {
|
||||||
|
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Стартовая страница по роли после логина. */
|
||||||
|
export function getDefaultRouteForRole(role: AdminRole | undefined): string {
|
||||||
|
if (role === 'moderator') return '/inbox/reports';
|
||||||
|
if (role === 'support') return '/inbox/tickets';
|
||||||
|
return '/dashboard';
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useMetricsStore } from '../store/metricsStore';
|
import { useMetricsStore } from '../store/metricsStore';
|
||||||
|
import { canAccessRoute, canReceiveWsNotification } from '../config/adminAccess';
|
||||||
|
|
||||||
type WsMessage = {
|
type WsMessage = {
|
||||||
type: 'report_created' | 'ticket_created' | 'node_metric';
|
type: 'report_created' | 'ticket_created' | 'node_metric';
|
||||||
@@ -11,10 +12,17 @@ type WsMessage = {
|
|||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wsLog = (...args: unknown[]) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useAdminWebSocket = () => {
|
export const useAdminWebSocket = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
|
const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
|
||||||
const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
|
const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
|
||||||
|
|
||||||
@@ -59,6 +67,7 @@ export const useAdminWebSocket = () => {
|
|||||||
|
|
||||||
shouldReconnectRef.current = true;
|
shouldReconnectRef.current = true;
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
const sessionStartSecondsRef = { current: 0 };
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (!isMounted || !shouldReconnectRef.current) return;
|
if (!isMounted || !shouldReconnectRef.current) return;
|
||||||
@@ -74,9 +83,14 @@ export const useAdminWebSocket = () => {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (!isMounted || !shouldReconnectRef.current) return;
|
if (!isMounted || !shouldReconnectRef.current) return;
|
||||||
console.log('[WS] Connected');
|
sessionStartSecondsRef.current = Math.floor(Date.now() / 1000);
|
||||||
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
|
wsLog('[WS] Connected');
|
||||||
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
|
if (canAccessRoute('/reports', role)) {
|
||||||
|
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
|
||||||
|
}
|
||||||
|
if (canAccessRoute('/tickets', role)) {
|
||||||
|
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
|
||||||
|
}
|
||||||
|
|
||||||
pingIntervalRef.current = setInterval(() => {
|
pingIntervalRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -89,7 +103,7 @@ export const useAdminWebSocket = () => {
|
|||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
try {
|
try {
|
||||||
const msg: WsMessage = JSON.parse(event.data);
|
const msg: WsMessage = JSON.parse(event.data);
|
||||||
console.log('[WS] Message:', msg);
|
wsLog('[WS] Message:', msg);
|
||||||
|
|
||||||
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
|
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
|
||||||
return;
|
return;
|
||||||
@@ -99,9 +113,19 @@ export const useAdminWebSocket = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'report_created' || msg.type === 'ticket_created') {
|
if (msg.type === 'report_created' || msg.type === 'ticket_created') {
|
||||||
window.dispatchEvent(
|
if (!canReceiveWsNotification(msg.type, role)) {
|
||||||
new CustomEvent('admin-ws-message', { detail: msg })
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const isLiveEvent =
|
||||||
|
!msg.timestamp || msg.timestamp >= sessionStartSecondsRef.current;
|
||||||
|
|
||||||
|
if (isLiveEvent) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('admin-ws-message', { detail: msg })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'report_created') {
|
if (msg.type === 'report_created') {
|
||||||
queryClient.invalidateQueries({ queryKey: ['reports'] });
|
queryClient.invalidateQueries({ queryKey: ['reports'] });
|
||||||
} else if (msg.type === 'ticket_created') {
|
} else if (msg.type === 'ticket_created') {
|
||||||
@@ -113,13 +137,15 @@ export const useAdminWebSocket = () => {
|
|||||||
addToAllHistory(msg.data);
|
addToAllHistory(msg.data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[WS] Failed to parse message:', e);
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[WS] Failed to parse message:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
if (!isMounted || !shouldReconnectRef.current) return;
|
if (!isMounted || !shouldReconnectRef.current) return;
|
||||||
console.log('[WS] Disconnected:', event.reason);
|
wsLog('[WS] Disconnected:', event.reason);
|
||||||
clearTimers();
|
clearTimers();
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
reconnectTimeoutRef.current = setTimeout(connect, 5000);
|
reconnectTimeoutRef.current = setTimeout(connect, 5000);
|
||||||
@@ -127,7 +153,9 @@ export const useAdminWebSocket = () => {
|
|||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
console.error('[WS] Error:', error);
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('[WS] Error:', error);
|
||||||
|
}
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -139,5 +167,5 @@ export const useAdminWebSocket = () => {
|
|||||||
shouldReconnectRef.current = false;
|
shouldReconnectRef.current = false;
|
||||||
closeSocket();
|
closeSocket();
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, accessToken, queryClient, setCurrentMetric, addToAllHistory]);
|
}, [isAuthenticated, accessToken, role, queryClient, setCurrentMetric, addToAllHistory]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { adminsApi } from '../api/adminsApi';
|
import { adminsApi } from '../api/adminsApi';
|
||||||
import { AdminListParams, Admin } from '../types/api';
|
import { AdminListParams, Admin } from '../types/api';
|
||||||
import { message } from 'antd';
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
|
||||||
export const useAdmins = (params: AdminListParams) => {
|
export const useAdmins = (params: AdminListParams) => {
|
||||||
@@ -30,9 +31,9 @@ export const useCreateAdmin = () => {
|
|||||||
adminsApi.createAdmin(data),
|
adminsApi.createAdmin(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
||||||
message.success('Администратор создан');
|
notify.success(i18n.t('common.entityCreated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка создания'),
|
onError: () => notify.error(i18n.t('common.createError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,9 +44,9 @@ export const useUpdateAdmin = () => {
|
|||||||
adminsApi.updateAdmin(id, data),
|
adminsApi.updateAdmin(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
||||||
message.success('Администратор обновлён');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ export const useDeleteAdmin = () => {
|
|||||||
mutationFn: (id: string) => adminsApi.deleteAdmin(id),
|
mutationFn: (id: string) => adminsApi.deleteAdmin(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
queryClient.invalidateQueries({ queryKey: ['admins'] });
|
||||||
message.success('Администратор удалён');
|
notify.success(i18n.t('common.entityDeleted'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка удаления'),
|
onError: () => notify.error(i18n.t('common.deleteError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { automodApi, AutomodHitListParams, AutomodSettings } from '@/api/automodApi';
|
||||||
|
|
||||||
|
export const useAutomodSettings = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['automod-settings'],
|
||||||
|
queryFn: () => automodApi.getSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUpdateAutomodSettings = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: Partial<AutomodSettings>) => automodApi.updateSettings(payload),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-settings'] }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAutomodHits = (params?: AutomodHitListParams) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['automod-hits', params],
|
||||||
|
queryFn: () => automodApi.getHits(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useResolveAutomodHit = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'rejected' }) =>
|
||||||
|
automodApi.resolveHit(id, status),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['automod-hits'] }),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { bannedWordsApi, BannedWordListParams } from '../api/bannedWordsApi';
|
import { bannedWordsApi, BannedWordListParams } from '../api/bannedWordsApi';
|
||||||
import { message } from 'antd';
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
|
||||||
export const useBannedWords = (params?: BannedWordListParams) => {
|
export const useBannedWords = (params?: BannedWordListParams) => {
|
||||||
@@ -19,9 +20,9 @@ export const useAddBannedWord = () => {
|
|||||||
mutationFn: (word: string) => bannedWordsApi.addBannedWord(word),
|
mutationFn: (word: string) => bannedWordsApi.addBannedWord(word),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
|
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
|
||||||
message.success('Слово добавлено');
|
notify.success(i18n.t('common.entityCreated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка добавления'),
|
onError: () => notify.error(i18n.t('common.createError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,8 +32,8 @@ export const useRemoveBannedWord = () => {
|
|||||||
mutationFn: (word: string) => bannedWordsApi.removeBannedWord(word),
|
mutationFn: (word: string) => bannedWordsApi.removeBannedWord(word),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
|
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
|
||||||
message.success('Слово удалено');
|
notify.success(i18n.t('common.entityDeleted'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка удаления'),
|
onError: () => notify.error(i18n.t('common.deleteError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { calendarsApi } from '../api/calendarsApi';
|
import { calendarsApi } from '../api/calendarsApi';
|
||||||
import { Calendar, CalendarListParams } from '../types/api';
|
import { Calendar, CalendarListParams } from '../types/api';
|
||||||
import { message } from 'antd';
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
|
||||||
export const useCalendars = (params: CalendarListParams) => {
|
export const useCalendars = (params: CalendarListParams) => {
|
||||||
@@ -29,9 +30,9 @@ export const useUpdateCalendar = () => {
|
|||||||
calendarsApi.updateCalendar(id, data),
|
calendarsApi.updateCalendar(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
||||||
message.success('Календарь обновлён');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,9 +42,9 @@ export const useDeleteCalendar = () => {
|
|||||||
mutationFn: (id: string) => calendarsApi.deleteCalendar(id),
|
mutationFn: (id: string) => calendarsApi.deleteCalendar(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
||||||
message.success('Календарь удалён');
|
notify.success(i18n.t('common.entityDeleted'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка удаления'),
|
onError: () => notify.error(i18n.t('common.deleteError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/** Глобальный хоткей Ctrl/⌘+K */
|
||||||
|
export function useCommandPaletteHotkey(onOpen: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [onOpen]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsApi } from '../api/eventsApi';
|
import { eventsApi } from '../api/eventsApi';
|
||||||
import { Event, EventListParams } from '../types/api';
|
import { Event, EventListParams } from '../types/api';
|
||||||
import { message } from 'antd';
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
|
||||||
export const useEvents = (params: EventListParams) => {
|
export const useEvents = (params: EventListParams) => {
|
||||||
@@ -29,9 +30,9 @@ export const useUpdateEvent = () => {
|
|||||||
eventsApi.updateEvent(id, data),
|
eventsApi.updateEvent(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
message.success('Событие обновлено');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,9 +42,9 @@ export const useDeleteEvent = () => {
|
|||||||
mutationFn: (id: string) => eventsApi.deleteEvent(id),
|
mutationFn: (id: string) => eventsApi.deleteEvent(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
message.success('Событие удалено');
|
notify.success(i18n.t('common.entityDeleted'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка удаления'),
|
onError: () => notify.error(i18n.t('common.deleteError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { getExploreViewMode, setExploreViewMode, type ExploreViewMode } from '@/lib/exploreView';
|
||||||
|
|
||||||
|
export function useExploreView() {
|
||||||
|
const [viewMode, setMode] = useState<ExploreViewMode>(() => getExploreViewMode());
|
||||||
|
|
||||||
|
const setViewMode = useCallback((mode: ExploreViewMode) => {
|
||||||
|
setExploreViewMode(mode);
|
||||||
|
setMode(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { viewMode, setViewMode } as const;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Горячие клавиши inbox (когда фокус не в input/textarea/select/contenteditable).
|
||||||
|
* j/k — соседний элемент, 1/2 — действия, Esc — снять фокус списка (опционально).
|
||||||
|
*/
|
||||||
|
export function useInboxHotkeys(options: {
|
||||||
|
enabled?: boolean;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onPrimary?: () => void;
|
||||||
|
onSecondary?: () => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
}) {
|
||||||
|
const { enabled = true, onNext, onPrev, onPrimary, onSecondary, onOpen } = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const isTypingTarget = (el: EventTarget | null) => {
|
||||||
|
if (!(el instanceof HTMLElement)) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (isTypingTarget(e.target)) return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key === 'j' || key === 'arrowdown') {
|
||||||
|
e.preventDefault();
|
||||||
|
onNext();
|
||||||
|
} else if (key === 'k' || key === 'arrowup') {
|
||||||
|
e.preventDefault();
|
||||||
|
onPrev();
|
||||||
|
} else if (key === 'enter' && onOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
} else if (key === '1' && onPrimary) {
|
||||||
|
e.preventDefault();
|
||||||
|
onPrimary();
|
||||||
|
} else if (key === '2' && onSecondary) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSecondary();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [enabled, onNext, onPrev, onPrimary, onSecondary, onOpen]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectNeighborId(
|
||||||
|
ids: string[],
|
||||||
|
currentId: string,
|
||||||
|
direction: 1 | -1
|
||||||
|
): string | null {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const idx = ids.indexOf(currentId);
|
||||||
|
if (idx < 0) return ids[0] ?? null;
|
||||||
|
const next = idx + direction;
|
||||||
|
if (next < 0 || next >= ids.length) return currentId;
|
||||||
|
return ids[next];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Следующий после текущего; если конец — предыдущий. */
|
||||||
|
export function selectAfterRemove(ids: string[], currentId: string): string | null {
|
||||||
|
const idx = ids.indexOf(currentId);
|
||||||
|
if (idx < 0) return ids[0] ?? null;
|
||||||
|
return ids[idx + 1] ?? ids[idx - 1] ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { authApi } from '@/api/authApi';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { Admin } from '@/types/api';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
export const useUpdateProfile = () => {
|
||||||
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Partial<Admin>) => authApi.updateMe(data),
|
||||||
|
onSuccess: (user) => {
|
||||||
|
setUser(user);
|
||||||
|
notify.success(i18n.t('profile.saved'));
|
||||||
|
},
|
||||||
|
onError: () => notify.error(i18n.t('profile.saveError')),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { reportsApi } from '../api/reportsApi';
|
import { reportsApi } from '../api/reportsApi';
|
||||||
import { ReportListParams } from '../types/api';
|
import { ReportListParams } from '../types/api';
|
||||||
import { message } from 'antd';
|
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
export const useReports = (params: ReportListParams) => {
|
export const useReports = (params: ReportListParams) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -29,9 +30,9 @@ export const useUpdateReport = () => {
|
|||||||
reportsApi.updateReport(id, data),
|
reportsApi.updateReport(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['reports'] });
|
queryClient.invalidateQueries({ queryKey: ['reports'] });
|
||||||
message.success('Статус обновлён');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { reviewsApi } from '../api/reviewsApi';
|
import { reviewsApi } from '../api/reviewsApi';
|
||||||
import { ReviewListParams, Review } from '../types/api';
|
import { ReviewListParams, Review } from '../types/api';
|
||||||
import { message } from 'antd';
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
|
||||||
export const useReviews = (params: ReviewListParams) => {
|
export const useReviews = (params: ReviewListParams) => {
|
||||||
@@ -31,9 +32,9 @@ export const useUpdateReview = () => {
|
|||||||
reviewsApi.updateReview(id, data),
|
reviewsApi.updateReview(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['reviews'] });
|
queryClient.invalidateQueries({ queryKey: ['reviews'] });
|
||||||
message.success('Отзыв обновлён');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,10 +45,10 @@ export const useBulkUpdateReviews = () => {
|
|||||||
reviewsApi.bulkUpdateReviews(updates),
|
reviewsApi.bulkUpdateReviews(updates),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['reviews'] });
|
queryClient.invalidateQueries({ queryKey: ['reviews'] });
|
||||||
message.success('Статусы обновлены');
|
notify.success(i18n.t('common.bulkUpdated'));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
message.error('Ошибка массового обновления');
|
notify.error(i18n.t('common.bulkUpdateError'));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { subscriptionsApi } from '../api/subscriptionsApi';
|
import { subscriptionsApi } from '../api/subscriptionsApi';
|
||||||
import { SubscriptionListParams, Subscription as SubscriptionType } from '../types/api';
|
import { SubscriptionListParams, Subscription as SubscriptionType } from '../types/api';
|
||||||
import { message } from 'antd';
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
|
||||||
export const useSubscriptions = (params: SubscriptionListParams) => {
|
export const useSubscriptions = (params: SubscriptionListParams) => {
|
||||||
@@ -29,9 +30,9 @@ export const useUpdateSubscription = () => {
|
|||||||
subscriptionsApi.updateSubscription(id, data),
|
subscriptionsApi.updateSubscription(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
message.success('Подписка обновлена');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,9 +42,9 @@ export const useDeleteSubscription = () => {
|
|||||||
mutationFn: (id: string) => subscriptionsApi.deleteSubscription(id),
|
mutationFn: (id: string) => subscriptionsApi.deleteSubscription(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
message.success('Подписка удалена');
|
notify.success(i18n.t('common.entityDeleted'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка удаления'),
|
onError: () => notify.error(i18n.t('common.deleteError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ticketsApi } from '../api/ticketsApi';
|
import { ticketsApi } from '../api/ticketsApi';
|
||||||
import { TicketListParams, Ticket } from '../types/api';
|
import { TicketListParams, Ticket } from '../types/api';
|
||||||
import { message } from 'antd';
|
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
export const useTickets = (params: TicketListParams) => {
|
export const useTickets = (params: TicketListParams) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -29,9 +30,9 @@ export const useUpdateTicket = () => {
|
|||||||
ticketsApi.updateTicket(id, data),
|
ticketsApi.updateTicket(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||||
message.success('Тикет обновлён');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,9 +42,9 @@ export const useDeleteTicket = () => {
|
|||||||
mutationFn: (id: string) => ticketsApi.deleteTicket(id),
|
mutationFn: (id: string) => ticketsApi.deleteTicket(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||||
message.success('Тикет удалён');
|
notify.success(i18n.t('common.entityDeleted'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка удаления'),
|
onError: () => notify.error(i18n.t('common.deleteError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { usersApi } from '../api/usersApi';
|
import { usersApi } from '../api/usersApi';
|
||||||
import { UserListParams, User } from '../types/api';
|
import { UserListParams, User } from '../types/api';
|
||||||
import { message } from 'antd';
|
import { notify } from '@/lib/notify';
|
||||||
|
import i18n from '@/i18n';
|
||||||
import { normalizeData } from '../utils/normalize';
|
import { normalizeData } from '../utils/normalize';
|
||||||
|
|
||||||
export const useUsers = (params: UserListParams) => {
|
export const useUsers = (params: UserListParams) => {
|
||||||
@@ -28,9 +29,9 @@ export const useUpdateUser = () => {
|
|||||||
mutationFn: ({ id, data }: { id: string; data: Partial<User> }) => usersApi.updateUser(id, data),
|
mutationFn: ({ id, data }: { id: string; data: Partial<User> }) => usersApi.updateUser(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
message.success('Пользователь обновлен');
|
notify.success(i18n.t('common.entityUpdated'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка обновления'),
|
onError: () => notify.error(i18n.t('common.updateError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,9 +41,9 @@ export const useDeleteUser = () => {
|
|||||||
mutationFn: (id: string) => usersApi.deleteUser(id),
|
mutationFn: (id: string) => usersApi.deleteUser(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
message.success('Пользователь удален');
|
notify.success(i18n.t('common.entityDeleted'));
|
||||||
},
|
},
|
||||||
onError: () => message.error('Ошибка удаления'),
|
onError: () => notify.error(i18n.t('common.deleteError')),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import ru from '../locales/ru.json';
|
||||||
|
import en from '../locales/en.json';
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
ru: { translation: ru },
|
||||||
|
en: { translation: en },
|
||||||
|
},
|
||||||
|
lng: 'ru',
|
||||||
|
fallbackLng: 'ru',
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
+97
-94
@@ -1,111 +1,114 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
--background: 0 0% 100%;
|
||||||
--text-h: #08060d;
|
--foreground: 224 71% 4%;
|
||||||
--bg: #fff;
|
--card: 0 0% 100%;
|
||||||
--border: #e5e4e7;
|
--card-foreground: 224 71% 4%;
|
||||||
--code-bg: #f4f3ec;
|
--popover: 0 0% 100%;
|
||||||
--accent: #aa3bff;
|
--popover-foreground: 224 71% 4%;
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
--primary: 221 83% 53%;
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
--primary-foreground: 210 40% 98%;
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
--secondary: 220 14% 96%;
|
||||||
--shadow:
|
--secondary-foreground: 220 9% 46%;
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
--muted: 220 14% 96%;
|
||||||
|
--muted-foreground: 220 9% 46%;
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--accent: 220 14% 96%;
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--accent-foreground: 224 71% 4%;
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
font: 18px/145% var(--sans);
|
--border: 220 13% 91%;
|
||||||
letter-spacing: 0.18px;
|
--input: 220 13% 91%;
|
||||||
color-scheme: light dark;
|
--ring: 221 83% 53%;
|
||||||
color: var(--text);
|
--radius: 0.5rem;
|
||||||
background: var(--bg);
|
--sidebar: 224 71% 4%;
|
||||||
font-synthesis: none;
|
--sidebar-foreground: 210 40% 98%;
|
||||||
text-rendering: optimizeLegibility;
|
--sidebar-muted: 215 28% 17%;
|
||||||
-webkit-font-smoothing: antialiased;
|
--sidebar-accent: 217 33% 17%;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root {
|
--background: 224 71% 4%;
|
||||||
--text: #9ca3af;
|
--foreground: 210 40% 98%;
|
||||||
--text-h: #f3f4f6;
|
--card: 224 71% 4%;
|
||||||
--bg: #16171d;
|
--card-foreground: 210 40% 98%;
|
||||||
--border: #2e303a;
|
--popover: 224 71% 4%;
|
||||||
--code-bg: #1f2028;
|
--popover-foreground: 210 40% 98%;
|
||||||
--accent: #c084fc;
|
--primary: 217 91% 60%;
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
--primary-foreground: 222 47% 11%;
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
--secondary: 217 33% 17%;
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
--secondary-foreground: 210 40% 98%;
|
||||||
--shadow:
|
--muted: 217 33% 17%;
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
--muted-foreground: 215 20% 65%;
|
||||||
}
|
--accent: 217 33% 17%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
#social .button-icon {
|
--destructive: 0 63% 31%;
|
||||||
filter: invert(1) brightness(2);
|
--destructive-foreground: 210 40% 98%;
|
||||||
}
|
--border: 217 33% 17%;
|
||||||
|
--input: 217 33% 17%;
|
||||||
|
--ring: 224 76% 48%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
* {
|
||||||
width: 1126px;
|
border-color: hsl(var(--border));
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: 'Segoe UI', 'IBM Plex Sans', 'Source Sans 3', system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
#root {
|
||||||
h2 {
|
min-height: 100vh;
|
||||||
font-family: var(--heading);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.cc-main-surface {
|
||||||
font-size: 56px;
|
background:
|
||||||
letter-spacing: -1.68px;
|
radial-gradient(ellipse 80% 50% at 100% -10%, hsl(221 83% 53% / 0.06), transparent 55%),
|
||||||
margin: 32px 0;
|
hsl(var(--background));
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
/* Compact table density (toggle in Control Center header) */
|
||||||
.counter {
|
html[data-density='compact'] [data-slot='table-head'] {
|
||||||
font-family: var(--mono);
|
height: 2rem;
|
||||||
display: inline-flex;
|
padding-left: 0.5rem;
|
||||||
border-radius: 4px;
|
padding-right: 0.5rem;
|
||||||
color: var(--text-h);
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
html[data-density='compact'] [data-slot='table-cell'] {
|
||||||
font-size: 15px;
|
padding: 0.375rem 0.5rem;
|
||||||
line-height: 135%;
|
font-size: 0.8125rem;
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip } from 'antd';
|
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
|
||||||
import { useAdminWebSocket } from '../hooks/useAdminWebSocket';
|
|
||||||
import { useMetricsStore, type NodeMetric } from '../store/metricsStore';
|
|
||||||
import MetricIndicator from '../components/MetricIndicator';
|
|
||||||
import {
|
|
||||||
DashboardOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
WarningOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
StopOutlined,
|
|
||||||
BugOutlined,
|
|
||||||
DollarOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
AuditOutlined,
|
|
||||||
LogoutOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
MenuFoldOutlined,
|
|
||||||
MenuUnfoldOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { MENU_ITEMS, filterMenuByRole } from '../config/adminAccess';
|
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
const NODE_STATS_THRESHOLD = 5;
|
|
||||||
|
|
||||||
const AdminLayout: React.FC = () => {
|
|
||||||
useAdminWebSocket();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const { user, logout } = useAuthStore();
|
|
||||||
const { token: { colorBgContainer } } = theme.useToken();
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
|
|
||||||
const allHistory = useMetricsStore((s) => s.allHistory);
|
|
||||||
const previous = useMetricsStore((s) => s.previous);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (event: CustomEvent) => {
|
|
||||||
const msg = event.detail;
|
|
||||||
if (msg.type === 'report_created') {
|
|
||||||
const { report_id, target_type, target_id, reason } = msg.data || {};
|
|
||||||
notification.info({
|
|
||||||
title: 'Новая жалоба',
|
|
||||||
description: (
|
|
||||||
<span>
|
|
||||||
Жалоба{' '}
|
|
||||||
{report_id ? (
|
|
||||||
<a href={`/reports/${report_id}`} onClick={(e) => { e.preventDefault(); navigate(`/reports/${report_id}`); }} style={{ fontWeight: 600 }}>
|
|
||||||
#{report_id}
|
|
||||||
</a>
|
|
||||||
) : ('#?')}{' '}
|
|
||||||
на {target_type || 'неизвестный тип'}{' '}
|
|
||||||
{target_id ? (
|
|
||||||
<a href={`/${target_type}s/${target_id}`} onClick={(e) => { e.preventDefault(); navigate(`/${target_type}s/${target_id}`); }} style={{ fontWeight: 600 }}>
|
|
||||||
{target_id}
|
|
||||||
</a>
|
|
||||||
) : ('?')}{' '}
|
|
||||||
{reason ? `(${reason})` : ''}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
placement: 'topRight',
|
|
||||||
});
|
|
||||||
} else if (msg.type === 'ticket_created') {
|
|
||||||
const { ticket_id } = msg.data || {};
|
|
||||||
notification.info({
|
|
||||||
title: 'Новый тикет',
|
|
||||||
description: (
|
|
||||||
<span>
|
|
||||||
Тикет{' '}
|
|
||||||
{ticket_id ? (
|
|
||||||
<a href={`/tickets/${ticket_id}`} onClick={(e) => { e.preventDefault(); navigate(`/tickets/${ticket_id}`); }} style={{ fontWeight: 600 }}>
|
|
||||||
#{ticket_id}
|
|
||||||
</a>
|
|
||||||
) : ('без ID')}{' '}
|
|
||||||
создан
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
placement: 'topRight',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('admin-ws-message', handler as EventListener);
|
|
||||||
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const onlineNodes = React.useMemo(() => {
|
|
||||||
const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute');
|
|
||||||
const recentMetrics = allHistory.filter(m => dayjs(m.timestamp).isAfter(cutoff));
|
|
||||||
const nodes = new Set(recentMetrics.map(m => m.node));
|
|
||||||
return Array.from(nodes);
|
|
||||||
}, [allHistory]);
|
|
||||||
|
|
||||||
const latestByNode = React.useMemo(() => {
|
|
||||||
const map = new Map<string, NodeMetric>();
|
|
||||||
const recent = allHistory.slice(-1000);
|
|
||||||
for (let i = recent.length - 1; i >= 0; i--) {
|
|
||||||
const m = recent[i];
|
|
||||||
if (!map.has(m.node)) {
|
|
||||||
map.set(m.node, m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [allHistory]);
|
|
||||||
|
|
||||||
const menuIconByKey: Record<string, React.ReactNode> = {
|
|
||||||
'/dashboard': <DashboardOutlined />,
|
|
||||||
'/users': <UserOutlined />,
|
|
||||||
'/calendars': <CalendarOutlined />,
|
|
||||||
'/events': <CalendarOutlined />,
|
|
||||||
'/reports': <WarningOutlined />,
|
|
||||||
'/reviews': <StarOutlined />,
|
|
||||||
'/banned-words': <StopOutlined />,
|
|
||||||
'/tickets': <BugOutlined />,
|
|
||||||
'/subscriptions': <DollarOutlined />,
|
|
||||||
'/admins': <TeamOutlined />,
|
|
||||||
'/audit': <AuditOutlined />,
|
|
||||||
'/monitoring': <CloudServerOutlined />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems = MENU_ITEMS.map((item) => ({
|
|
||||||
key: item.key,
|
|
||||||
icon: menuIconByKey[item.key],
|
|
||||||
label: item.label,
|
|
||||||
roles: item.roles,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const filteredMenu = filterMenuByRole(menuItems, user?.role);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout();
|
|
||||||
navigate('/login');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDisplayName = () => {
|
|
||||||
const nick = user?.nickname;
|
|
||||||
const email = user?.email;
|
|
||||||
if (nick && nick !== '-' && nick !== 'undefined') return nick;
|
|
||||||
if (email && email !== '-' && email !== 'undefined') return email;
|
|
||||||
return user?.id ?? '—';
|
|
||||||
};
|
|
||||||
|
|
||||||
const NodeIndicator: React.FC<{ node: string; stats: NodeMetric; prevStats?: NodeMetric }> = ({
|
|
||||||
node,
|
|
||||||
stats,
|
|
||||||
prevStats,
|
|
||||||
}) => <MetricIndicator node={node} stats={stats} prevStats={prevStats} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
|
||||||
<Sider
|
|
||||||
collapsible
|
|
||||||
collapsed={collapsed}
|
|
||||||
onCollapse={setCollapsed}
|
|
||||||
trigger={null}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100vh',
|
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ height: 32, margin: 16, color: 'white', textAlign: 'center', fontWeight: 'bold' }}>
|
|
||||||
{collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE}
|
|
||||||
</div>
|
|
||||||
<Menu
|
|
||||||
theme="dark"
|
|
||||||
mode="inline"
|
|
||||||
selectedKeys={[location.pathname]}
|
|
||||||
items={filteredMenu}
|
|
||||||
onClick={({ key }) => navigate(key)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: collapsed ? 'center' : 'space-between',
|
|
||||||
padding: '12px 24px',
|
|
||||||
borderTop: '1px solid rgba(255,255,255,0.1)',
|
|
||||||
background: 'rgba(0,0,0,0.1)',
|
|
||||||
marginTop: 'auto',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dropdown
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'profile',
|
|
||||||
icon: <SettingOutlined />,
|
|
||||||
label: 'Мой профиль',
|
|
||||||
onClick: () => navigate('/profile'),
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'logout',
|
|
||||||
icon: <LogoutOutlined />,
|
|
||||||
label: 'Выйти',
|
|
||||||
onClick: handleLogout,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
trigger={['click']}
|
|
||||||
placement="topLeft"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
style={{
|
|
||||||
color: 'white',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '4px 0',
|
|
||||||
border: 'none',
|
|
||||||
width: collapsed ? 'auto' : '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar icon={<UserOutlined />} src={user?.avatar_url} size="small" />
|
|
||||||
{!collapsed && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: 'white',
|
|
||||||
marginLeft: 8,
|
|
||||||
maxWidth: 100,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
ellipsis
|
|
||||||
>
|
|
||||||
{getDisplayName()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
style={{
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '16px',
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Sider>
|
|
||||||
<Layout>
|
|
||||||
<Header
|
|
||||||
style={{
|
|
||||||
padding: '0 24px',
|
|
||||||
background: colorBgContainer,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space size="large">
|
|
||||||
<Tooltip title={`Онлайн-ноды: ${onlineNodes.join(', ')}`}>
|
|
||||||
<Badge
|
|
||||||
status="processing"
|
|
||||||
text={`Ноды: ${onlineNodes.length}`}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
{onlineNodes.map(node => {
|
|
||||||
const stats = latestByNode.get(node);
|
|
||||||
if (!stats) return null;
|
|
||||||
const prevStats = previous?.node === node ? previous : undefined;
|
|
||||||
return <NodeIndicator key={node} node={node} stats={stats} prevStats={prevStats} />;
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
<div>{/* Резерв */}</div>
|
|
||||||
</Header>
|
|
||||||
<Content style={{ margin: 24 }}>
|
|
||||||
<Outlet />
|
|
||||||
</Content>
|
|
||||||
</Layout>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminLayout;
|
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Inbox,
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
CalendarDays,
|
||||||
|
AlertTriangle,
|
||||||
|
Star,
|
||||||
|
Ban,
|
||||||
|
Ticket,
|
||||||
|
CreditCard,
|
||||||
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
|
FileSearch,
|
||||||
|
Activity,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
Search,
|
||||||
|
Rows3,
|
||||||
|
Check,
|
||||||
|
Languages,
|
||||||
|
Settings2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { useAdminWebSocket } from '@/hooks/useAdminWebSocket';
|
||||||
|
import { useUpdateProfile } from '@/hooks/useProfile';
|
||||||
|
import {
|
||||||
|
filterNavGroupsByRole,
|
||||||
|
getMenuSelectedKey,
|
||||||
|
canReceiveWsNotification,
|
||||||
|
NAV_GROUPS,
|
||||||
|
type NavItem,
|
||||||
|
} from '@/config/adminAccess';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { testid } from '@/lib/testid';
|
||||||
|
import { getTableDensity, toggleTableDensity, type TableDensity } from '@/lib/density';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
import { CommandPalette } from '@/components/CommandPalette';
|
||||||
|
import { useCommandPaletteHotkey } from '@/hooks/useCommandPaletteHotkey';
|
||||||
|
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
|
||||||
|
import { healthApi } from '@/api/healthApi';
|
||||||
|
|
||||||
|
function formatBuildLabel(version: string, build: number | string | undefined, sha: string): string {
|
||||||
|
const shortSha = sha && sha !== 'unknown' && sha !== 'dev' ? sha.slice(0, 12) : sha;
|
||||||
|
const buildPart =
|
||||||
|
build === undefined || build === null || build === '' || Number(build) === 0
|
||||||
|
? version
|
||||||
|
: `${version}.${build}`;
|
||||||
|
return `${buildPart} (${shortSha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
'/dashboard': LayoutDashboard,
|
||||||
|
'/inbox/reports': Inbox,
|
||||||
|
'/inbox/tickets': Ticket,
|
||||||
|
'/inbox/automod': ShieldAlert,
|
||||||
|
'/users': Users,
|
||||||
|
'/calendars': Calendar,
|
||||||
|
'/events': CalendarDays,
|
||||||
|
'/reports': AlertTriangle,
|
||||||
|
'/reviews': Star,
|
||||||
|
'/banned-words': Ban,
|
||||||
|
'/automod-settings': Settings2,
|
||||||
|
'/tickets': Ticket,
|
||||||
|
'/subscriptions': CreditCard,
|
||||||
|
'/admins': Shield,
|
||||||
|
'/audit': FileSearch,
|
||||||
|
'/monitoring': Activity,
|
||||||
|
};
|
||||||
|
|
||||||
|
function navTestId(path: string): string {
|
||||||
|
return testid(['nav', ...path.split('/').filter(Boolean)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLinkItem({ item, active, collapsed }: { item: NavItem; active: boolean; collapsed: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const Icon = navIconByKey[item.key] ?? LayoutDashboard;
|
||||||
|
const label = t(item.label);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={item.key}
|
||||||
|
data-testid={navTestId(item.key)}
|
||||||
|
title={collapsed ? label : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
||||||
|
collapsed && 'justify-center px-2',
|
||||||
|
active
|
||||||
|
? 'bg-sidebar-accent text-white font-medium'
|
||||||
|
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span className="truncate">{label}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ControlCenterLayout: React.FC = () => {
|
||||||
|
useAdminWebSocket();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const updateProfile = useUpdateProfile();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [density, setDensity] = useState<TableDensity>(() => getTableDensity());
|
||||||
|
const [apiVersionLabel, setApiVersionLabel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openPalette = useCallback(() => setPaletteOpen(true), []);
|
||||||
|
useCommandPaletteHotkey(openPalette);
|
||||||
|
|
||||||
|
const adminVersionLabel = formatBuildLabel(
|
||||||
|
import.meta.env.VITE_APP_VERSION || '0.0',
|
||||||
|
import.meta.env.VITE_APP_BUILD || '0',
|
||||||
|
import.meta.env.VITE_GIT_SHA || 'dev'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
healthApi
|
||||||
|
.getAdminHealth()
|
||||||
|
.then((info) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setApiVersionLabel(
|
||||||
|
formatBuildLabel(
|
||||||
|
info.version || '0.0',
|
||||||
|
info.build,
|
||||||
|
info.git_sha || 'unknown'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setApiVersionLabel(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedKey = getMenuSelectedKey(location.pathname);
|
||||||
|
const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: CustomEvent) => {
|
||||||
|
const msg = event.detail;
|
||||||
|
if (msg.type === 'report_created') {
|
||||||
|
if (!canReceiveWsNotification('report_created', user?.role)) return;
|
||||||
|
const { report_id, target_type, reason } = msg.data || {};
|
||||||
|
const inboxPath = report_id
|
||||||
|
? `/inbox/reports?selected=${report_id}`
|
||||||
|
: '/inbox/reports';
|
||||||
|
notify.info(
|
||||||
|
[
|
||||||
|
report_id ? t('ws.reportId', { id: report_id }) : t('ws.newReport'),
|
||||||
|
target_type ? t('ws.onTarget', { type: target_type }) : '',
|
||||||
|
reason ? `(${reason})` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: t('ws.open'),
|
||||||
|
onClick: () => navigate(inboxPath),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (msg.type === 'ticket_created') {
|
||||||
|
if (!canReceiveWsNotification('ticket_created', user?.role)) return;
|
||||||
|
const { ticket_id } = msg.data || {};
|
||||||
|
const inboxPath = ticket_id
|
||||||
|
? `/inbox/tickets?selected=${ticket_id}`
|
||||||
|
: '/inbox/tickets';
|
||||||
|
notify.info(
|
||||||
|
ticket_id ? t('ws.ticketCreated', { id: ticket_id }) : t('ws.newTicket'),
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: t('ws.open'),
|
||||||
|
onClick: () => navigate(inboxPath),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (msg.type === 'automod_hit') {
|
||||||
|
if (!canReceiveWsNotification('automod_hit', user?.role)) return;
|
||||||
|
const { entity_type, trigger } = msg.data || {};
|
||||||
|
notify.info(
|
||||||
|
[
|
||||||
|
t('ws.automodHit'),
|
||||||
|
entity_type ? `(${entity_type})` : '',
|
||||||
|
trigger ? `[${trigger}]` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: t('ws.open'),
|
||||||
|
onClick: () => navigate('/inbox/automod'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('admin-ws-message', handler as EventListener);
|
||||||
|
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
|
||||||
|
}, [navigate, user?.role, t]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDensityToggle = () => {
|
||||||
|
const next = toggleTableDensity();
|
||||||
|
setDensity(next);
|
||||||
|
notify.info(next === 'compact' ? t('layout.densityCompact') : t('layout.densityNormal'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = (() => {
|
||||||
|
const nick = user?.nickname;
|
||||||
|
const email = user?.email;
|
||||||
|
if (nick && nick !== '-' && nick !== 'undefined') return nick;
|
||||||
|
if (email && email !== '-' && email !== 'undefined') return email;
|
||||||
|
return user?.id ?? t('common.emDash');
|
||||||
|
})();
|
||||||
|
|
||||||
|
const initials = displayName.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="layout-shell" className="flex min-h-screen bg-background">
|
||||||
|
<aside
|
||||||
|
data-testid="layout-sidebar"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-[width]',
|
||||||
|
collapsed ? 'w-16' : 'w-60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center justify-between px-3 border-b border-white/10">
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="font-semibold text-sm truncate">
|
||||||
|
{import.meta.env.VITE_APP_TITLE || t('common.appTitle')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-sidebar-foreground hover:bg-sidebar-accent shrink-0"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 px-2 py-3">
|
||||||
|
<nav className="space-y-4">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.id}>
|
||||||
|
{!collapsed && (
|
||||||
|
<p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
|
||||||
|
{t(group.label)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<NavLinkItem
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={selectedKey === item.key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="flex h-14 items-center gap-3 border-b bg-card px-4">
|
||||||
|
<HeaderNodeMetrics className="min-w-0 flex-1" />
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="btn-open-palette"
|
||||||
|
className="hidden sm:inline-flex gap-2 text-muted-foreground"
|
||||||
|
onClick={openPalette}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
{t('layout.search')}
|
||||||
|
<kbd className="pointer-events-none rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="sm:hidden"
|
||||||
|
title={t('layout.searchHotkey')}
|
||||||
|
onClick={openPalette}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title={density === 'compact' ? t('layout.densityToNormal') : t('layout.densityToCompact')}
|
||||||
|
onClick={handleDensityToggle}
|
||||||
|
>
|
||||||
|
<Rows3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="gap-2 px-2" data-testid="menu-user">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={user?.avatar_url ?? undefined} alt={displayName} />
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="hidden sm:inline max-w-[120px] truncate text-sm font-medium">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56" data-testid="menu-user-content">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{displayName}</span>
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">{user?.role}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem data-testid="menu-profile" onClick={() => navigate('/profile')}>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
{t('layout.myProfile')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
|
||||||
|
<Languages className="h-3.5 w-3.5" />
|
||||||
|
{t('layout.uiLanguage')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="lang-ru"
|
||||||
|
disabled={updateProfile.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if ((user?.language || 'ru') === 'ru') return;
|
||||||
|
updateProfile.mutate({ language: 'ru' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
(user?.language || 'ru') === 'ru' ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t('common.langRu')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="lang-en"
|
||||||
|
disabled={updateProfile.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (user?.language === 'en') return;
|
||||||
|
updateProfile.mutate({ language: 'en' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
user?.language === 'en' ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t('common.langEn')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="space-y-0.5 text-xs font-normal text-muted-foreground">
|
||||||
|
<div>{t('layout.adminVersion', { version: adminVersionLabel })}</div>
|
||||||
|
<div>
|
||||||
|
{apiVersionLabel
|
||||||
|
? t('layout.apiVersion', { version: apiVersionLabel })
|
||||||
|
: t('layout.apiVersionUnavailable')}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="btn-logout"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{t('layout.logout')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="cc-main-surface flex-1 overflow-auto p-4 md:p-6">
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Outlet />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ControlCenterLayout;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const STORAGE_KEY = 'admin.ui.density';
|
||||||
|
|
||||||
|
export type TableDensity = 'comfortable' | 'compact';
|
||||||
|
|
||||||
|
export function getTableDensity(): TableDensity {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw === 'compact' ? 'compact' : 'comfortable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTableDensity(density: TableDensity): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, density);
|
||||||
|
document.documentElement.dataset.density = density;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTableDensity(): void {
|
||||||
|
document.documentElement.dataset.density = getTableDensity();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTableDensity(): TableDensity {
|
||||||
|
const next: TableDensity = getTableDensity() === 'compact' ? 'comfortable' : 'compact';
|
||||||
|
setTableDensity(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { ticketsApi, type TicketSource } from '@/api/ticketsApi';
|
||||||
|
import { ACCESS_TOKEN_KEY } from '@/utils/constants';
|
||||||
|
|
||||||
|
const THROTTLE_MS = 60_000;
|
||||||
|
const recentHashes = new Map<string, number>();
|
||||||
|
let reporting = false;
|
||||||
|
|
||||||
|
function simpleHash(input: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return String(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldThrottle(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = recentHashes.get(key) ?? 0;
|
||||||
|
if (now - last < THROTTLE_MS) return true;
|
||||||
|
recentHashes.set(key, now);
|
||||||
|
// prune old entries
|
||||||
|
if (recentHashes.size > 200) {
|
||||||
|
for (const [k, ts] of recentHashes) {
|
||||||
|
if (now - ts > THROTTLE_MS) recentHashes.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContext(extra?: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
route: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||||
|
build: import.meta.env.VITE_APP_VERSION || '0.0',
|
||||||
|
build_number: import.meta.env.VITE_APP_BUILD || '0',
|
||||||
|
git_sha: import.meta.env.VITE_GIT_SHA || 'dev',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportErrorInput = {
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
source?: TicketSource;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an error to POST /v1/tickets. No-ops without auth token,
|
||||||
|
* while reporting is in flight for this call chain, or when throttled.
|
||||||
|
*/
|
||||||
|
export async function reportClientError(input: ReportErrorInput): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!localStorage.getItem(ACCESS_TOKEN_KEY)) return;
|
||||||
|
if (reporting) return;
|
||||||
|
|
||||||
|
const source = input.source ?? (input.stack ? 'frontend' : 'manual');
|
||||||
|
const message = (input.message || 'Unknown error').slice(0, 2000);
|
||||||
|
const stack = (input.stack || '').slice(0, 8000);
|
||||||
|
const throttleKey = simpleHash(`${source}|${message}|${stack.slice(0, 500)}`);
|
||||||
|
if (shouldThrottle(throttleKey)) return;
|
||||||
|
|
||||||
|
reporting = true;
|
||||||
|
try {
|
||||||
|
await ticketsApi.createTicket({
|
||||||
|
error_message: message,
|
||||||
|
stacktrace: stack || undefined,
|
||||||
|
source,
|
||||||
|
context: buildContext(input.context),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Never rethrow — avoid error loops from the reporter itself.
|
||||||
|
} finally {
|
||||||
|
reporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGlobalErrorHandlers(): () => void {
|
||||||
|
const onError = (event: ErrorEvent) => {
|
||||||
|
const msg = event.message || 'window.onerror';
|
||||||
|
if (msg.includes('/v1/tickets')) return;
|
||||||
|
void reportClientError({
|
||||||
|
message: msg,
|
||||||
|
stack: event.error?.stack || `${event.filename}:${event.lineno}:${event.colno}`,
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
const message =
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: typeof reason === 'string'
|
||||||
|
? reason
|
||||||
|
: 'unhandledrejection';
|
||||||
|
const stack = reason instanceof Error ? reason.stack : undefined;
|
||||||
|
if (message.includes('/v1/tickets')) return;
|
||||||
|
// Skip expected auth failures
|
||||||
|
if (typeof message === 'string' && /\b(401|403)\b/.test(message)) return;
|
||||||
|
void reportClientError({
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', onError);
|
||||||
|
window.addEventListener('unhandledrejection', onRejection);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('error', onError);
|
||||||
|
window.removeEventListener('unhandledrejection', onRejection);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const STORAGE_KEY = 'admin.ui.exploreView';
|
||||||
|
|
||||||
|
export type ExploreViewMode = 'table' | 'rows';
|
||||||
|
|
||||||
|
export function getExploreViewMode(): ExploreViewMode {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) === 'rows' ? 'rows' : 'table';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setExploreViewMode(mode: ExploreViewMode): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user