mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-13 21:27:46 +02:00
Merge branch 'staging-new' of https://github.com/hcengineering/platform
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Reusable action: set all @hcengineering package versions from a v tag ref
|
||||
# (e.g. refs/tags/v0.7.370 → 0.7.370) using common/scripts/bump.js.
|
||||
# Call only when github.ref is a v* tag (e.g. if: startsWith(github.ref, 'refs/tags/v')).
|
||||
name: 'Set package versions'
|
||||
description: 'Set all @hcengineering package versions from a v tag ref using bump.js'
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref for the tag (e.g. github.ref, e.g. refs/tags/v0.7.370)'
|
||||
required: true
|
||||
outputs:
|
||||
version:
|
||||
description: 'Semver version derived from the tag (e.g. 0.7.370)'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- id: bump
|
||||
run: |
|
||||
VERSION="${INPUT_REF#refs/tags/v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
node common/scripts/bump.js "$VERSION"
|
||||
shell: bash
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
name: CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -19,6 +22,16 @@ on:
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Ref to run as (e.g. refs/tags/v0.7.370 to test release flow)'
|
||||
required: false
|
||||
default: 'refs/heads/develop'
|
||||
skip_publish:
|
||||
description: 'Skip all publishing (npm, Docker, R2) when testing release flow'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CacheFolders: |
|
||||
@@ -57,12 +70,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
filter: tree:0
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Set package versions
|
||||
if: startsWith(github.event.inputs.ref || github.ref, 'refs/tags/v')
|
||||
uses: ./.github/actions/set-package-versions
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
@@ -74,6 +93,15 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Installing...
|
||||
run: |
|
||||
REF="${{ github.event.inputs.ref || github.ref }}"
|
||||
if [[ "$REF" == refs/tags/v* ]]; then
|
||||
node common/scripts/install-run-rush.js update
|
||||
else
|
||||
node common/scripts/install-run-rush.js install
|
||||
fi
|
||||
|
||||
# - name: Cheking model is updated...
|
||||
# run: node common/scripts/check_model_version.js
|
||||
|
||||
@@ -83,9 +111,6 @@ jobs:
|
||||
- name: Checking for mis-matching transitive dependencies...
|
||||
run: node common/scripts/check-versions.js
|
||||
|
||||
- name: Installing...
|
||||
run: node common/scripts/install-run-rush.js install
|
||||
|
||||
- name: Model version from git tags
|
||||
run: node common/scripts/install-run-rush.js model-version
|
||||
|
||||
@@ -698,12 +723,12 @@ jobs:
|
||||
# if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# run: node common/scripts/install-run-rush.js docker:staging -v
|
||||
- name: Docker push tag
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/s') }}
|
||||
if: ${{ (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/s')) && (github.event_name != 'workflow_dispatch' || github.event.inputs.skip_publish != 'true') }}
|
||||
run: |
|
||||
echo Pushing release of tag ${{ github.ref }}
|
||||
node common/scripts/install-run-rush.js docker:push -v
|
||||
- name: Docker push love-agent
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/s') }}
|
||||
if: ${{ (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/s')) && (github.event_name != 'workflow_dispatch' || github.event.inputs.skip_publish != 'true') }}
|
||||
run: |
|
||||
echo Pushing love-agent release of tag ${{ github.ref }}
|
||||
cd ./services/ai-bot/love-agent
|
||||
@@ -782,6 +807,7 @@ jobs:
|
||||
node ../common/scripts/install-run-rushx.js dist-signed --macos --x64 --arm64
|
||||
./scripts/copy-publish-artifacts.sh ${{ env.PublishTempFolder}}
|
||||
- name: Publish distribution assets and version
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.skip_publish != 'true'
|
||||
uses: ryand56/r2-upload-action@latest
|
||||
with:
|
||||
r2-account-id: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
@@ -884,6 +910,7 @@ jobs:
|
||||
node ../common/scripts/install-run-rushx.js dist-signed --macos --x64 --arm64
|
||||
./scripts/copy-publish-artifacts.sh ${{ env.PublishTempFolder}}
|
||||
- name: Publish distribution assets and version
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.skip_publish != 'true'
|
||||
uses: ryand56/r2-upload-action@latest
|
||||
with:
|
||||
r2-account-id: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
@@ -912,3 +939,34 @@ jobs:
|
||||
with:
|
||||
name: TraceX-Linux
|
||||
path: ./qms-desktop-package/deploy/TraceX-linux-*.zip
|
||||
|
||||
publish-npm:
|
||||
if: startsWith(github.event.inputs.ref || github.ref, 'refs/tags/v') && (github.event_name != 'workflow_dispatch' || github.event.inputs.skip_publish != 'true')
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
cache-name: cache-node-platform
|
||||
with:
|
||||
path: common/temp
|
||||
key: ${{ runner.os }}-build-cache-node-platform-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-build-cache-node-platform-
|
||||
- name: Installing...
|
||||
run: node common/scripts/install-run-rush.js install
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
REF="${{ github.event.inputs.ref || github.ref }}"
|
||||
VERSION="${REF#refs/tags/v}"
|
||||
node common/scripts/bump.js "$VERSION" --publish
|
||||
|
||||
+46
-16
@@ -1,35 +1,51 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const execSync = require('child_process').execSync
|
||||
const repo = '@hcengineering'
|
||||
|
||||
const packages = {}
|
||||
const pathes = {}
|
||||
const jsons = {}
|
||||
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim()
|
||||
|
||||
function fillPackages (config) {
|
||||
for (const package of config.projects) {
|
||||
if (!package.name.startsWith(repo)) continue
|
||||
for (const project of config.projects) {
|
||||
const packageName = project.name ?? project.packageName
|
||||
if (typeof packageName !== 'string' || !packageName.startsWith(repo)) continue
|
||||
const projectPath = project.path ?? project.projectFolder ?? path.relative(repoRoot, project.fullPath ?? '')
|
||||
if (typeof projectPath !== 'string' || projectPath.length === 0) continue
|
||||
const fullProjectPath = path.resolve(repoRoot, projectPath)
|
||||
|
||||
packages[package.name] = {
|
||||
version: package.version,
|
||||
path: package.path
|
||||
packages[packageName] = {
|
||||
version: project.version,
|
||||
path: fullProjectPath
|
||||
}
|
||||
pathes[package.path] = package.name
|
||||
pathes[fullProjectPath] = packageName
|
||||
|
||||
const file = package.path + '/package.json'
|
||||
const raw = fs.readFileSync(file)
|
||||
jsons[package.name] = JSON.parse(raw)
|
||||
const file = path.join(fullProjectPath, 'package.json')
|
||||
if (!fs.existsSync(file)) {
|
||||
console.log('skip, package.json not found:', file)
|
||||
continue
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(file)
|
||||
jsons[packageName] = JSON.parse(raw)
|
||||
}
|
||||
}
|
||||
|
||||
function bumpPackage (name, newVersion) {
|
||||
const json = jsons[name]
|
||||
|
||||
if (json === undefined) return
|
||||
json.version = newVersion
|
||||
if (typeof json.dependencies === 'object') {
|
||||
for (const [dependency] of Object.entries(json.dependencies)) {
|
||||
const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
||||
for (const depType of depTypes) {
|
||||
if (typeof json[depType] !== 'object') continue
|
||||
for (const [dependency, currentVersion] of Object.entries(json[depType])) {
|
||||
if (packages[dependency] !== undefined) {
|
||||
json.dependencies[dependency] = `^${newVersion}`
|
||||
json[depType][dependency] = String(currentVersion).startsWith('workspace:')
|
||||
? `workspace:^${newVersion}`
|
||||
: `^${newVersion}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +60,7 @@ function publish (name) {
|
||||
const package = packages[name]
|
||||
try {
|
||||
console.log('publishing', name)
|
||||
execSync(`cd ${package.path} && npm publish && cd ../..`, { encoding: 'utf-8' })
|
||||
execSync('npm publish', { encoding: 'utf-8', cwd: package.path })
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
@@ -54,7 +70,7 @@ function fix (name) {
|
||||
const package = packages[name]
|
||||
try {
|
||||
console.log('fixing', name)
|
||||
execSync(`cd ${package.path} && npm pkg fix && cd ../..`, { encoding: 'utf-8' })
|
||||
execSync('npm pkg fix', { encoding: 'utf-8', cwd: package.path })
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
@@ -78,7 +94,20 @@ function main () {
|
||||
|
||||
console.log('bump version ...', version)
|
||||
|
||||
const config = JSON.parse(execSync('rush list -p --json', { encoding: 'utf-8' }))
|
||||
const output = execSync('node common/scripts/install-run-rush.js list -p --json', { encoding: 'utf-8', cwd: repoRoot })
|
||||
const lines = output.split('\n')
|
||||
let jsonStart = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith('{')) {
|
||||
jsonStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (jsonStart === -1) {
|
||||
console.error('Could not find JSON output from rush list')
|
||||
process.exit(1)
|
||||
}
|
||||
const config = JSON.parse(lines.slice(jsonStart).join('\n'))
|
||||
|
||||
fillPackages(config)
|
||||
|
||||
@@ -89,7 +118,8 @@ function main () {
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const package = packages[packageName]
|
||||
const file = package.path + '/package.json'
|
||||
if (jsons[packageName] === undefined) continue
|
||||
const file = path.join(package.path, 'package.json')
|
||||
const res = JSON.stringify(jsons[packageName], undefined, 2)
|
||||
fs.writeFileSync(file, res + '\n')
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -1175,7 +1175,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hulypulse"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"actix-cors",
|
||||
"actix-web",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hulypulse"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -30,7 +30,7 @@ hulyrs = { git = "https://github.com/hcengineering/hulyrs.git", features = [ "ac
|
||||
secrecy = { version = "0.10.3", optional = true }
|
||||
|
||||
#redis
|
||||
redis = { version = "=0.32.5", features = ["aio", "tokio-comp", "sentinel"], optional = true }
|
||||
redis = { version = "=0.32.5", features = ["aio", "tokio-comp", "sentinel"] }
|
||||
|
||||
[[bin]]
|
||||
name = "hulypulse"
|
||||
@@ -43,7 +43,6 @@ tokio-tungstenite = { version = "0.21", default-features = false, features = [
|
||||
] }
|
||||
|
||||
[features]
|
||||
default = ["db-redis","auth"] # lopt
|
||||
default = ["auth"] # lopt
|
||||
auth = ["regorus", "uuid", "hulyrs", "secrecy"]
|
||||
lopt = []
|
||||
db-redis = ["redis"]
|
||||
@@ -190,15 +190,14 @@ Size of data is limited to some reasonable size
|
||||
- `{"message":"Del","key":"00000000-0000-0000-0000-000000000001/foo/bar"}`
|
||||
|
||||
## Special options in config/default.toml
|
||||
- ```memory_mode = true``` Use native memory storage instead Redis
|
||||
- ```backend = "memory"``` Use native memory storage instead Redis
|
||||
- ```max_size = 100``` Max value size in bytes
|
||||
|
||||
## Special cargo build options
|
||||
- "db-redis" (default) - use Redis (Memory instead)
|
||||
- "auth" (default) - use huly-authorization
|
||||
Disable both:
|
||||
Disable auth:
|
||||
cargo build --no-default-features
|
||||
Enable one:
|
||||
Enable auth:
|
||||
cargo build --no-default-features --features "auth"
|
||||
|
||||
## Running
|
||||
@@ -207,7 +206,17 @@ Pre-build docker images is available at: hardcoreeng/service_hulypulse:{tag}.
|
||||
|
||||
You can use the following command to run the image locally:
|
||||
```bash
|
||||
docker run -p 8095:8095 -it --rm hardcoreeng/service_hulypulse:{tag}"
|
||||
docker run -p 8095:8095 -it --rm hardcoreeng/service_hulypulse:{tag}
|
||||
```
|
||||
|
||||
Run from source using Redis:
|
||||
```bash
|
||||
HULY_REDIS_URLS=redis://huly.local:6379 cargo run
|
||||
```
|
||||
|
||||
Run from source in in-memory mode:
|
||||
```bash
|
||||
HULY_BACKEND=memory cargo run
|
||||
```
|
||||
|
||||
If you want to run the service as a part of local huly development environment use the following command:
|
||||
@@ -228,6 +237,7 @@ The following environment variables are used to configure hulypulse:
|
||||
- ```HULY_BIND_HOST```: host to bind the server to (default: 0.0.0.0)
|
||||
- ```HULY_BIND_PORT```: port to bind the server to (default: 8094)
|
||||
- ```HULY_TOKEN_SECRET```: secret used to sign JWT tokens (default: secret)
|
||||
- ```HULY_BACKEND```: storage backend "redis" or "memory" (default: "redis")
|
||||
- ```HULY_REDIS_URLS```: redis connection string (default: redis://huly.local:6379)
|
||||
- ```HULY_REDIS_PASSWORD```: redis password (default: "<invalid>")
|
||||
- ```HULY_REDIS_MODE```: redis mode "direct" or "sentinel" (default: "direct")
|
||||
|
||||
@@ -19,12 +19,9 @@ use std::{path::Path, sync::LazyLock};
|
||||
use secrecy::SecretString;
|
||||
|
||||
use serde::Deserialize;
|
||||
#[cfg(feature = "db-redis")]
|
||||
use serde_with::StringWithSeparator;
|
||||
#[cfg(feature = "db-redis")]
|
||||
use serde_with::formats::CommaSeparator;
|
||||
use serde_with::serde_as;
|
||||
#[cfg(feature = "db-redis")]
|
||||
use url::Url;
|
||||
|
||||
use config::FileFormat;
|
||||
@@ -43,6 +40,10 @@ pub enum BackendType {
|
||||
Redis,
|
||||
}
|
||||
|
||||
fn default_backend() -> BackendType {
|
||||
BackendType::Redis
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
@@ -52,20 +53,18 @@ pub struct Config {
|
||||
#[cfg(feature = "auth")]
|
||||
pub token_secret: SecretString,
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
#[serde(default = "default_backend")]
|
||||
pub backend: BackendType,
|
||||
|
||||
#[serde_as(as = "StringWithSeparator::<CommaSeparator, url::Url>")]
|
||||
pub redis_urls: Vec<Url>,
|
||||
#[cfg(feature = "db-redis")]
|
||||
pub redis_password: String,
|
||||
#[cfg(feature = "db-redis")]
|
||||
pub redis_mode: RedisMode,
|
||||
#[cfg(feature = "db-redis")]
|
||||
pub redis_service: String,
|
||||
|
||||
pub max_ttl: usize,
|
||||
pub max_size: Option<usize>,
|
||||
|
||||
// pub backend: BackendType,
|
||||
pub heartbeat_timeout: u64,
|
||||
pub ping_timeout: u64,
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ bind_host = "0.0.0.0"
|
||||
|
||||
token_secret = "secret"
|
||||
|
||||
backend = "redis"
|
||||
redis_urls = "redis://huly.local:6379"
|
||||
redis_password = "<invalid>"
|
||||
redis_mode = "direct"
|
||||
|
||||
+161
-125
@@ -1,43 +1,38 @@
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
use crate::hub_service::{HubState, RedisEvent, RedisEventAction, broadcast_event};
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
use crate::memory::{
|
||||
MemoryBackend, memory_delete, memory_info, memory_list, memory_read, memory_save,
|
||||
};
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
use crate::redis::{redis_delete, redis_info, redis_list, redis_read, redis_save};
|
||||
use redis::aio::MultiplexedConnection;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
use ::redis::aio::MultiplexedConnection;
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
pub type DbError = redis::RedisError;
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
#[derive(Debug)]
|
||||
pub struct DbError(pub String);
|
||||
pub enum DbError {
|
||||
Redis(redis::RedisError),
|
||||
Message(String),
|
||||
}
|
||||
|
||||
pub type DbResult<T> = Result<T, DbError>;
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
impl std::fmt::Display for DbError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
match self {
|
||||
Self::Redis(err) => write!(f, "{err}"),
|
||||
Self::Message(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
impl std::error::Error for DbError {}
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use serde::Serialize;
|
||||
impl From<redis::RedisError> for DbError {
|
||||
fn from(value: redis::RedisError) -> Self {
|
||||
Self::Redis(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DbArray {
|
||||
@@ -61,33 +56,8 @@ pub enum SaveMode {
|
||||
Equal(String), // only if md5 matches provided
|
||||
}
|
||||
|
||||
/// return Error
|
||||
// pub fn error<T>(code: u16, msg: impl Into<String>) -> DbResult<T> {
|
||||
// let msg = msg.into();
|
||||
// let full = format!("{}: {}", code, msg);
|
||||
// Err(redis::RedisError::from((
|
||||
// redis::ErrorKind::ExtensionError,
|
||||
// "",
|
||||
// full,
|
||||
// )))
|
||||
// }
|
||||
|
||||
pub fn error<T>(code: u16, msg: impl Into<String>) -> DbResult<T> {
|
||||
let msg = format!("{}: {}", code, msg.into());
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
{
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::ExtensionError,
|
||||
"",
|
||||
msg,
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
{
|
||||
return Err(DbError(msg));
|
||||
}
|
||||
Err(DbError::Message(format!("{}: {}", code, msg.into())))
|
||||
}
|
||||
|
||||
/// Check for redis-deprecated symbols
|
||||
@@ -108,59 +78,67 @@ pub fn deprecated_symbol_error(s: &str) -> DbResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum DbBackend {
|
||||
Redis(MultiplexedConnection),
|
||||
Memory {
|
||||
db: MemoryBackend,
|
||||
hub: Arc<RwLock<HubState>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
#[cfg(feature = "db-redis")]
|
||||
db: MultiplexedConnection,
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
db: MemoryBackend,
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
hub: Arc<RwLock<HubState>>,
|
||||
backend: DbBackend,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn new_db(
|
||||
#[cfg(not(feature = "db-redis"))] db: MemoryBackend,
|
||||
#[cfg(feature = "db-redis")] db: MultiplexedConnection,
|
||||
#[cfg(not(feature = "db-redis"))] hub: Arc<RwLock<HubState>>,
|
||||
) -> Self {
|
||||
pub fn new_redis(db: MultiplexedConnection) -> Self {
|
||||
Self {
|
||||
db,
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
hub,
|
||||
backend: DbBackend::Redis(db),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_memory(db: MemoryBackend, hub: Arc<RwLock<HubState>>) -> Self {
|
||||
Self {
|
||||
backend: DbBackend::Memory { db, hub },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> &'static str {
|
||||
match &self.backend {
|
||||
DbBackend::Redis(_) => "redis",
|
||||
DbBackend::Memory { .. } => "memory",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn info(&self) -> DbResult<String> {
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
return memory_info(&self.db).await;
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
{
|
||||
let mut c = self.db.clone();
|
||||
redis_info(&mut c).await
|
||||
match &self.backend {
|
||||
DbBackend::Memory { db, .. } => memory_info(db).await,
|
||||
DbBackend::Redis(conn) => {
|
||||
let mut c = conn.clone();
|
||||
redis_info(&mut c).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(&self, key: &str) -> DbResult<Vec<DbArray>> {
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
return memory_list(&self.db, key).await;
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
{
|
||||
let mut c = self.db.clone();
|
||||
redis_list(&mut c, key).await
|
||||
match &self.backend {
|
||||
DbBackend::Memory { db, .. } => memory_list(db, key).await,
|
||||
DbBackend::Redis(conn) => {
|
||||
let mut c = conn.clone();
|
||||
redis_list(&mut c, key).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(&self, key: &str) -> DbResult<Option<DbArray>> {
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
return memory_read(&self.db, key).await;
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
{
|
||||
let mut c = self.db.clone();
|
||||
redis_read(&mut c, key).await
|
||||
match &self.backend {
|
||||
DbBackend::Memory { db, .. } => memory_read(db, key).await,
|
||||
DbBackend::Redis(conn) => {
|
||||
let mut c = conn.clone();
|
||||
redis_read(&mut c, key).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,54 +149,112 @@ impl Db {
|
||||
ttl: Option<Ttl>,
|
||||
mode: Option<SaveMode>,
|
||||
) -> DbResult<()> {
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
{
|
||||
memory_save(&self.db, key, value.as_ref(), ttl, mode).await?;
|
||||
// Send events
|
||||
let value_str = std::str::from_utf8(value.as_ref())
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
broadcast_event(
|
||||
&self.hub,
|
||||
RedisEvent {
|
||||
message: RedisEventAction::Set,
|
||||
key: key.to_string(),
|
||||
},
|
||||
value_str,
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
{
|
||||
let mut c = self.db.clone();
|
||||
redis_save(&mut c, key, value.as_ref(), ttl, mode).await
|
||||
match &self.backend {
|
||||
DbBackend::Memory { db, hub } => {
|
||||
memory_save(db, key, value.as_ref(), ttl, mode).await?;
|
||||
let value_str = std::str::from_utf8(value.as_ref())
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
broadcast_event(
|
||||
hub,
|
||||
RedisEvent {
|
||||
message: RedisEventAction::Set,
|
||||
key: key.to_string(),
|
||||
},
|
||||
value_str,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
DbBackend::Redis(conn) => {
|
||||
let mut c = conn.clone();
|
||||
redis_save(&mut c, key, value.as_ref(), ttl, mode).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(&self, key: &str, mode: Option<SaveMode>) -> DbResult<bool> {
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
{
|
||||
let deleted = memory_delete(&self.db, key, mode).await?;
|
||||
if deleted {
|
||||
broadcast_event(
|
||||
&self.hub,
|
||||
RedisEvent {
|
||||
message: RedisEventAction::Del,
|
||||
key: key.to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
match &self.backend {
|
||||
DbBackend::Memory { db, hub } => {
|
||||
let deleted = memory_delete(db, key, mode).await?;
|
||||
if deleted {
|
||||
broadcast_event(
|
||||
hub,
|
||||
RedisEvent {
|
||||
message: RedisEventAction::Del,
|
||||
key: key.to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
DbBackend::Redis(conn) => {
|
||||
let mut c = conn.clone();
|
||||
redis_delete(&mut c, key, mode).await
|
||||
}
|
||||
return Ok(deleted);
|
||||
}
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
{
|
||||
let mut c = self.db.clone();
|
||||
redis_delete(&mut c, key, mode).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hub_service::HubState;
|
||||
use crate::memory::MemoryBackend;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
fn memory_db() -> Db {
|
||||
let hub = Arc::new(RwLock::new(HubState::default()));
|
||||
let backend = MemoryBackend::new();
|
||||
Db::new_memory(backend, hub)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_db_mode_and_crud_work() {
|
||||
let db = memory_db();
|
||||
assert_eq!(db.mode(), "memory");
|
||||
|
||||
db.save("workspace/tests/key1", b"hello", Some(Ttl::Sec(60)), None)
|
||||
.await
|
||||
.expect("save should succeed");
|
||||
|
||||
let item = db
|
||||
.read("workspace/tests/key1")
|
||||
.await
|
||||
.expect("read should succeed")
|
||||
.expect("key should exist");
|
||||
assert_eq!(item.data, "hello");
|
||||
|
||||
let list = db
|
||||
.list("workspace/tests/")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].key, "workspace/tests/key1");
|
||||
|
||||
let deleted = db
|
||||
.delete("workspace/tests/key1", None)
|
||||
.await
|
||||
.expect("delete should succeed");
|
||||
assert!(deleted);
|
||||
assert!(
|
||||
db.read("workspace/tests/key1")
|
||||
.await
|
||||
.expect("read should succeed")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_db_status_reports_memory_backend() {
|
||||
let hub = Arc::new(RwLock::new(HubState::default()));
|
||||
let db = Db::new_memory(MemoryBackend::new(), hub.clone());
|
||||
|
||||
let info = hub.read().await.info_json(&db).await;
|
||||
assert_eq!(info["backend"], "memory");
|
||||
assert_eq!(info["status"], "OK");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +35,19 @@ use crate::workspace_owner::test_rego_http;
|
||||
pub fn map_redis_error(err: impl std::fmt::Display) -> Error {
|
||||
let msg = err.to_string();
|
||||
|
||||
if let Some(detail) = msg.split(" - ExtensionError: ").nth(1) {
|
||||
if let Some((code, text)) = detail.split_once(": ") {
|
||||
let text = format!("{} {}", code, text);
|
||||
return match code {
|
||||
"400" => actix_web::error::ErrorBadRequest(text),
|
||||
"404" => actix_web::error::ErrorNotFound(text),
|
||||
"412" => actix_web::error::ErrorPreconditionFailed(text),
|
||||
"500" => actix_web::error::ErrorInternalServerError(text),
|
||||
_ => actix_web::error::ErrorInternalServerError("unexpected error"),
|
||||
};
|
||||
}
|
||||
let detail = msg
|
||||
.split(" - ExtensionError: ")
|
||||
.nth(1)
|
||||
.unwrap_or(msg.as_str());
|
||||
if let Some((code, text)) = detail.split_once(": ") {
|
||||
let text = format!("{} {}", code, text);
|
||||
return match code {
|
||||
"400" => actix_web::error::ErrorBadRequest(text),
|
||||
"404" => actix_web::error::ErrorNotFound(text),
|
||||
"412" => actix_web::error::ErrorPreconditionFailed(text),
|
||||
"500" => actix_web::error::ErrorInternalServerError(text),
|
||||
_ => actix_web::error::ErrorInternalServerError("unexpected error"),
|
||||
};
|
||||
}
|
||||
actix_web::error::ErrorInternalServerError("internal error")
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{BACKEND, db::Db};
|
||||
use crate::db::Db;
|
||||
|
||||
fn subscription_matches(sub_key: &str, key: &str) -> bool {
|
||||
if sub_key == key {
|
||||
@@ -54,10 +54,8 @@ pub fn new_session_id() -> SessionId {
|
||||
pub enum RedisEventAction {
|
||||
Set,
|
||||
Del,
|
||||
#[cfg(feature = "db-redis")]
|
||||
Unlink,
|
||||
Expired,
|
||||
#[cfg(feature = "db-redis")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -171,7 +169,7 @@ impl HubState {
|
||||
let info = db.info().await.unwrap_or_else(|_| "error".to_string());
|
||||
json!({
|
||||
"memory_info": info,
|
||||
"backend": BACKEND,
|
||||
"backend": db.mode(),
|
||||
"websockets": self.sessions.len(),
|
||||
"subscriptions": self.subs.len(),
|
||||
"heartbeats": self.heartbeats.len(),
|
||||
|
||||
@@ -19,6 +19,8 @@ use actix_web::{
|
||||
middleware::{self},
|
||||
web::{self},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[cfg(feature = "auth")]
|
||||
use actix_web::{
|
||||
@@ -45,7 +47,7 @@ mod config;
|
||||
mod handlers_http;
|
||||
mod handlers_ws;
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
mod memory;
|
||||
mod redis;
|
||||
|
||||
#[cfg(feature = "auth")]
|
||||
@@ -57,20 +59,12 @@ use hub_service::HubState;
|
||||
use config::CONFIG;
|
||||
|
||||
mod db;
|
||||
use crate::config::BackendType;
|
||||
use crate::db::Db;
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
mod memory;
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
use crate::memory::MemoryBackend;
|
||||
|
||||
use crate::hub_service::check_heartbeat;
|
||||
|
||||
#[cfg(feature = "db-redis")]
|
||||
pub const BACKEND: &str = "REDIS";
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
pub const BACKEND: &str = "MEMORY";
|
||||
|
||||
fn initialize_tracing() {
|
||||
use tracing_subscriber::{filter::targets::Targets, prelude::*};
|
||||
|
||||
@@ -147,9 +141,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
// starting heartbeat checker
|
||||
check_heartbeat(hub_state.clone());
|
||||
|
||||
let db_backend = {
|
||||
#[cfg(feature = "db-redis")]
|
||||
{
|
||||
let db_backend = match &CONFIG.backend {
|
||||
BackendType::Redis => {
|
||||
let redis_client = redis::client().await?;
|
||||
let db_connection = redis_client
|
||||
.get_multiplexed_async_connection()
|
||||
@@ -166,19 +159,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
e
|
||||
})?;
|
||||
tokio::spawn(crate::redis::receiver(redis_client, hub_state.clone()));
|
||||
Db::new_db(db_connection)
|
||||
tokio::spawn({
|
||||
let hub_state = hub_state.clone();
|
||||
async move {
|
||||
if let Err(err) = crate::redis::receiver(redis_client, hub_state).await {
|
||||
tracing::error!("Redis receiver stopped: {err}");
|
||||
}
|
||||
}
|
||||
});
|
||||
Db::new_redis(db_connection)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "db-redis"))]
|
||||
{
|
||||
BackendType::Memory => {
|
||||
let db_connection = MemoryBackend::new();
|
||||
db_connection.spawn_ticker(hub_state.clone());
|
||||
Db::new_db(db_connection, hub_state.clone())
|
||||
Db::new_memory(db_connection, hub_state.clone())
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("DB mode: {}", BACKEND);
|
||||
tracing::info!("DB mode: {}", db_backend.mode());
|
||||
|
||||
let socket = std::net::SocketAddr::new(CONFIG.bind_host.as_str().parse()?, CONFIG.bind_port);
|
||||
|
||||
@@ -192,9 +190,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
tracing::info!("Status: {}/status", &url);
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let server = HttpServer::new(move || {
|
||||
let cors = Cors::default()
|
||||
.allow_any_origin()
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use redis::{
|
||||
Client, ConnectionInfo, ProtocolVersion, RedisConnectionInfo, RedisResult, ToRedisArgs,
|
||||
Client, ConnectionInfo, ProtocolVersion, RedisConnectionInfo, ToRedisArgs,
|
||||
aio::MultiplexedConnection,
|
||||
};
|
||||
// use serde::Serialize;
|
||||
@@ -61,7 +61,7 @@ pub async fn push_event(
|
||||
}
|
||||
|
||||
/// redis_info(&connection)
|
||||
pub async fn redis_info(conn: &mut MultiplexedConnection) -> redis::RedisResult<String> {
|
||||
pub async fn redis_info(conn: &mut MultiplexedConnection) -> DbResult<String> {
|
||||
let info: String = redis::cmd("INFO").query_async(conn).await?;
|
||||
|
||||
let mut redis_keys: Option<usize> = None;
|
||||
@@ -91,10 +91,7 @@ pub async fn redis_info(conn: &mut MultiplexedConnection) -> redis::RedisResult<
|
||||
}
|
||||
|
||||
/// redis_list(&connection,prefix)
|
||||
pub async fn redis_list(
|
||||
conn: &mut MultiplexedConnection,
|
||||
key: &str,
|
||||
) -> redis::RedisResult<Vec<DbArray>> {
|
||||
pub async fn redis_list(conn: &mut MultiplexedConnection, key: &str) -> DbResult<Vec<DbArray>> {
|
||||
deprecated_symbol_error(key)?;
|
||||
if !key.ends_with('/') {
|
||||
return error(412, "Key must end with slash");
|
||||
@@ -146,10 +143,7 @@ pub async fn redis_list(
|
||||
}
|
||||
|
||||
/// redis_read(&connection,key)
|
||||
pub async fn redis_read(
|
||||
conn: &mut MultiplexedConnection,
|
||||
key: &str,
|
||||
) -> redis::RedisResult<Option<DbArray>> {
|
||||
pub async fn redis_read(conn: &mut MultiplexedConnection, key: &str) -> DbResult<Option<DbArray>> {
|
||||
deprecated_symbol_error(key)?;
|
||||
|
||||
if key.ends_with('/') {
|
||||
@@ -306,7 +300,7 @@ pub async fn redis_delete(
|
||||
conn: &mut MultiplexedConnection,
|
||||
key: &str,
|
||||
mode: Option<SaveMode>,
|
||||
) -> RedisResult<bool> {
|
||||
) -> DbResult<bool> {
|
||||
deprecated_symbol_error(key)?;
|
||||
|
||||
if key.ends_with('/') {
|
||||
@@ -433,7 +427,7 @@ pub async fn receiver(
|
||||
while let Some(message) = messages.next().await {
|
||||
match RedisEvent::try_from(message) {
|
||||
Ok(ev) => {
|
||||
push_event(&hub_state, &mut redis, ev); // .await;
|
||||
push_event(&hub_state, &mut redis, ev).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("invalid redis message: {e}");
|
||||
|
||||
@@ -31,7 +31,12 @@ async fn status(base: &str, client: &reqwest::Client) -> () {
|
||||
let text = resp.text().await.unwrap();
|
||||
let json: Value = serde_json::from_str(&text).unwrap();
|
||||
|
||||
assert_eq!(json["backend"], "memory");
|
||||
let backend = json["backend"].as_str().unwrap_or_default();
|
||||
if let Ok(expected_backend) = env::var("TEST_BACKEND") {
|
||||
assert_eq!(backend, expected_backend);
|
||||
} else {
|
||||
assert!(backend == "memory" || backend == "redis");
|
||||
}
|
||||
assert_eq!(json["status"], "OK");
|
||||
assert!(json.get("memory_info").is_some());
|
||||
assert!(json.get("websockets").is_some());
|
||||
|
||||
@@ -459,6 +459,26 @@ export function createModel (builder: Builder): void {
|
||||
card.ids.CardNotificationGroup
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
notification.class.NotificationType,
|
||||
core.space.Model,
|
||||
{
|
||||
hidden: false,
|
||||
generated: false,
|
||||
label: card.string.CardCreated,
|
||||
group: card.ids.CardNotificationGroup,
|
||||
txClasses: [core.class.TxCreateDoc],
|
||||
objectClass: card.class.Card,
|
||||
defaultEnabled: true,
|
||||
templates: {
|
||||
textTemplate: '{body}',
|
||||
htmlTemplate: '<p>{body}</p><p>{link}</p>',
|
||||
subjectTemplate: '{title} created'
|
||||
}
|
||||
},
|
||||
card.ids.CardCreateNotification
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
notification.class.NotificationType,
|
||||
core.space.Model,
|
||||
|
||||
@@ -55,6 +55,7 @@ export default mergeIds(cardId, card, {
|
||||
ManageMasterTags: '' as Ref<Doc>,
|
||||
TagRelations: '' as Ref<Doc>,
|
||||
CardNotificationGroup: '' as Ref<NotificationGroup>,
|
||||
CardCreateNotification: '' as Ref<NotificationType>,
|
||||
CardNotification: '' as Ref<NotificationType>,
|
||||
CardMessageNotification: '' as Ref<NotificationType>
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({
|
||||
'kind',
|
||||
'assignee',
|
||||
'priority',
|
||||
'space',
|
||||
'component',
|
||||
'milestone',
|
||||
'createdBy',
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Které vztahy chcete zkopírovat?",
|
||||
"Import": "Importovat",
|
||||
"Export": "Exportovat",
|
||||
"CardUpdated": "Karta aktualizována"
|
||||
"CardUpdated": "Karta aktualizována",
|
||||
"CardCreated": "Karta vytvořena"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Welche Beziehungen möchten Sie kopieren?",
|
||||
"Import": "Importieren",
|
||||
"Export": "Exportieren",
|
||||
"CardUpdated": "Karte aktualisiert"
|
||||
"CardUpdated": "Karte aktualisiert",
|
||||
"CardCreated": "Karte erstellt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Which relations do you want to copy?",
|
||||
"Import": "Import",
|
||||
"Export": "Export",
|
||||
"CardUpdated": "Card updated"
|
||||
"CardUpdated": "Card updated",
|
||||
"CardCreated": "Card created"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "¿Qué relaciones quieres copiar?",
|
||||
"Import": "Importar",
|
||||
"Export": "Exportar",
|
||||
"CardUpdated": "Tarjeta actualizada"
|
||||
"CardUpdated": "Tarjeta actualizada",
|
||||
"CardCreated": "Tarjeta creada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Quelles relations souhaitez-vous copier ?",
|
||||
"Import": "Importer",
|
||||
"Export": "Exporter",
|
||||
"CardUpdated": "Carte mise à jour"
|
||||
"CardUpdated": "Carte mise à jour",
|
||||
"CardCreated": "Carte créée"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Quali relazioni vuoi copiare?",
|
||||
"Import": "Importa",
|
||||
"Export": "Esporta",
|
||||
"CardUpdated": "Scheda aggiornata"
|
||||
"CardUpdated": "Scheda aggiornata",
|
||||
"CardCreated": "Scheda creata"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "どの関連をコピーしますか?",
|
||||
"Import": "インポート",
|
||||
"Export": "エクスポート",
|
||||
"CardUpdated": "カードが更新されました"
|
||||
"CardUpdated": "カードが更新されました",
|
||||
"CardCreated": "カードが作成されました"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Quais relações você deseja copiar?",
|
||||
"Import": "Importar",
|
||||
"Export": "Exportar",
|
||||
"CardUpdated": "Cartão atualizado"
|
||||
"CardUpdated": "Cartão atualizado",
|
||||
"CardCreated": "Cartão criado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Quais relações você deseja copiar?",
|
||||
"Import": "Importar",
|
||||
"Export": "Exportar",
|
||||
"CardUpdated": "Cartão atualizado"
|
||||
"CardUpdated": "Cartão atualizado",
|
||||
"CardCreated": "Cartão criado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Какие связи вы хотите скопировать?",
|
||||
"Import": "Импортировать",
|
||||
"Export": "Экспортировать",
|
||||
"CardUpdated": "Карточка обновлена"
|
||||
"CardUpdated": "Карточка обновлена",
|
||||
"CardCreated": "Карточка создана"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "Hangi ilişkileri kopyalamak istiyorsunuz?",
|
||||
"Import": "İçe aktar",
|
||||
"Export": "Dışa aktar",
|
||||
"CardUpdated": "Kart güncellendi"
|
||||
"CardUpdated": "Kart güncellendi",
|
||||
"CardCreated": "Kart oluşturuldu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"RelationCopyDescr": "您想复制哪些关系?",
|
||||
"Import": "导入",
|
||||
"Export": "导出",
|
||||
"CardUpdated": "卡片已更新"
|
||||
"CardUpdated": "卡片已更新",
|
||||
"CardCreated": "卡片已创建"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ export default mergeIds(cardId, card, {
|
||||
ForbidCreateCardPermission: '' as IntlString,
|
||||
ForbidAddTagPermission: '' as IntlString,
|
||||
ForbidRemoveTag: '' as IntlString,
|
||||
CardUpdated: '' as IntlString
|
||||
CardUpdated: '' as IntlString,
|
||||
CardCreated: '' as IntlString
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use it except in compliance with the License. You may obtain
|
||||
// you may not use this file except in compliance with the License. You may obtain
|
||||
// a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Class, Doc, DocumentQuery, Ref } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Button, IconCopy } from '@hcengineering/ui'
|
||||
import { ButtonMenu, IconCopy, IconMoreH, type DropdownIntlItem } from '@hcengineering/ui'
|
||||
import view from '@hcengineering/view'
|
||||
import { viewletContextStore } from '@hcengineering/view-resources'
|
||||
import { copyAsMarkdownTableFromResource, copyRelationshipTableAsMarkdown } from '../markdown/copyActions'
|
||||
@@ -17,13 +17,23 @@
|
||||
export let query: DocumentQuery<Doc> = {}
|
||||
export let config: Array<string | import('@hcengineering/view').BuildModelKey> = []
|
||||
|
||||
// TODO: Register actions separately and make common extension for viewlet actions
|
||||
const COPY_ALL_ACTION_ID = 'copy-all'
|
||||
|
||||
$: ctx = $viewletContextStore?.getLastContext()
|
||||
$: relationshipTableData = ctx?.relationshipTableData
|
||||
$: viewlet = ctx?.viewlet
|
||||
|
||||
$: hasData = relationshipTableData !== undefined || (_class !== undefined && query !== undefined)
|
||||
$: actions = [
|
||||
{
|
||||
id: COPY_ALL_ACTION_ID,
|
||||
label: view.string.CopyAll,
|
||||
icon: IconCopy
|
||||
}
|
||||
] satisfies DropdownIntlItem[]
|
||||
|
||||
async function handleClick (e: MouseEvent): Promise<void> {
|
||||
async function handleCopyAll (e: Event): Promise<void> {
|
||||
if (relationshipTableData !== undefined) {
|
||||
await copyRelationshipTableAsMarkdown(e, relationshipTableData)
|
||||
} else if (_class !== undefined && query !== undefined) {
|
||||
@@ -38,9 +48,16 @@
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function onActionSelected (event?: CustomEvent): Promise<void> {
|
||||
if (event == null || event.detail !== COPY_ALL_ACTION_ID) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleCopyAll(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if hasData}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<Button icon={IconCopy} label={view.string.CopyAll} on:click={handleClick} />
|
||||
<ButtonMenu size="small" noSelection icon={IconMoreH} items={actions} on:selected={onActionSelected} />
|
||||
{/if}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/**
|
||||
* Tests for the token guard fix in SignupForm.svelte (issue #10518).
|
||||
*
|
||||
* When MAIL_URL is configured the account service returns token: undefined
|
||||
* to force email confirmation. The signup handler must skip logIn() in that
|
||||
* case — calling logIn() without a token triggers PUT /cookie with no
|
||||
* Authorization header, which returns 401 and crashes the client's JSON
|
||||
* parser with "Unexpected token".
|
||||
*/
|
||||
|
||||
interface LoginInfo {
|
||||
account: string
|
||||
name?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the fixed logic from SignupForm.svelte:
|
||||
*
|
||||
* if (result != null) {
|
||||
* if (result.token != null) {
|
||||
* await logIn(result)
|
||||
* }
|
||||
* goTo('confirmationSend')
|
||||
* }
|
||||
*/
|
||||
async function handleSignupResult (
|
||||
result: LoginInfo | null,
|
||||
logIn: (info: LoginInfo) => Promise<void>,
|
||||
goTo: (page: string) => void
|
||||
): Promise<void> {
|
||||
if (result != null) {
|
||||
if (result.token != null) {
|
||||
await logIn(result)
|
||||
}
|
||||
goTo('confirmationSend')
|
||||
}
|
||||
}
|
||||
|
||||
describe('SignupForm token guard (issue #10518)', () => {
|
||||
let logIn: jest.Mock
|
||||
let goTo: jest.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
logIn = jest.fn().mockResolvedValue(undefined)
|
||||
goTo = jest.fn()
|
||||
})
|
||||
|
||||
it('skips logIn and redirects to confirmationSend when token is undefined (MAIL_URL configured)', async () => {
|
||||
// Server returns token: undefined when email confirmation is required
|
||||
const result: LoginInfo = { account: 'acc-uuid', name: 'Alice Smith', token: undefined }
|
||||
|
||||
await handleSignupResult(result, logIn, goTo)
|
||||
|
||||
expect(logIn).not.toHaveBeenCalled()
|
||||
expect(goTo).toHaveBeenCalledWith('confirmationSend')
|
||||
})
|
||||
|
||||
it('skips logIn and redirects to confirmationSend when token is absent', async () => {
|
||||
const result: LoginInfo = { account: 'acc-uuid' }
|
||||
|
||||
await handleSignupResult(result, logIn, goTo)
|
||||
|
||||
expect(logIn).not.toHaveBeenCalled()
|
||||
expect(goTo).toHaveBeenCalledWith('confirmationSend')
|
||||
})
|
||||
|
||||
it('calls logIn then redirects to confirmationSend when token is present (no MAIL_URL)', async () => {
|
||||
const result: LoginInfo = { account: 'acc-uuid', name: 'Bob Jones', token: 'eyJhbGciOiJIUzI1NiJ9.test' }
|
||||
|
||||
await handleSignupResult(result, logIn, goTo)
|
||||
|
||||
expect(logIn).toHaveBeenCalledTimes(1)
|
||||
expect(logIn).toHaveBeenCalledWith(result)
|
||||
expect(goTo).toHaveBeenCalledWith('confirmationSend')
|
||||
})
|
||||
|
||||
it('calls neither logIn nor goTo when result is null (signup error)', async () => {
|
||||
await handleSignupResult(null, logIn, goTo)
|
||||
|
||||
expect(logIn).not.toHaveBeenCalled()
|
||||
expect(goTo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -95,9 +95,7 @@
|
||||
status = loginStatus
|
||||
|
||||
if (result != null) {
|
||||
if (result.token != null) {
|
||||
await logIn(result)
|
||||
}
|
||||
await logIn(result)
|
||||
goTo('confirmationSend')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@
|
||||
const client = getClient()
|
||||
const h = client.getHierarchy()
|
||||
|
||||
const values: Record<ContextId, any> = {}
|
||||
let values: Record<ContextId, any> = {}
|
||||
|
||||
results.forEach((r) => {
|
||||
values[r._id] = context[r._id]
|
||||
values = values
|
||||
})
|
||||
|
||||
export function canClose (): boolean {
|
||||
@@ -43,6 +44,7 @@
|
||||
function getOnChange (id: ContextId): (val: any) => void {
|
||||
return (val: any) => {
|
||||
values[id] = val
|
||||
values = values
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -51,7 +53,7 @@
|
||||
width={'small'}
|
||||
on:close
|
||||
label={plugin.string.Result}
|
||||
canSave={Object.keys(values).length === results.length}
|
||||
canSave={Object.values(values).filter((v) => v != null).length === results.length}
|
||||
okAction={save}
|
||||
hideClose
|
||||
okLabel={presentation.string.Save}
|
||||
|
||||
@@ -60,9 +60,7 @@
|
||||
|
||||
$: selected = _id !== undefined ? items.find((it) => it.id === _id)?.id : undefined
|
||||
|
||||
$: processes = client
|
||||
.getModel()
|
||||
.findAllSync(plugin.class.Process, { masterTag: { $in: ancestors }, _id: { $ne: process._id } })
|
||||
$: processes = client.getModel().findAllSync(plugin.class.Process, { masterTag: { $in: ancestors } })
|
||||
|
||||
function changeThis (): void {
|
||||
step.params = {}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
hulykvs hardcoreeng/service_hulykvs:0.2.1
|
||||
hulykvs hardcoreeng/service_hulykvs:0.3.1
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
hulypulse hardcoreeng/service_hulypulse:0.1.29
|
||||
hulypulse hardcoreeng/service_hulypulse:0.4.1
|
||||
@@ -20,6 +20,7 @@ import core, {
|
||||
Class,
|
||||
Data,
|
||||
Doc,
|
||||
fillDefaults,
|
||||
findProperty,
|
||||
generateId,
|
||||
getObjectValue,
|
||||
@@ -110,6 +111,7 @@ export async function CheckSubProcessMatch (
|
||||
|
||||
const subExecutions = await control.client.findAll(process.class.Execution, {
|
||||
parentId: execution._id,
|
||||
status: { $ne: ExecutionStatus.Cancelled },
|
||||
process: targetProcess
|
||||
})
|
||||
|
||||
@@ -315,19 +317,33 @@ export async function AddTag (
|
||||
const res: Tx[] = []
|
||||
const _process = control.client.getModel().findObject(execution.process)
|
||||
if (_process === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.process })
|
||||
// todo fill default for tag and set parent tags
|
||||
const tx = control.client.txFactory.createTxMixin(execution.card, _process.masterTag, execution.space, tagId, props)
|
||||
res.push(tx)
|
||||
const card = control.cache.get(execution.card)
|
||||
if (card === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.card })
|
||||
if (control.client.getHierarchy().hasMixin(card, tagId)) {
|
||||
return { txes: res, rollback: [], context: null }
|
||||
}
|
||||
const cardWithMixin =
|
||||
card !== undefined ? TxProcessor.updateMixin4Doc(control.client.getHierarchy().clone(card), tx) : undefined
|
||||
|
||||
const rollback = control.client.txFactory.createTxUpdateDoc(_process.masterTag, execution.space, execution.card, {
|
||||
$unset: { [tagId]: true }
|
||||
})
|
||||
const rollback: Tx[] = [
|
||||
control.client.txFactory.createTxUpdateDoc(_process.masterTag, execution.space, execution.card, {
|
||||
$unset: { [tagId]: true }
|
||||
})
|
||||
]
|
||||
|
||||
const processes = control.client.getModel().findAllSync(process.class.Process, { masterTag: tagId, autoStart: true })
|
||||
for (const proc of processes) {
|
||||
const [txes, rbTxes] = await createExecution(proc._id, execution.card, execution, control)
|
||||
res.push(...txes)
|
||||
rollback.push(...rbTxes)
|
||||
}
|
||||
|
||||
return {
|
||||
txes: res,
|
||||
rollback: [rollback],
|
||||
rollback,
|
||||
context: [
|
||||
{
|
||||
_id: execution.card,
|
||||
@@ -382,6 +398,9 @@ export async function RunSubProcess (
|
||||
const res: Tx[] = []
|
||||
const resultContext: SuccessExecutionContext[] = []
|
||||
const rollback: Tx[] = []
|
||||
const initTransition = control.client
|
||||
.getModel()
|
||||
.findAllSync(process.class.Transition, { process: target._id, from: null })[0]
|
||||
for (const _card of Array.isArray(card) ? card : [card]) {
|
||||
if (target.parallelExecutionForbidden === true) {
|
||||
const currentExecution = await control.client.findAll(process.class.Execution, {
|
||||
@@ -394,9 +413,13 @@ export async function RunSubProcess (
|
||||
continue
|
||||
}
|
||||
}
|
||||
const initTransition = control.client
|
||||
.getModel()
|
||||
.findAllSync(process.class.Transition, { process: target._id, from: null })[0]
|
||||
|
||||
// check card is exists
|
||||
const exists = await control.client.findOne(cardPlugin.class.Card, { _id: _card })
|
||||
if (exists === undefined) {
|
||||
throw processError(process.error.ObjectNotFound, { _id: _card })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const context = params.context ?? ({} as ExecutionContext)
|
||||
const _id = generateId<Execution>()
|
||||
@@ -704,11 +727,10 @@ export async function CreateCard (
|
||||
throw processError(process.error.RequiredParamsNotProvided, { params: key })
|
||||
}
|
||||
}
|
||||
const masterTag = _class as Ref<MasterTag>
|
||||
const _id = generateId<Card>()
|
||||
const newContent =
|
||||
content !== undefined && !isEmpty(content)
|
||||
? await getContent(control, content, _id, _class as Ref<Class<Card>>)
|
||||
: content
|
||||
content !== undefined && !isEmpty(content) ? await getContent(control, content, _id, masterTag) : content
|
||||
const data = {
|
||||
title,
|
||||
...attrs
|
||||
@@ -716,9 +738,26 @@ export async function CreateCard (
|
||||
if (newContent !== undefined) {
|
||||
data.content = content
|
||||
}
|
||||
const tx = control.client.txFactory.createTxCreateDoc(_class as Ref<MasterTag>, execution.space, data, _id)
|
||||
const filledData = fillDefaults(control.client.getHierarchy(), data, masterTag)
|
||||
|
||||
const tx = control.client.txFactory.createTxCreateDoc(masterTag, execution.space, filledData, _id)
|
||||
const res: Tx[] = [tx]
|
||||
const rollback: Tx[] = [control.client.txFactory.createTxRemoveDoc(_class as Ref<MasterTag>, execution.space, _id)]
|
||||
const rollback: Tx[] = [control.client.txFactory.createTxRemoveDoc(masterTag, execution.space, _id)]
|
||||
|
||||
const ancestors = control.client
|
||||
.getHierarchy()
|
||||
.getAncestors(masterTag)
|
||||
.filter((p) => control.client.getHierarchy().isDerived(p, cardPlugin.class.Card))
|
||||
|
||||
const processes = control.client.getModel().findAllSync(process.class.Process, {
|
||||
masterTag: { $in: ancestors },
|
||||
autoStart: true
|
||||
})
|
||||
for (const proc of processes) {
|
||||
const [txes, rbTxes] = await createExecution(proc._id, _id, execution, control)
|
||||
res.push(...txes)
|
||||
rollback.push(...rbTxes)
|
||||
}
|
||||
return {
|
||||
txes: res,
|
||||
rollback,
|
||||
@@ -734,3 +773,36 @@ export async function CreateCard (
|
||||
function isEmpty (value: any): boolean {
|
||||
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '')
|
||||
}
|
||||
|
||||
async function createExecution (
|
||||
proc: Ref<Process>,
|
||||
_id: Ref<Card>,
|
||||
execution: Execution,
|
||||
control: ProcessControl
|
||||
): Promise<[Tx[], Tx[]]> {
|
||||
const res: Tx[] = []
|
||||
const rollback: Tx[] = []
|
||||
const initTransition = control.client.getModel().findAllSync(process.class.Transition, {
|
||||
process: proc,
|
||||
from: null
|
||||
})[0]
|
||||
if (initTransition === undefined) return [res, rollback]
|
||||
const execId = generateId()
|
||||
const tx = control.client.txFactory.createTxCreateDoc(
|
||||
process.class.Execution,
|
||||
execution.space,
|
||||
{
|
||||
process: proc,
|
||||
currentState: initTransition.to,
|
||||
card: _id,
|
||||
rollback: [],
|
||||
context: {},
|
||||
status: ExecutionStatus.Active
|
||||
},
|
||||
execId
|
||||
)
|
||||
|
||||
res.push(tx)
|
||||
rollback.push(control.client.txFactory.createTxRemoveDoc(process.class.Execution, execution.space, execId))
|
||||
return [res, rollback]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Random (value: Doc[]): Doc {
|
||||
}
|
||||
|
||||
export function All (value: Doc[]): Doc[] {
|
||||
return value
|
||||
return value ?? []
|
||||
}
|
||||
|
||||
export async function FirstMatchValue (
|
||||
|
||||
@@ -178,11 +178,14 @@ async function getRelationValue (
|
||||
const q = context.direction === 'A' ? { docB: execution.card } : { docA: execution.card }
|
||||
const relations = await control.client.findAll(core.class.Relation, { association: assoc._id, ...q })
|
||||
const name = context.direction === 'A' ? assoc.nameA : assoc.nameB
|
||||
if (relations.length === 0) throw processError(process.error.RelatedObjectNotFound, { attr: name })
|
||||
const shouldBeArray = assoc.type === 'N:N' || (assoc.type === '1:N' && context.direction === 'A')
|
||||
if (relations.length === 0) {
|
||||
if (shouldBeArray) return []
|
||||
throw processError(process.error.RelatedObjectNotFound, { attr: name })
|
||||
}
|
||||
const ids = relations.map((it) => {
|
||||
return context.direction === 'A' ? it.docA : it.docB
|
||||
})
|
||||
const shouldBeArray = assoc.type === 'N:N' || (assoc.type === '1:N' && context.direction === 'A')
|
||||
const target = await control.client.findAll(targetClass, { _id: { $in: ids } })
|
||||
if (target.length === 0) throw processError(process.error.RelatedObjectNotFound, { attr: context.name })
|
||||
const attr = context.key !== '' ? control.client.getHierarchy().findAttribute(targetClass, context.key) : undefined
|
||||
|
||||
@@ -306,8 +306,11 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap
|
||||
router.put('/cookie', async (ctx) => {
|
||||
const token = extractToken(ctx.request.headers)
|
||||
if (token === undefined) {
|
||||
ctx.status = 401
|
||||
ctx.body = { error: new Status(Severity.ERROR, platform.status.Unauthorized, {}) }
|
||||
ctx.body = JSON.stringify({
|
||||
error: new Status(Severity.ERROR, platform.status.Unauthorized, {})
|
||||
})
|
||||
ctx.res.writeHead(401)
|
||||
ctx.res.end()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user