mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6816b0aded | ||
|
|
ac02817d4e | ||
|
|
fe0a09fe1c | ||
|
|
2f00966f27 | ||
|
|
921057252d | ||
|
|
703c602527 | ||
|
|
0e29c9b9c6 | ||
|
|
f60c5859f9 | ||
|
|
ee54cc495b | ||
|
|
96582ecff4 | ||
|
|
3de82dfa41 | ||
|
|
d7ee4f6909 | ||
|
|
bf4bae9bf5 | ||
|
|
3d10dc6042 | ||
|
|
fef6d06a9d | ||
|
|
951b69a67f | ||
|
|
8a7b14c079 | ||
|
|
f30ec4645c | ||
|
|
c78472bd19 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test/testFixtures/** linguist-vendored
|
||||||
120
CLAUDE.md
Normal file
120
CLAUDE.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Fredy is a self-hosted real estate finder for Germany. It scrapes German real estate portals (ImmoScout24, Immowelt, Immonet, Kleinanzeigen, WG-Gesucht, etc.), deduplicates results across providers, and sends notifications via Slack, Telegram, Email, Discord, ntfy, etc. It includes a React web UI and a built-in MCP server for LLM access to listings data.
|
||||||
|
|
||||||
|
- Node.js >= 22, ESM-only (`"type": "module"`)
|
||||||
|
- Default port: 9998, default login: admin / admin
|
||||||
|
- SQLite via `better-sqlite3` (synchronous - all DB ops are sync; only network I/O is async)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
yarn run start:backend:dev # nodemon backend
|
||||||
|
yarn run start:frontend:dev # Vite dev server (proxies /api → :9998)
|
||||||
|
|
||||||
|
# Production
|
||||||
|
yarn run start:backend # NODE_ENV=production node index.js
|
||||||
|
yarn run build:frontend # vite build → ui/public/
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
yarn test # Live tests (hits actual providers)
|
||||||
|
yarn test:offline # Offline tests using HTML/JSON fixtures (fast, preferred)
|
||||||
|
yarn test:download-fixtures # Re-download fresh provider HTML fixtures
|
||||||
|
|
||||||
|
# Single test file
|
||||||
|
TEST_MODE=offline npx vitest run test/provider/immoscout.test.js
|
||||||
|
|
||||||
|
# Lint / Format
|
||||||
|
yarn lint && yarn lint:fix
|
||||||
|
yarn format && yarn format:check
|
||||||
|
|
||||||
|
# DB migrations
|
||||||
|
yarn migratedb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
index.js (startup)
|
||||||
|
├── runMigrations()
|
||||||
|
├── getProviders() # lazily imports lib/provider/*.js
|
||||||
|
├── similarityCache.init() # preloads hash cache from DB
|
||||||
|
├── api.js # starts restana HTTP server
|
||||||
|
└── initJobExecutionService() # registers event-bus listeners + starts scheduler
|
||||||
|
|
||||||
|
scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run
|
||||||
|
└── FredyPipelineExecutioner.execute()
|
||||||
|
1. queryStringMutator(url) # inject sort-by-date param
|
||||||
|
2. provider.getListings() # API or Puppeteer+Cheerio
|
||||||
|
3. provider.normalize(listing) # raw → ParsedListing
|
||||||
|
4. provider.filter(listing) # blacklist + required fields
|
||||||
|
5. filter to hashes not yet in DB
|
||||||
|
6. provider.fetchDetails() # optional enrichment
|
||||||
|
7. geocodeAddress() # optional lat/lng
|
||||||
|
8. storeListings()
|
||||||
|
9. similarityCache.checkAndAddEntry() # cross-provider dedup
|
||||||
|
10. _filterBySpecs() + _filterByArea()
|
||||||
|
11. notify.send() # fan-out to all adapters
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin systems
|
||||||
|
|
||||||
|
**Providers** (`lib/provider/*.js`) - each module exports:
|
||||||
|
- `metaInformation` - `{ id, name, baseUrl }`
|
||||||
|
- `config` - `ProviderConfig` with `requiredFieldNames`, `crawlContainer`, `crawlFields`, `sortByDateParam`, `normalize()`, `filter()`, optional `getListings()`, `fetchDetails()`, `activeTester()`
|
||||||
|
- `init(sourceConfig, blacklist)` - called before each job run; providers are **stateful modules** holding mutable `url` and `appliedBlackList` at module scope
|
||||||
|
|
||||||
|
**Notification adapters** (`lib/notification/adapter/*.js`) - each exports:
|
||||||
|
- `config` - `{ id, name, description, fields }` (drives the UI form)
|
||||||
|
- `send({ serviceName, newListings, notificationConfig, jobKey, baseUrl })`
|
||||||
|
- Loaded dynamically at startup via `fs.readdirSync`
|
||||||
|
|
||||||
|
### Key services
|
||||||
|
|
||||||
|
| Service | Location | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Event bus | `lib/services/events/event-bus.js` | Plain `EventEmitter`; events: `jobs:runAll`, `jobs:runOne`, `jobs:status` |
|
||||||
|
| SSE broker | `lib/services/sse/sse-broker.js` | Per-userId `Set<ServerResponse>`; heartbeat every 25s; pushes job status to UI |
|
||||||
|
| Similarity cache | `lib/services/similarity-check/` | In-memory SHA-256 Set; refreshes hourly; cross-provider dedup by title+price+address |
|
||||||
|
| SqliteConnection | `lib/services/storage/SqliteConnection.js` | Singleton, WAL mode; `execute()`, `query()`, `withTransaction()` |
|
||||||
|
| Migrations | `lib/services/storage/migrations/` | Numbered JS files each exporting `up(db)`; checksum-tracked in `schema_migrations` |
|
||||||
|
| Extractor | `lib/services/extractor/` | Orchestrates Puppeteer + Cheerio; shared browser instance per job |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- React 19 SPA, Vite build → `ui/public/` (served as static by backend)
|
||||||
|
- State: Zustand single store with per-domain slices
|
||||||
|
- UI library: `@douyinfe/semi-ui`
|
||||||
|
- Map: MapLibre GL + `@mapbox/mapbox-gl-draw` + `@turf/boolean-point-in-polygon` for GeoJSON polygon filters
|
||||||
|
- In dev: Vite proxies `/api` to `:9998`
|
||||||
|
|
||||||
|
### MCP server
|
||||||
|
|
||||||
|
Two transports:
|
||||||
|
1. **stdio** (`lib/mcp/stdio.js`) - for Claude Desktop/LM Studio; opens its own DB connection (main process need not be running)
|
||||||
|
2. **HTTP** (`/api/mcp`) - authenticated via Bearer token (`mcp_token` column in `users` table)
|
||||||
|
|
||||||
|
Tools: `list_jobs`, `get_job`, `list_listings`, `get_listing`, `get_current_date_time`. Responses are Markdown via `lib/mcp/mcpNormalizer.js`.
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **ESM only** - `import`/`export` everywhere, no CommonJS
|
||||||
|
- **JSDoc typedefs** (no TypeScript) in `lib/types/` - `listing.js`, `job.js`, `filter.js`, `providerConfig.js`
|
||||||
|
- **Copyright header** required on all `.js` files - enforced by `lint-staged` pre-commit hook via `copyright.js`
|
||||||
|
- **`NoNewListingsWarning`** (`lib/errors.js`) is used as control flow to short-circuit the pipeline (not an error)
|
||||||
|
- **Test fixtures** in `test/testFixtures/` - HTML/JSON snapshots per provider; `TEST_MODE=offline` mocks `puppeteerExtractor` and global `fetch` via `test/offlineFixtures.js`
|
||||||
|
- **`conf/config.json`** is the only runtime config file; created with defaults if missing
|
||||||
|
|
||||||
|
## Coding
|
||||||
|
- After building the task, run the linter
|
||||||
|
- After building the task, run the tests
|
||||||
|
- New features must be tested
|
||||||
|
- New features must be properly documented with JsDoc
|
||||||
|
- You do **not** commit any changes, you do **not** create a new branch unless I told you so
|
||||||
13
Dockerfile
13
Dockerfile
@@ -1,16 +1,15 @@
|
|||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
ARG TARGETARCH
|
# System deps for CloakBrowser + build tools for native modules (better-sqlite3)
|
||||||
|
# fonts-noto-color-emoji and fonts-freefont-ttf are required so canvas fingerprint
|
||||||
# System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
|
# hashes match real browsers; missing emoji fonts cause bot detection on Kasada/Akamai.
|
||||||
# On ARM64 we also install system Chromium (Chrome for Testing has no ARM64 binary)
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl ca-certificates fonts-liberation libasound2 \
|
curl ca-certificates fonts-liberation libasound2 \
|
||||||
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
|
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
|
||||||
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
|
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
|
||||||
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
|
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
|
||||||
|
fonts-noto-color-emoji fonts-freefont-ttf \
|
||||||
python3 make g++ \
|
python3 make g++ \
|
||||||
&& if [ "$TARGETARCH" = "arm64" ]; then apt-get install -y --no-install-recommends chromium; fi \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& mkdir -p /db /conf /fredy
|
&& mkdir -p /db /conf /fredy
|
||||||
|
|
||||||
@@ -26,8 +25,8 @@ RUN yarn config set network-timeout 600000 \
|
|||||||
&& yarn --frozen-lockfile \
|
&& yarn --frozen-lockfile \
|
||||||
&& yarn cache clean
|
&& yarn cache clean
|
||||||
|
|
||||||
# on arm64 use the system Chromium installed above
|
# Pre-download the CloakBrowser stealth Chromium binary (supports x86_64 and arm64)
|
||||||
RUN if [ "$TARGETARCH" != "arm64" ]; then npx puppeteer browsers install chrome; fi
|
RUN node -e "import('cloakbrowser').then(({ensureBinary}) => ensureBinary())"
|
||||||
|
|
||||||
# Purge build tools now that native modules are compiled
|
# Purge build tools now that native modules are compiled
|
||||||
RUN apt-get purge -y python3 make g++ \
|
RUN apt-get purge -y python3 make g++ \
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -240,6 +240,20 @@ flowchart TD
|
|||||||
F1 --> F2
|
F1 --> F2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
------------------------------------------------------------------------
|
||||||
|
## 🤖 Using AI such as Claude Code
|
||||||
|
When I started building Fredy, LLMs were still basically the wet dream of a few nerdy scientists.
|
||||||
|
|
||||||
|
Nowadays, it’s easier than ever to throw a prompt into the LLM of your choice and let 'the AI' build your stuff. I’m not against that. I use Claude Code myself for smaller tasks, and I do think these tools can be really useful.
|
||||||
|
|
||||||
|
That said, I still believe humans should stay in charge. AI is great-ish at writing code, but it still lacks creativity, context, and the ability to see the full picture.
|
||||||
|
|
||||||
|
So, if you want to contribute to Fredy, using AI tools to get things done is totally fine. Just please don’t stop thinking.
|
||||||
|
|
||||||
|
I’ve had one too many PRs full of hallucinated bullshit.
|
||||||
|
|
||||||
|
**Thanks ;)**
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
## 👐 Contributing
|
## 👐 Contributing
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ for i in $(seq 1 30); do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# Verify the DB is readable/writable via the API.
|
# Verify the DB is readable/writable via the API.
|
||||||
# /api/demo is unauthenticated and reads the settings table — if SQLite is broken this returns an error.
|
# /api/demo is unauthenticated and reads the settings table - if SQLite is broken this returns an error.
|
||||||
echo "Testing DB via API (/api/demo)..."
|
echo "Testing DB via API (/api/demo)..."
|
||||||
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
|
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
|
||||||
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
|
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
|
||||||
echo "DB is readable (got demoMode from /api/demo)"
|
echo "DB is readable (got demoMode from /api/demo)"
|
||||||
else
|
else
|
||||||
echo "DB check failed — unexpected response from /api/demo: $DEMO_RESPONSE"
|
echo "DB check failed - unexpected response from /api/demo: $DEMO_RESPONSE"
|
||||||
docker logs fredy
|
docker logs fredy
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
<title>Fredy || Real Estate Finder</title>
|
<title>Fredy || Real Estate Finder</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body theme-mode="dark">
|
<body theme-mode="dark">
|
||||||
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
||||||
|
|||||||
9
index.js
9
index.js
@@ -15,6 +15,15 @@ import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js';
|
|||||||
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||||
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
|
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
|
||||||
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
|
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
|
||||||
|
import { ensureValidBinary } from './lib/services/ensureValidBinary.js';
|
||||||
|
|
||||||
|
// Ensure the CloakBrowser stealth Chromium binary is present and complete before
|
||||||
|
// jobs run. ensureValidBinary() also detects and auto-heals partial extractions
|
||||||
|
// (e.g. a newer version that was downloaded but only the chrome executable was
|
||||||
|
// written) so Chrome never crashes with "Invalid file descriptor to ICU data".
|
||||||
|
logger.info('Checking CloakBrowser binary...');
|
||||||
|
await ensureValidBinary();
|
||||||
|
logger.info('CloakBrowser binary ready.');
|
||||||
|
|
||||||
//in the config, we store the path of the sqlite file, thus we must check if it is available
|
//in the config, we store the path of the sqlite file, thus we must check if it is available
|
||||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import urlModifier from './services/queryStringMutator.js';
|
|||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||||
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||||
import { getUserSettings } from './services/storage/settingsStorage.js';
|
import { getUserSettings, getSettings } from './services/storage/settingsStorage.js';
|
||||||
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
||||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||||
import { formatListing } from './utils/formatListing.js';
|
import { formatListing } from './utils/formatListing.js';
|
||||||
@@ -300,17 +300,19 @@ class FredyPipelineExecutioner {
|
|||||||
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
|
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
|
||||||
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
||||||
*/
|
*/
|
||||||
_notify(newListings) {
|
async _notify(newListings) {
|
||||||
if (newListings.length === 0) {
|
if (newListings.length === 0) {
|
||||||
throw new NoNewListingsWarning();
|
throw new NoNewListingsWarning();
|
||||||
}
|
}
|
||||||
// TODO: move this to the notification adapter, so it will handle for all providers in same way.
|
|
||||||
const formattedListings = newListings.map(formatListing);
|
const formattedListings = newListings.map(formatListing);
|
||||||
|
const settings = await getSettings();
|
||||||
|
const baseUrl = settings?.baseUrl ?? '';
|
||||||
const sendNotifications = notify.send(
|
const sendNotifications = notify.send(
|
||||||
this._providerId,
|
this._providerId,
|
||||||
formattedListings,
|
formattedListings,
|
||||||
this._jobNotificationConfig,
|
this._jobNotificationConfig,
|
||||||
this._jobKey,
|
this._jobKey,
|
||||||
|
baseUrl,
|
||||||
);
|
);
|
||||||
return Promise.all(sendNotifications).then(() => newListings);
|
return Promise.all(sendNotifications).then(() => newListings);
|
||||||
}
|
}
|
||||||
|
|||||||
142
lib/api/api.js
142
lib/api/api.js
@@ -3,64 +3,100 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
import Fastify from 'fastify';
|
||||||
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
|
import fastifyHelmet from '@fastify/helmet';
|
||||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import { providerRouter } from './routes/providerRouter.js';
|
import fastifySession from '@fastify/session';
|
||||||
import { versionRouter } from './routes/versionRouter.js';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { loginRouter } from './routes/loginRoute.js';
|
|
||||||
import { userRouter } from './routes/userRoute.js';
|
|
||||||
import { userSettingsRouter } from './routes/userSettingsRoute.js';
|
|
||||||
import { jobRouter } from './routes/jobRouter.js';
|
|
||||||
import bodyParser from 'body-parser';
|
|
||||||
import restana from 'restana';
|
|
||||||
import files from 'serve-static';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getDirName } from '../utils.js';
|
import { getDirName } from '../utils.js';
|
||||||
import { demoRouter } from './routes/demoRouter.js';
|
|
||||||
import logger from '../services/logger.js';
|
|
||||||
import { listingsRouter } from './routes/listingsRouter.js';
|
|
||||||
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
|
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
|
||||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
import logger from '../services/logger.js';
|
||||||
import { backupRouter } from './routes/backupRouter.js';
|
import { authHook, adminHook } from './security.js';
|
||||||
import { trackingRouter } from './routes/trackingRoute.js';
|
|
||||||
|
import loginPlugin from './routes/loginRoute.js';
|
||||||
|
import demoPlugin from './routes/demoRouter.js';
|
||||||
|
import jobPlugin from './routes/jobRouter.js';
|
||||||
|
import versionPlugin from './routes/versionRouter.js';
|
||||||
|
import listingsPlugin from './routes/listingsRouter.js';
|
||||||
|
import dashboardPlugin from './routes/dashboardRouter.js';
|
||||||
|
import userSettingsPlugin from './routes/userSettingsRoute.js';
|
||||||
|
import trackingPlugin from './routes/trackingRoute.js';
|
||||||
|
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
|
||||||
|
import backupPlugin from './routes/backupRouter.js';
|
||||||
|
import userPlugin from './routes/userRoute.js';
|
||||||
|
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
|
||||||
|
import providerPlugin from './routes/providerRouter.js';
|
||||||
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
|
||||||
const service = restana();
|
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
|
||||||
const PORT = (await getSettings()).port || 9998;
|
const PORT = (await getSettings()).port || 9998;
|
||||||
const sessionSecret = await getOrCreateSessionSecret();
|
const sessionSecret = await getOrCreateSessionSecret();
|
||||||
|
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
service.use(bodyParser.json());
|
const fastify = Fastify({
|
||||||
service.use(cookieSession(sessionSecret));
|
logger: false,
|
||||||
service.use(staticService);
|
bodyLimit: 50 * 1024 * 1024, // 50 MB for backup uploads
|
||||||
service.use('/api/admin', authInterceptor());
|
|
||||||
service.use('/api/jobs', authInterceptor());
|
|
||||||
service.use('/api/version', authInterceptor());
|
|
||||||
service.use('/api/listings', authInterceptor());
|
|
||||||
service.use('/api/dashboard', authInterceptor());
|
|
||||||
service.use('/api/user/settings', authInterceptor());
|
|
||||||
service.use('/api/tracking', authInterceptor());
|
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
|
||||||
service.use('/api/admin', adminInterceptor());
|
|
||||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
|
||||||
service.use('/api/admin/generalSettings', generalSettingsRouter);
|
|
||||||
service.use('/api/admin/backup', backupRouter);
|
|
||||||
service.use('/api/jobs/provider', providerRouter);
|
|
||||||
service.use('/api/admin/users', userRouter);
|
|
||||||
service.use('/api/user/settings', userSettingsRouter);
|
|
||||||
service.use('/api/version', versionRouter);
|
|
||||||
service.use('/api/jobs', jobRouter);
|
|
||||||
service.use('/api/login', loginRouter);
|
|
||||||
service.use('/api/listings', listingsRouter);
|
|
||||||
service.use('/api/dashboard', dashboardRouter);
|
|
||||||
service.use('/api/tracking', trackingRouter);
|
|
||||||
//this route is unsecured intentionally as it is being queried from the login page
|
|
||||||
service.use('/api/demo', demoRouter);
|
|
||||||
|
|
||||||
// MCP Streamable HTTP endpoint (secured via Bearer token, not cookie-session)
|
|
||||||
registerMcpRoutes(service);
|
|
||||||
|
|
||||||
service.start(PORT).then(() => {
|
|
||||||
logger.debug(`Started API service on port ${PORT}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security headers (CSP disabled to avoid breaking the SPA)
|
||||||
|
await fastify.register(fastifyHelmet, { contentSecurityPolicy: false });
|
||||||
|
|
||||||
|
// Cookie + session (in-memory store, signed cookie)
|
||||||
|
await fastify.register(fastifyCookie);
|
||||||
|
await fastify.register(fastifySession, {
|
||||||
|
secret: sessionSecret,
|
||||||
|
cookieName: 'fredy-admin-session',
|
||||||
|
cookie: {
|
||||||
|
maxAge: SESSION_MAX_AGE,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'lax',
|
||||||
|
},
|
||||||
|
saveUninitialized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve the React SPA from ui/public/
|
||||||
|
await fastify.register(fastifyStatic, {
|
||||||
|
root: path.join(getDirName(), '../ui/public'),
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public routes - no auth required
|
||||||
|
fastify.register(loginPlugin, { prefix: '/api/login' });
|
||||||
|
fastify.register(demoPlugin, { prefix: '/api/demo' });
|
||||||
|
|
||||||
|
// User-authenticated routes
|
||||||
|
fastify.register(async (app) => {
|
||||||
|
app.addHook('preHandler', authHook);
|
||||||
|
app.register(jobPlugin, { prefix: '/api/jobs' });
|
||||||
|
app.register(notificationAdapterPlugin, { prefix: '/api/jobs/notificationAdapter' });
|
||||||
|
app.register(providerPlugin, { prefix: '/api/jobs/provider' });
|
||||||
|
app.register(versionPlugin, { prefix: '/api/version' });
|
||||||
|
app.register(listingsPlugin, { prefix: '/api/listings' });
|
||||||
|
app.register(dashboardPlugin, { prefix: '/api/dashboard' });
|
||||||
|
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
|
||||||
|
app.register(trackingPlugin, { prefix: '/api/tracking' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
fastify.register(async (app) => {
|
||||||
|
app.addHook('preHandler', authHook);
|
||||||
|
app.addHook('preHandler', adminHook);
|
||||||
|
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
|
||||||
|
app.register(backupPlugin, { prefix: '/api/admin/backup' });
|
||||||
|
app.register(userPlugin, { prefix: '/api/admin/users' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// MCP Streamable HTTP (Bearer token auth - no session)
|
||||||
|
registerMcpRoutes(fastify);
|
||||||
|
|
||||||
|
// SPA fallback - serve index.html for all non-API GET requests
|
||||||
|
fastify.setNotFoundHandler((request, reply) => {
|
||||||
|
if (!request.url.startsWith('/api/')) {
|
||||||
|
return reply.sendFile('index.html');
|
||||||
|
}
|
||||||
|
return reply.code(404).send({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
||||||
|
logger.debug(`Started API service on port ${PORT}`);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import {
|
import {
|
||||||
buildBackupFileName,
|
buildBackupFileName,
|
||||||
createBackupZip,
|
createBackupZip,
|
||||||
@@ -12,64 +11,41 @@ import {
|
|||||||
} from '../../services/storage/backupRestoreService.js';
|
} from '../../services/storage/backupRestoreService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup & Restore Admin Router
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
*
|
|
||||||
* Endpoints:
|
|
||||||
* - GET /api/admin/backup
|
|
||||||
* Returns the current database as a zip download. Content-Type: application/zip
|
|
||||||
* - POST /api/admin/backup/restore?dryRun=true
|
|
||||||
* Accepts a zip file (raw body). Returns a compatibility report, does not restore.
|
|
||||||
* - POST /api/admin/backup/restore?force=true|false
|
|
||||||
* Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400.
|
|
||||||
*/
|
*/
|
||||||
const service = restana();
|
export default async function backupPlugin(fastify) {
|
||||||
const backupRouter = service.newRouter();
|
// Parse raw binary uploads as Buffer
|
||||||
|
fastify.addContentTypeParser(
|
||||||
|
['application/zip', 'application/octet-stream'],
|
||||||
|
{ parseAs: 'buffer' },
|
||||||
|
(req, body, done) => done(null, body),
|
||||||
|
);
|
||||||
|
|
||||||
backupRouter.get('/', async (req, res) => {
|
fastify.get('/', async (_request, reply) => {
|
||||||
const zipBuffer = await createBackupZip();
|
const zipBuffer = await createBackupZip();
|
||||||
const fileName = await buildBackupFileName();
|
const fileName = await buildBackupFileName();
|
||||||
res.setHeader('Content-Type', 'application/zip');
|
reply.header('Content-Type', 'application/zip');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
res.send(zipBuffer);
|
return reply.send(zipBuffer);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
fastify.post('/restore', async (request, reply) => {
|
||||||
* Read the full request body as a Buffer. Used for raw zip uploads.
|
const { dryRun = 'false', force = 'false' } = request.query || {};
|
||||||
* @param {import('http').IncomingMessage} req
|
const doDryRun = String(dryRun) === 'true';
|
||||||
* @returns {Promise<Buffer>}
|
const doForce = String(force) === 'true';
|
||||||
*/
|
const body = request.body; // Buffer from addContentTypeParser
|
||||||
function readBody(req) {
|
|
||||||
return new Promise((resolve, reject) => {
|
if (doDryRun) {
|
||||||
const chunks = [];
|
return precheckRestore(body);
|
||||||
req.on('data', (c) => chunks.push(c));
|
}
|
||||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
||||||
req.on('error', (e) => reject(e));
|
try {
|
||||||
|
return restoreFromZip(body, { force: doForce });
|
||||||
|
} catch (e) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: e?.message || 'Restore failed',
|
||||||
|
details: e?.payload || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload endpoint. Accepts raw zip (Content-Type: application/zip or application/octet-stream)
|
|
||||||
// Query parameters:
|
|
||||||
// - dryRun=true => only validate and return compatibility info
|
|
||||||
// - force=true => proceed even if incompatible
|
|
||||||
backupRouter.post('/restore', async (req, res) => {
|
|
||||||
const { dryRun = 'false', force = 'false' } = req.query || {};
|
|
||||||
const doDryRun = String(dryRun) === 'true';
|
|
||||||
const doForce = String(force) === 'true';
|
|
||||||
const body = await readBody(req);
|
|
||||||
|
|
||||||
if (doDryRun) {
|
|
||||||
res.body = await precheckRestore(body);
|
|
||||||
return res.send();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
res.body = await restoreFromZip(body, { force: doForce });
|
|
||||||
return res.send();
|
|
||||||
} catch (e) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.body = { message: e?.message || 'Restore failed', details: e?.payload || null };
|
|
||||||
return res.send();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export { backupRouter };
|
|
||||||
|
|||||||
@@ -3,23 +3,14 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
|
||||||
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { isAdmin } from '../security.js';
|
||||||
|
|
||||||
const service = restana();
|
function getAccessibleJobs(request) {
|
||||||
export const dashboardRouter = service.newRouter();
|
const currentUser = request.session.currentUser;
|
||||||
|
const admin = isAdmin(request);
|
||||||
function isAdmin(req) {
|
|
||||||
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
|
|
||||||
return !!user?.isAdmin;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAccessibleJobs(req) {
|
|
||||||
const currentUser = req.session.currentUser;
|
|
||||||
const admin = isAdmin(req);
|
|
||||||
return jobStorage
|
return jobStorage
|
||||||
.getJobs()
|
.getJobs()
|
||||||
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
|
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
|
||||||
@@ -29,43 +20,45 @@ function cap(val) {
|
|||||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
dashboardRouter.get('/', async (req, res) => {
|
/**
|
||||||
const jobs = getAccessibleJobs(req);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
const settings = await getSettings();
|
*/
|
||||||
|
export default async function dashboardPlugin(fastify) {
|
||||||
|
fastify.get('/', async (request) => {
|
||||||
|
const jobs = getAccessibleJobs(request);
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
// KPIs
|
const totalJobs = jobs.length;
|
||||||
const totalJobs = jobs.length;
|
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
||||||
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
const jobIds = jobs.map((j) => j.id);
|
||||||
const jobIds = jobs.map((j) => j.id);
|
const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
||||||
const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
|
||||||
// Build Pie data in a simple shape the frontend can consume directly
|
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
||||||
// Shape: { labels: string[], values: number[] } with values as percentages
|
const providerPie = Array.isArray(providerPieRaw)
|
||||||
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
|
||||||
const providerPie = Array.isArray(providerPieRaw)
|
|
||||||
? {
|
|
||||||
labels: providerPieRaw.map((p) => cap(p.type)),
|
|
||||||
values: providerPieRaw.map((p) => Number(p.value) || 0),
|
|
||||||
}
|
|
||||||
: providerPieRaw && typeof providerPieRaw === 'object'
|
|
||||||
? {
|
? {
|
||||||
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
|
labels: providerPieRaw.map((p) => cap(p.type)),
|
||||||
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
|
values: providerPieRaw.map((p) => Number(p.value) || 0),
|
||||||
}
|
}
|
||||||
: { labels: [], values: [] };
|
: providerPieRaw && typeof providerPieRaw === 'object'
|
||||||
|
? {
|
||||||
|
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
|
||||||
|
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
|
||||||
|
}
|
||||||
|
: { labels: [], values: [] };
|
||||||
|
|
||||||
res.body = {
|
return {
|
||||||
general: {
|
general: {
|
||||||
interval: settings.interval,
|
interval: settings.interval,
|
||||||
lastRun: settings.lastRun || null,
|
lastRun: settings.lastRun || null,
|
||||||
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
||||||
},
|
},
|
||||||
kpis: {
|
kpis: {
|
||||||
totalJobs,
|
totalJobs,
|
||||||
totalListings,
|
totalListings,
|
||||||
numberOfActiveListings,
|
numberOfActiveListings,
|
||||||
medianPriceOfListings,
|
medianPriceOfListings,
|
||||||
},
|
},
|
||||||
pie: providerPie,
|
pie: providerPie,
|
||||||
};
|
};
|
||||||
res.send();
|
});
|
||||||
});
|
}
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
|
||||||
const demoRouter = service.newRouter();
|
|
||||||
|
|
||||||
demoRouter.get('/', async (req, res) => {
|
/**
|
||||||
const settings = await getSettings();
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
res.body = Object.assign({}, { demoMode: settings.demoMode });
|
*/
|
||||||
res.send();
|
export default async function demoPlugin(fastify) {
|
||||||
});
|
fastify.get('/', async () => {
|
||||||
|
const settings = await getSettings();
|
||||||
export { demoRouter };
|
return { demoMode: settings.demoMode };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,39 +3,42 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import { getDirName } from '../../utils.js';
|
import { getDirName } from '../../utils.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
import { isAdmin } from '../security.js';
|
||||||
const generalSettingsRouter = service.newRouter();
|
|
||||||
|
|
||||||
generalSettingsRouter.get('/', async (req, res) => {
|
/**
|
||||||
res.body = Object.assign({}, await getSettings());
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
res.send();
|
*/
|
||||||
});
|
export default async function generalSettingsPlugin(fastify) {
|
||||||
generalSettingsRouter.post('/', async (req, res) => {
|
fastify.get('/', async () => {
|
||||||
const { sqlitepath, ...appSettings } = req.body || {};
|
return Object.assign({}, await getSettings());
|
||||||
const localSettings = await getSettings();
|
});
|
||||||
|
|
||||||
if (localSettings.demoMode) {
|
fastify.post('/', async (request, reply) => {
|
||||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
const { sqlitepath, ...appSettings } = request.body || {};
|
||||||
return;
|
if (typeof appSettings.baseUrl === 'string') {
|
||||||
}
|
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof sqlitepath !== 'undefined') {
|
|
||||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
|
||||||
}
|
}
|
||||||
upsertSettings(appSettings);
|
const localSettings = await getSettings();
|
||||||
ensureDemoUserExists();
|
|
||||||
} catch (err) {
|
if (localSettings.demoMode && !isAdmin(request)) {
|
||||||
logger.error(err);
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change these settings.' });
|
||||||
res.send(new Error('Error while trying to write settings.'));
|
}
|
||||||
return;
|
|
||||||
}
|
try {
|
||||||
res.send();
|
if (typeof sqlitepath !== 'undefined') {
|
||||||
});
|
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath }));
|
||||||
export { generalSettingsRouter };
|
}
|
||||||
|
upsertSettings(appSettings);
|
||||||
|
ensureDemoUserExists();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
|
return reply.code(500).send({ error: 'Error while trying to write settings.' });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
@@ -13,257 +12,234 @@ import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
|||||||
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
|
||||||
const jobRouter = service.newRouter();
|
|
||||||
|
|
||||||
const DEMO_JOB_NAME = 'Demo-Job';
|
const DEMO_JOB_NAME = 'Demo-Job';
|
||||||
|
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, request) {
|
||||||
const userId = req.session.currentUser;
|
const userId = request.session.currentUser;
|
||||||
if (userId == null) {
|
if (userId == null) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const user = userStorage.getUser(userId);
|
const user = userStorage.getUser(userId);
|
||||||
if (user == null) {
|
if (user == null) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return user.isAdmin || job.userId === user.id;
|
return user.isAdmin || job.userId === user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
jobRouter.get('/', async (req, res) => {
|
/**
|
||||||
const isUserAdmin = isAdmin(req);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
*/
|
||||||
res.body = jobStorage
|
export default async function jobPlugin(fastify) {
|
||||||
.getJobs()
|
fastify.get('/', async (request) => {
|
||||||
.filter(
|
const isUserAdmin = isAdmin(request);
|
||||||
(job) =>
|
return jobStorage
|
||||||
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
|
.getJobs()
|
||||||
)
|
.filter(
|
||||||
.map((job) => {
|
(job) =>
|
||||||
return {
|
isUserAdmin ||
|
||||||
|
job.userId === request.session.currentUser ||
|
||||||
|
job.shared_with_user.includes(request.session.currentUser),
|
||||||
|
)
|
||||||
|
.map((job) => ({
|
||||||
...job,
|
...job,
|
||||||
running: isJobRunning(job.id),
|
running: isJobRunning(job.id),
|
||||||
isOnlyShared:
|
isOnlyShared:
|
||||||
!isUserAdmin &&
|
!isUserAdmin &&
|
||||||
job.userId !== req.session.currentUser &&
|
job.userId !== request.session.currentUser &&
|
||||||
job.shared_with_user.includes(req.session.currentUser),
|
job.shared_with_user.includes(request.session.currentUser),
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.get('/data', async (req, res) => {
|
|
||||||
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
|
|
||||||
|
|
||||||
// normalize booleans
|
|
||||||
const toBool = (v) => {
|
|
||||||
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
|
||||||
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const normalizedActivity = toBool(activityFilter);
|
|
||||||
|
|
||||||
const queryResult = jobStorage.queryJobs({
|
|
||||||
page: page ? parseInt(page, 10) : 1,
|
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
|
||||||
freeTextFilter: freeTextFilter || null,
|
|
||||||
activityFilter: normalizedActivity,
|
|
||||||
sortField: sortfield || null,
|
|
||||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
|
||||||
userId: req.session.currentUser,
|
|
||||||
isAdmin: isAdmin(req),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isUserAdmin = isAdmin(req);
|
fastify.get('/data', async (request) => {
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
pageSize = 50,
|
||||||
|
activityFilter,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
freeTextFilter,
|
||||||
|
} = request.query || {};
|
||||||
|
|
||||||
// Map result to include runtime status
|
const toBool = (v) => {
|
||||||
queryResult.result = queryResult.result.map((job) => {
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
return {
|
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const normalizedActivity = toBool(activityFilter);
|
||||||
|
|
||||||
|
const queryResult = jobStorage.queryJobs({
|
||||||
|
page: page ? parseInt(page, 10) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
|
freeTextFilter: freeTextFilter || null,
|
||||||
|
activityFilter: normalizedActivity,
|
||||||
|
sortField: sortfield || null,
|
||||||
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
|
userId: request.session.currentUser,
|
||||||
|
isAdmin: isAdmin(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUserAdmin = isAdmin(request);
|
||||||
|
queryResult.result = queryResult.result.map((job) => ({
|
||||||
...job,
|
...job,
|
||||||
running: isJobRunning(job.id),
|
running: isJobRunning(job.id),
|
||||||
isOnlyShared:
|
isOnlyShared:
|
||||||
!isUserAdmin &&
|
!isUserAdmin &&
|
||||||
job.userId !== req.session.currentUser &&
|
job.userId !== request.session.currentUser &&
|
||||||
job.shared_with_user.includes(req.session.currentUser),
|
job.shared_with_user.includes(request.session.currentUser),
|
||||||
};
|
}));
|
||||||
|
|
||||||
|
return queryResult;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.body = queryResult;
|
// Server-Sent Events for real-time job status updates
|
||||||
res.send();
|
fastify.get('/events', async (request, reply) => {
|
||||||
});
|
const userId = request.session?.currentUser;
|
||||||
|
if (userId == null) {
|
||||||
|
return reply.code(401).send({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.hijack();
|
||||||
|
const raw = reply.raw;
|
||||||
|
raw.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
raw.setHeader('Cache-Control', 'no-cache');
|
||||||
|
raw.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
// Server-Sent Events for job status updates
|
|
||||||
jobRouter.get('/events', async (req, res) => {
|
|
||||||
const userId = req.session.currentUser;
|
|
||||||
if (userId == null) {
|
|
||||||
res.send({ message: 'Unauthorized' }, 401);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// SSE headers
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
try {
|
|
||||||
// Initial comment to establish stream
|
|
||||||
res.write(': connected\n\n');
|
|
||||||
addSseClient(userId, res);
|
|
||||||
// Cleanup on close/aborted
|
|
||||||
const onClose = () => removeClient(userId, res);
|
|
||||||
// restana exposes original req/res; use both close and finish
|
|
||||||
req.on('close', onClose);
|
|
||||||
req.on('aborted', onClose);
|
|
||||||
res.on('close', onClose);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Error establishing SSE connection', e);
|
|
||||||
try {
|
try {
|
||||||
res.end();
|
raw.write(': connected\n\n');
|
||||||
} catch {
|
addSseClient(userId, raw);
|
||||||
//noop
|
const onClose = () => removeClient(userId, raw);
|
||||||
|
request.raw.on('close', onClose);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error establishing SSE connection', e);
|
||||||
|
try {
|
||||||
|
raw.end();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.post('/startAll', async (req, res) => {
|
fastify.post('/startAll', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.currentUser;
|
const userId = request.session.currentUser;
|
||||||
// Emit only the userId; handler will decide based on admin/ownership
|
bus.emit('jobs:runAll', { userId });
|
||||||
bus.emit('jobs:runAll', { userId });
|
return reply.code(202).send({ message: 'Run all accepted' });
|
||||||
res.send({ message: 'Run all accepted' }, 202);
|
} catch (err) {
|
||||||
} catch (err) {
|
logger.error('Failed to trigger startAll', err);
|
||||||
logger.error('Failed to trigger startAll', err);
|
return reply.code(500).send({ message: 'Unexpected error' });
|
||||||
res.send({ message: 'Unexpected error' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger a single job run
|
|
||||||
jobRouter.post('/:jobId/run', async (req, res) => {
|
|
||||||
const { jobId } = req.params;
|
|
||||||
try {
|
|
||||||
const job = jobStorage.getJob(jobId);
|
|
||||||
if (!job) {
|
|
||||||
res.send({ message: 'Job not found' }, 404);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
});
|
||||||
res.send({ message: 'You are trying to run a job that is not associated to your user' }, 403);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isJobRunning(jobId)) {
|
|
||||||
res.send({ message: 'Job is already running' }, 409);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// fire and forget; actual execution handled by index.js listener
|
|
||||||
bus.emit('jobs:runOne', { jobId });
|
|
||||||
res.send({ message: 'Job run accepted' }, 202);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
res.send({ message: 'Unexpected error triggering job' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.post('/', async (req, res) => {
|
fastify.post('/:jobId/run', async (request, reply) => {
|
||||||
const {
|
const { jobId } = request.params;
|
||||||
provider,
|
try {
|
||||||
notificationAdapter,
|
const job = jobStorage.getJob(jobId);
|
||||||
name,
|
if (!job) {
|
||||||
blacklist = [],
|
return reply.code(404).send({ message: 'Job not found' });
|
||||||
jobId,
|
}
|
||||||
enabled,
|
if (!doesJobBelongsToUser(job, request)) {
|
||||||
shareWithUsers = [],
|
return reply.code(403).send({ message: 'You are trying to run a job that is not associated to your user' });
|
||||||
spatialFilter = null,
|
}
|
||||||
specFilter = null,
|
if (isJobRunning(jobId)) {
|
||||||
} = req.body;
|
return reply.code(409).send({ message: 'Job is already running' });
|
||||||
const settings = await getSettings();
|
}
|
||||||
try {
|
bus.emit('jobs:runOne', { jobId });
|
||||||
let jobFromDb = jobStorage.getJob(jobId);
|
return reply.code(202).send({ message: 'Job run accepted' });
|
||||||
|
} catch (error) {
|
||||||
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
|
logger.error(error);
|
||||||
res.send(new Error('You are trying to change a job that is not associated to your user.'));
|
return reply.code(500).send({ message: 'Unexpected error triggering job' });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
fastify.post('/', async (request, reply) => {
|
||||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
const {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
|
||||||
userId: req.session.currentUser,
|
|
||||||
jobId,
|
|
||||||
enabled,
|
|
||||||
name,
|
|
||||||
blacklist,
|
|
||||||
provider,
|
provider,
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
shareWithUsers,
|
name,
|
||||||
spatialFilter,
|
blacklist = [],
|
||||||
specFilter,
|
jobId,
|
||||||
});
|
enabled,
|
||||||
} catch (error) {
|
shareWithUsers = [],
|
||||||
res.send(new Error(error));
|
spatialFilter = null,
|
||||||
logger.error(error);
|
specFilter = null,
|
||||||
}
|
} = request.body;
|
||||||
res.send();
|
const settings = await getSettings();
|
||||||
});
|
try {
|
||||||
|
const jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, request)) {
|
||||||
const { jobId } = req.body;
|
return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user.' });
|
||||||
const settings = await getSettings();
|
}
|
||||||
try {
|
|
||||||
const job = jobStorage.getJob(jobId);
|
|
||||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
|
||||||
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (settings.demoMode && !isAdmin(request) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
|
||||||
} else {
|
}
|
||||||
jobStorage.removeJob(jobId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.send(new Error(error));
|
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
|
||||||
const { status } = req.body;
|
|
||||||
const { jobId } = req.params;
|
|
||||||
const settings = await getSettings();
|
|
||||||
try {
|
|
||||||
const job = jobStorage.getJob(jobId);
|
|
||||||
|
|
||||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
jobStorage.upsertJob({
|
||||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
userId: request.session.currentUser,
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
|
||||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
|
||||||
} else {
|
|
||||||
jobStorage.setJobStatus({
|
|
||||||
jobId,
|
jobId,
|
||||||
status,
|
enabled,
|
||||||
|
name,
|
||||||
|
blacklist,
|
||||||
|
provider,
|
||||||
|
notificationAdapter,
|
||||||
|
shareWithUsers,
|
||||||
|
spatialFilter,
|
||||||
|
specFilter,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return reply.send();
|
||||||
res.send(new Error(error));
|
});
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
jobRouter.get('/shareableUserList', async (req, res) => {
|
fastify.delete('/', async (request, reply) => {
|
||||||
const currentUser = req.session.currentUser;
|
const { jobId } = request.body;
|
||||||
const users = userStorage.getUsers(false);
|
const settings = await getSettings();
|
||||||
res.body = users
|
try {
|
||||||
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
const job = jobStorage.getJob(jobId);
|
||||||
.map((user) => ({
|
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
|
||||||
id: user.id,
|
return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' });
|
||||||
name: user.username,
|
}
|
||||||
}));
|
|
||||||
res.send();
|
if (!doesJobBelongsToUser(job, request)) {
|
||||||
});
|
return reply.code(403).send({ error: 'You are trying to remove a job that is not associated to your user' });
|
||||||
export { jobRouter };
|
}
|
||||||
|
jobStorage.removeJob(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.put('/:jobId/status', async (request, reply) => {
|
||||||
|
const { status } = request.body;
|
||||||
|
const { jobId } = request.params;
|
||||||
|
const settings = await getSettings();
|
||||||
|
try {
|
||||||
|
const job = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
|
||||||
|
return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doesJobBelongsToUser(job, request)) {
|
||||||
|
return reply.code(403).send({ error: 'You are trying change a job that is not associated to your user' });
|
||||||
|
}
|
||||||
|
jobStorage.setJobStatus({ jobId, status });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/shareableUserList', async (request) => {
|
||||||
|
const currentUser = request.session.currentUser;
|
||||||
|
const users = userStorage.getUsers(false);
|
||||||
|
return users
|
||||||
|
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||||
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
||||||
import { isAdmin as isAdminFn } from '../security.js';
|
import { isAdmin as isAdminFn } from '../security.js';
|
||||||
@@ -12,128 +11,114 @@ import { nullOrEmpty } from '../../utils.js';
|
|||||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
/**
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function listingsPlugin(fastify) {
|
||||||
|
fastify.get('/table', async (request) => {
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
pageSize = 50,
|
||||||
|
activityFilter,
|
||||||
|
jobNameFilter,
|
||||||
|
providerFilter,
|
||||||
|
watchListFilter,
|
||||||
|
sortfield = null,
|
||||||
|
sortdir = 'asc',
|
||||||
|
freeTextFilter,
|
||||||
|
} = request.query || {};
|
||||||
|
|
||||||
const listingsRouter = service.newRouter();
|
const toBool = (v) => {
|
||||||
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
|
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const normalizedActivity = toBool(activityFilter);
|
||||||
|
const normalizedWatch = toBool(watchListFilter);
|
||||||
|
|
||||||
listingsRouter.get('/table', async (req, res) => {
|
let jobFilter = null;
|
||||||
const {
|
let jobIdFilter = null;
|
||||||
page,
|
const jobs = getJobs();
|
||||||
pageSize = 50,
|
if (!nullOrEmpty(jobNameFilter)) {
|
||||||
activityFilter,
|
const job = jobs.find((j) => j.id === jobNameFilter);
|
||||||
jobNameFilter,
|
jobFilter = job != null ? job.name : null;
|
||||||
providerFilter,
|
jobIdFilter = job != null ? job.id : null;
|
||||||
watchListFilter,
|
}
|
||||||
sortfield = null,
|
|
||||||
sortdir = 'asc',
|
|
||||||
freeTextFilter,
|
|
||||||
} = req.query || {};
|
|
||||||
|
|
||||||
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
|
return listingStorage.queryListings({
|
||||||
const toBool = (v) => {
|
page: page ? parseInt(page, 10) : 1,
|
||||||
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
if (v === false || v === 'false' || v === 0 || v === '0') return false;
|
freeTextFilter: freeTextFilter || null,
|
||||||
return null;
|
activityFilter: normalizedActivity,
|
||||||
};
|
jobNameFilter: jobFilter,
|
||||||
const normalizedActivity = toBool(activityFilter);
|
jobIdFilter: jobIdFilter,
|
||||||
const normalizedWatch = toBool(watchListFilter);
|
providerFilter,
|
||||||
|
watchListFilter: normalizedWatch,
|
||||||
let jobFilter = null;
|
sortField: sortfield || null,
|
||||||
let jobIdFilter = null;
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
const jobs = getJobs();
|
userId: request.session.currentUser,
|
||||||
if (!nullOrEmpty(jobNameFilter)) {
|
isAdmin: isAdminFn(request),
|
||||||
const job = jobs.find((j) => j.id === jobNameFilter);
|
});
|
||||||
jobFilter = job != null ? job.name : null;
|
|
||||||
jobIdFilter = job != null ? job.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.body = listingStorage.queryListings({
|
|
||||||
page: page ? parseInt(page, 10) : 1,
|
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
|
||||||
freeTextFilter: freeTextFilter || null,
|
|
||||||
activityFilter: normalizedActivity,
|
|
||||||
jobNameFilter: jobFilter,
|
|
||||||
jobIdFilter: jobIdFilter,
|
|
||||||
providerFilter,
|
|
||||||
watchListFilter: normalizedWatch,
|
|
||||||
sortField: sortfield || null,
|
|
||||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
|
||||||
userId: req.session.currentUser,
|
|
||||||
isAdmin: isAdminFn(req),
|
|
||||||
});
|
});
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.get('/map', async (req, res) => {
|
fastify.get('/map', async (request) => {
|
||||||
const { jobId } = req.query || {};
|
const { jobId } = request.query || {};
|
||||||
|
return listingStorage.getListingsForMap({
|
||||||
res.body = listingStorage.getListingsForMap({
|
jobId: nullOrEmpty(jobId) ? null : jobId,
|
||||||
jobId: nullOrEmpty(jobId) ? null : jobId,
|
userId: request.session.currentUser,
|
||||||
userId: req.session.currentUser,
|
isAdmin: isAdminFn(request),
|
||||||
isAdmin: isAdminFn(req),
|
});
|
||||||
});
|
});
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.get('/:listingId', async (req, res) => {
|
fastify.get('/:listingId', async (request, reply) => {
|
||||||
const { listingId } = req.params;
|
const { listingId } = request.params;
|
||||||
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
|
const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
|
||||||
if (!listing) {
|
if (!listing) {
|
||||||
res.statusCode = 404;
|
return reply.code(404).send({ message: 'Listing not found' });
|
||||||
res.body = { message: 'Listing not found' };
|
|
||||||
return res.send();
|
|
||||||
}
|
|
||||||
res.body = listing;
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle watch state for the current user on a listing
|
|
||||||
listingsRouter.post('/watch', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { listingId } = req.body || {};
|
|
||||||
const userId = req.session?.currentUser;
|
|
||||||
if (!listingId || !userId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.body = { message: 'listingId or user not provided' };
|
|
||||||
return res.send();
|
|
||||||
}
|
}
|
||||||
watchListStorage.toggleWatch(listingId, userId);
|
return listing;
|
||||||
} catch (error) {
|
});
|
||||||
logger.error(error);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.body = { message: 'Failed to toggle watch' };
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.delete('/job', async (req, res) => {
|
fastify.post('/watch', async (request, reply) => {
|
||||||
const { jobId, hardDelete = false } = req.body;
|
try {
|
||||||
const settings = await getSettings();
|
const { listingId } = request.body || {};
|
||||||
try {
|
const userId = request.session?.currentUser;
|
||||||
if (settings.demoMode) {
|
if (!listingId || !userId) {
|
||||||
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
return reply.code(400).send({ message: 'listingId or user not provided' });
|
||||||
return;
|
}
|
||||||
|
watchListStorage.toggleWatch(listingId, userId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ message: 'Failed to toggle watch' });
|
||||||
}
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
fastify.delete('/job', async (request, reply) => {
|
||||||
} catch (error) {
|
const { jobId, hardDelete = false } = request.body;
|
||||||
res.send(new Error(error));
|
const settings = await getSettings();
|
||||||
logger.error(error);
|
try {
|
||||||
}
|
if (settings.demoMode && !isAdminFn(request)) {
|
||||||
res.send();
|
return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
|
||||||
});
|
}
|
||||||
|
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||||
listingsRouter.delete('/', async (req, res) => {
|
} catch (error) {
|
||||||
const { ids, hardDelete = false } = req.body;
|
logger.error(error);
|
||||||
try {
|
return reply.code(500).send({ error: error.message });
|
||||||
if (Array.isArray(ids) && ids.length > 0) {
|
|
||||||
listingStorage.deleteListingsById(ids, hardDelete);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return reply.send();
|
||||||
res.send(new Error(error));
|
});
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
export { listingsRouter };
|
fastify.delete('/', async (request, reply) => {
|
||||||
|
const { ids, hardDelete = false } = request.body;
|
||||||
|
try {
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
listingStorage.deleteListingsById(ids, hardDelete);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as hasher from '../../services/security/hash.js';
|
import * as hasher from '../../services/security/hash.js';
|
||||||
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
||||||
@@ -11,12 +10,12 @@ import logger from '../../services/logger.js';
|
|||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const MAX_LOGIN_ATTEMPTS = 10;
|
const MAX_LOGIN_ATTEMPTS = 10;
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const loginAttempts = new Map(); // ip -> { count, firstAttempt }
|
const loginAttempts = new Map();
|
||||||
|
|
||||||
function getClientIp(req) {
|
function getClientIp(request) {
|
||||||
const forwarded = req.headers['x-forwarded-for'];
|
const forwarded = request.headers['x-forwarded-for'];
|
||||||
return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown';
|
return (forwarded ? forwarded.split(',')[0] : request.socket?.remoteAddress) || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRateLimited(ip) {
|
function isRateLimited(ip) {
|
||||||
@@ -30,53 +29,51 @@ function isRateLimited(ip) {
|
|||||||
return record.count > MAX_LOGIN_ATTEMPTS;
|
return record.count > MAX_LOGIN_ATTEMPTS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = restana();
|
/**
|
||||||
const loginRouter = service.newRouter();
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
loginRouter.get('/user', async (req, res) => {
|
*/
|
||||||
const currentUserId = req.session.currentUser;
|
export default async function loginPlugin(fastify) {
|
||||||
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
|
fastify.get('/user', async (request) => {
|
||||||
if (currentUser == null) {
|
const currentUserId = request.session?.currentUser;
|
||||||
res.body = {};
|
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
|
||||||
} else {
|
if (currentUser == null) {
|
||||||
res.body = {
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
isAdmin: currentUser.isAdmin,
|
isAdmin: currentUser.isAdmin,
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
loginRouter.post('/', async (req, res) => {
|
|
||||||
const ip = getClientIp(req);
|
|
||||||
if (isRateLimited(ip)) {
|
|
||||||
logger.error(`Login rate limit exceeded for IP ${ip}`);
|
|
||||||
res.send(429);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const settings = await getSettings();
|
|
||||||
const { username, password } = req.body;
|
|
||||||
const user = userStorage.getUsers(true).find((user) => user.username === username);
|
|
||||||
if (user == null) {
|
|
||||||
res.send(401);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (user.password === hasher.hash(password)) {
|
|
||||||
if (settings.demoMode) {
|
|
||||||
await trackDemoAccessed();
|
|
||||||
}
|
|
||||||
|
|
||||||
req.session.currentUser = user.id;
|
fastify.post('/', async (request, reply) => {
|
||||||
req.session.createdAt = Date.now();
|
const ip = getClientIp(request);
|
||||||
loginAttempts.delete(ip);
|
if (isRateLimited(ip)) {
|
||||||
userStorage.setLastLoginToNow({ userId: user.id });
|
logger.error(`Login rate limit exceeded for IP ${ip}`);
|
||||||
res.send(200);
|
return reply.code(429).send();
|
||||||
return;
|
}
|
||||||
} else {
|
const settings = await getSettings();
|
||||||
logger.error(`User ${username} tried to login, but password was wrong.`);
|
const { username, password } = request.body;
|
||||||
}
|
const user = userStorage.getUsers(true).find((u) => u.username === username);
|
||||||
res.send(401);
|
if (user == null) {
|
||||||
});
|
return reply.code(401).send();
|
||||||
loginRouter.post('/logout', async (req, res) => {
|
}
|
||||||
req.session = null;
|
if (user.password === hasher.hash(password)) {
|
||||||
res.send(200);
|
if (settings.demoMode) {
|
||||||
});
|
await trackDemoAccessed();
|
||||||
export { loginRouter };
|
}
|
||||||
|
request.session.currentUser = user.id;
|
||||||
|
request.session.createdAt = Date.now();
|
||||||
|
loginAttempts.delete(ip);
|
||||||
|
userStorage.setLastLoginToNow({ userId: user.id });
|
||||||
|
return reply.code(200).send();
|
||||||
|
} else {
|
||||||
|
logger.error(`User ${username} tried to login, but password was wrong.`);
|
||||||
|
}
|
||||||
|
return reply.code(401).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/logout', async (request, reply) => {
|
||||||
|
await request.session.destroy();
|
||||||
|
return reply.code(200).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,62 +4,64 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import restana from 'restana';
|
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
const service = restana();
|
|
||||||
const notificationAdapterRouter = service.newRouter();
|
|
||||||
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
||||||
const notificationAdapter = await Promise.all(
|
const notificationAdapter = await Promise.all(
|
||||||
notificationAdapterList.map(async (pro) => {
|
notificationAdapterList.map(async (pro) => {
|
||||||
return await import(`../../notification/adapter/${pro}`);
|
return await import(`../../notification/adapter/${pro}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
notificationAdapterRouter.post('/try', async (req, res) => {
|
|
||||||
const { id, fields } = req.body;
|
/**
|
||||||
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
if (adapter == null) {
|
*/
|
||||||
res.send(404);
|
export default async function notificationAdapterPlugin(fastify) {
|
||||||
}
|
fastify.get('/', async () => {
|
||||||
const notificationConfig = [];
|
return notificationAdapter.map((adapter) => adapter.config);
|
||||||
const notificationObject = {};
|
|
||||||
Object.keys(fields).forEach((key) => {
|
|
||||||
notificationObject[key] = fields[key].value;
|
|
||||||
});
|
});
|
||||||
notificationConfig.push({
|
|
||||||
fields: { ...notificationObject },
|
fastify.post('/try', async (request, reply) => {
|
||||||
enabled: true,
|
const { id, fields } = request.body;
|
||||||
id,
|
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
|
||||||
});
|
if (adapter == null) {
|
||||||
try {
|
return reply.code(404).send();
|
||||||
await adapter.send({
|
}
|
||||||
serviceName: 'TestCall',
|
const notificationConfig = [];
|
||||||
newListings: [
|
const notificationObject = {};
|
||||||
{
|
Object.keys(fields).forEach((key) => {
|
||||||
address: 'Heidestrasse 17, 51147 Köln',
|
notificationObject[key] = fields[key].value;
|
||||||
description: exampleDescription,
|
|
||||||
id: '1',
|
|
||||||
imageUrl: 'https://placehold.co/600x400/png',
|
|
||||||
price: '1.000 €',
|
|
||||||
size: '76 m²',
|
|
||||||
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
|
|
||||||
url: 'https://www.orange-coding.net',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
notificationConfig,
|
|
||||||
jobKey: 'TestJob',
|
|
||||||
});
|
});
|
||||||
res.send();
|
notificationConfig.push({
|
||||||
} catch (Exception) {
|
fields: { ...notificationObject },
|
||||||
logger.error('Error during notification adapter test:', Exception);
|
enabled: true,
|
||||||
res.send(new Error(Exception));
|
id,
|
||||||
}
|
});
|
||||||
});
|
try {
|
||||||
notificationAdapterRouter.get('/', async (req, res) => {
|
await adapter.send({
|
||||||
res.body = notificationAdapter.map((adapter) => adapter.config);
|
serviceName: 'TestCall',
|
||||||
res.send();
|
newListings: [
|
||||||
});
|
{
|
||||||
export { notificationAdapterRouter };
|
address: 'Heidestrasse 17, 51147 Köln',
|
||||||
|
description: exampleDescription,
|
||||||
|
id: '1',
|
||||||
|
imageUrl: 'https://placehold.co/600x400/png',
|
||||||
|
price: '1.000 €',
|
||||||
|
size: '76 m²',
|
||||||
|
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
|
||||||
|
url: 'https://www.orange-coding.net',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationConfig,
|
||||||
|
jobKey: 'TestJob',
|
||||||
|
});
|
||||||
|
return reply.send();
|
||||||
|
} catch (Exception) {
|
||||||
|
logger.error('Error during notification adapter test:', Exception);
|
||||||
|
return reply.code(500).send({ error: String(Exception) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const exampleDescription = `
|
const exampleDescription = `
|
||||||
Wohnungstyp: Etagenwohnung
|
Wohnungstyp: Etagenwohnung
|
||||||
@@ -94,7 +96,7 @@ Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
|
|||||||
Ausstattung:
|
Ausstattung:
|
||||||
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
|
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
|
||||||
- sonniger Balkon (Süd)
|
- sonniger Balkon (Süd)
|
||||||
- Tiefgaragenstellplatz
|
- Tiefgaragenstellplatz
|
||||||
- Kellerabteil
|
- Kellerabteil
|
||||||
- gepflegtes Mehrfamilienhaus
|
- gepflegtes Mehrfamilienhaus
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ Vermietung direkt vom Eigentümer - provisionsfrei!
|
|||||||
|
|
||||||
Lage:
|
Lage:
|
||||||
• Park: 1 Minute zu Fuß
|
• Park: 1 Minute zu Fuß
|
||||||
• S-Bahn Station: 2 Minuten zu Fuß
|
• S-Bahn Station: 2 Minuten zu Fuß
|
||||||
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
|
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
|
||||||
• Gute Anbindung Richtung Großstadt und Flughafen
|
• Gute Anbindung Richtung Großstadt und Flughafen
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -4,17 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import restana from 'restana';
|
|
||||||
const service = restana();
|
|
||||||
const providerRouter = service.newRouter();
|
|
||||||
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
|
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
|
||||||
const provider = await Promise.all(
|
const providers = await Promise.all(providerList.map(async (pro) => import(`../../provider/${pro}`)));
|
||||||
providerList.map(async (pro) => {
|
|
||||||
return await import(`../../provider/${pro}`);
|
/**
|
||||||
}),
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
);
|
*/
|
||||||
providerRouter.get('/', async (req, res) => {
|
export default async function providerPlugin(fastify) {
|
||||||
res.body = provider.map((p) => p.metaInformation);
|
fastify.get('/', async () => {
|
||||||
res.send();
|
return providers.map((p) => p.metaInformation);
|
||||||
});
|
});
|
||||||
export { providerRouter };
|
}
|
||||||
|
|||||||
@@ -3,35 +3,29 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import { trackPoi } from '../../services/tracking/Tracker.js';
|
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||||
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
const service = restana();
|
/**
|
||||||
const trackingRouter = service.newRouter();
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function trackingPlugin(fastify) {
|
||||||
|
fastify.get('/trackingPois', async () => {
|
||||||
|
return TRACKING_POIS;
|
||||||
|
});
|
||||||
|
|
||||||
trackingRouter.get('/trackingPois', async (req, res) => {
|
fastify.post('/poi', async (request, reply) => {
|
||||||
res.body = TRACKING_POIS;
|
const { poi } = request.body;
|
||||||
res.send();
|
if (!poi) {
|
||||||
});
|
return reply.code(400).send({ error: 'Feature name is required' });
|
||||||
|
}
|
||||||
trackingRouter.post('/poi', async (req, res) => {
|
try {
|
||||||
const { poi } = req.body;
|
await trackPoi(poi);
|
||||||
if (!poi) {
|
return { success: true };
|
||||||
res.statusCode = 400;
|
} catch (error) {
|
||||||
res.send({ error: 'Feature name is required' });
|
logger.error('Error tracking feature', error);
|
||||||
return;
|
return reply.code(500).send({ error: error.message });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
try {
|
}
|
||||||
await trackPoi(poi);
|
|
||||||
res.send({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error tracking feature', error);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export { trackingRouter };
|
|
||||||
|
|||||||
@@ -3,81 +3,73 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as userStorage from '../../services/storage/userStorage.js';
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
const service = restana();
|
import { isAdmin as isAdminUser } from '../security.js';
|
||||||
const userRouter = service.newRouter();
|
|
||||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||||
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
|
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
|
||||||
}
|
}
|
||||||
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
|
|
||||||
return req.session.currentUser === userIdToBeRemoved;
|
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, request) {
|
||||||
|
return request.session.currentUser === userIdToBeRemoved;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nullOrEmpty = (str) => str == null || str.length === 0;
|
const nullOrEmpty = (str) => str == null || str.length === 0;
|
||||||
|
|
||||||
userRouter.get('/', async (req, res) => {
|
/**
|
||||||
res.body = userStorage.getUsers(false);
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
res.send();
|
*/
|
||||||
});
|
export default async function userPlugin(fastify) {
|
||||||
|
fastify.get('/', async () => {
|
||||||
userRouter.get('/:userId', async (req, res) => {
|
return userStorage.getUsers(false);
|
||||||
const { userId } = req.params;
|
|
||||||
res.body = userStorage.getUser(userId);
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
userRouter.delete('/', async (req, res) => {
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.demoMode) {
|
|
||||||
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId } = req.body;
|
|
||||||
const allUser = userStorage.getUsers(false);
|
|
||||||
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
|
||||||
res.send(new Error('You are trying to remove the last admin user. This is prohibited.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checkIfUserToBeRemovedIsLoggedIn(userId, req)) {
|
|
||||||
res.send(new Error('You are trying to remove yourself. This is prohibited.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
//TODO: Remove also analytics
|
|
||||||
jobStorage.removeJobsByUserId(userId);
|
|
||||||
userStorage.removeUser(userId);
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
userRouter.post('/', async (req, res) => {
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.demoMode) {
|
|
||||||
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password, password2, isAdmin, userId } = req.body;
|
|
||||||
if (password !== password2) {
|
|
||||||
res.send(new Error('Passwords does not match'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
|
|
||||||
res.send(new Error('Username and password are mandatory.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const allUser = userStorage.getUsers(false);
|
|
||||||
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
|
||||||
res.send(
|
|
||||||
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system'),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userStorage.upsertUser({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
isAdmin,
|
|
||||||
});
|
});
|
||||||
res.send();
|
|
||||||
});
|
fastify.get('/:userId', async (request) => {
|
||||||
export { userRouter };
|
const { userId } = request.params;
|
||||||
|
return userStorage.getUser(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete('/', async (request, reply) => {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode && !isAdminUser(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to remove user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = request.body;
|
||||||
|
const allUser = userStorage.getUsers(false);
|
||||||
|
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
||||||
|
return reply.code(400).send({ error: 'You are trying to remove the last admin user. This is prohibited.' });
|
||||||
|
}
|
||||||
|
if (checkIfUserToBeRemovedIsLoggedIn(userId, request)) {
|
||||||
|
return reply.code(400).send({ error: 'You are trying to remove yourself. This is prohibited.' });
|
||||||
|
}
|
||||||
|
jobStorage.removeJobsByUserId(userId);
|
||||||
|
userStorage.removeUser(userId);
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/', async (request, reply) => {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode && !isAdminUser(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change or add user.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password, password2, isAdmin, userId } = request.body;
|
||||||
|
if (password !== password2) {
|
||||||
|
return reply.code(400).send({ error: 'Passwords do not match.' });
|
||||||
|
}
|
||||||
|
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
|
||||||
|
return reply.code(400).send({ error: 'Username and password are mandatory.' });
|
||||||
|
}
|
||||||
|
const allUser = userStorage.getUsers(false);
|
||||||
|
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'You cannot change the admin flag for this user as otherwise, there is no other user in the system',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
userStorage.upsertUser({ userId, username, password, isAdmin });
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { isAdmin } from '../security.js';
|
||||||
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||||
@@ -15,113 +15,132 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
|||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||||
|
|
||||||
const service = restana();
|
/**
|
||||||
const userSettingsRouter = service.newRouter();
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
userSettingsRouter.get('/', async (req, res) => {
|
export default async function userSettingsPlugin(fastify) {
|
||||||
const userId = req.session.currentUser;
|
fastify.get('/', async (request) => {
|
||||||
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
|
const userId = request.session.currentUser;
|
||||||
const settings = {};
|
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
|
||||||
for (const r of rows) {
|
const settings = {};
|
||||||
settings[r.name] = fromJson(r.value, null);
|
for (const r of rows) {
|
||||||
}
|
settings[r.name] = fromJson(r.value, null);
|
||||||
res.body = settings;
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsRouter.get('/autocomplete', async (req, res) => {
|
|
||||||
const { q } = req.query;
|
|
||||||
try {
|
|
||||||
const results = await autocompleteAddress(q);
|
|
||||||
res.body = results;
|
|
||||||
res.send();
|
|
||||||
} catch (error) {
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsRouter.post('/home-address', async (req, res) => {
|
|
||||||
const userId = req.session.currentUser;
|
|
||||||
const { home_address } = req.body;
|
|
||||||
const settings = await getSettings();
|
|
||||||
|
|
||||||
if (settings.demoMode) {
|
|
||||||
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (home_address) {
|
|
||||||
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
|
|
||||||
const coords = await geocodeAddress(home_address);
|
|
||||||
if (coords && coords.lat !== -1) {
|
|
||||||
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
|
||||||
resetGeocoordinatesAndDistanceForUser(userId);
|
|
||||||
//we do NOT wait for this to finish, as we don't want to block the response
|
|
||||||
runGeoCordTask();
|
|
||||||
res.send({ success: true, coords });
|
|
||||||
} else {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.send({ error: 'Could not geocode address' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
upsertSettings({ home_address: null }, userId);
|
|
||||||
res.send({ success: true });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return settings;
|
||||||
logger.error('Error updating home address settings', error);
|
});
|
||||||
res.statusCode = 500;
|
|
||||||
res.send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsRouter.post('/news-hash', async (req, res) => {
|
fastify.get('/autocomplete', async (request, reply) => {
|
||||||
const userId = req.session.currentUser;
|
const { q } = request.query;
|
||||||
const { news_hash } = req.body;
|
try {
|
||||||
|
const results = await autocompleteAddress(q);
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const globalSettings = await getSettings();
|
fastify.post('/home-address', async (request, reply) => {
|
||||||
if (globalSettings.demoMode) {
|
const userId = request.session.currentUser;
|
||||||
res.statusCode = 403;
|
const { home_address } = request.body;
|
||||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
const settings = await getSettings();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (settings.demoMode && !isAdmin(request)) {
|
||||||
upsertSettings({ news_hash }, userId);
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change the home address.' });
|
||||||
res.send({ success: true });
|
}
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error updating news hash', error);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
userSettingsRouter.post('/provider-details', async (req, res) => {
|
try {
|
||||||
const userId = req.session.currentUser;
|
if (home_address) {
|
||||||
const { provider_details } = req.body;
|
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
|
||||||
|
const coords = await geocodeAddress(home_address);
|
||||||
|
if (coords && coords.lat !== -1) {
|
||||||
|
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||||
|
resetGeocoordinatesAndDistanceForUser(userId);
|
||||||
|
runGeoCordTask();
|
||||||
|
return { success: true, coords };
|
||||||
|
} else {
|
||||||
|
return reply.code(400).send({ error: 'Could not geocode address' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upsertSettings({ home_address: null }, userId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating home address settings', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const globalSettings = await getSettings();
|
fastify.post('/news-hash', async (request, reply) => {
|
||||||
if (globalSettings.demoMode) {
|
const userId = request.session.currentUser;
|
||||||
res.statusCode = 403;
|
const { news_hash } = request.body;
|
||||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(provider_details)) {
|
const globalSettings = await getSettings();
|
||||||
res.statusCode = 400;
|
if (globalSettings.demoMode && !isAdmin(request)) {
|
||||||
res.send({ error: 'provider_details must be an array of provider ids.' });
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
upsertSettings({ provider_details }, userId);
|
upsertSettings({ news_hash }, userId);
|
||||||
res.send({ success: true });
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating provider details setting', error);
|
logger.error('Error updating news hash', error);
|
||||||
res.statusCode = 500;
|
return reply.code(500).send({ error: error.message });
|
||||||
res.send({ error: error.message });
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
export { userSettingsRouter };
|
fastify.post('/provider-details', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { provider_details } = request.body;
|
||||||
|
|
||||||
|
const globalSettings = await getSettings();
|
||||||
|
if (globalSettings.demoMode && !isAdmin(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(provider_details)) {
|
||||||
|
return reply.code(400).send({ error: 'provider_details must be an array of provider ids.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ provider_details }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating provider details setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/listings-view-mode', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { listings_view_mode } = request.body;
|
||||||
|
|
||||||
|
if (listings_view_mode !== 'grid' && listings_view_mode !== 'table') {
|
||||||
|
return reply.code(400).send({ error: 'listings_view_mode must be "grid" or "table".' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ listings_view_mode }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating listings view mode setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/jobs-view-mode', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { jobs_view_mode } = request.body;
|
||||||
|
|
||||||
|
if (jobs_view_mode !== 'grid' && jobs_view_mode !== 'table') {
|
||||||
|
return reply.code(400).send({ error: 'jobs_view_mode must be "grid" or "table".' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ jobs_view_mode }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating jobs view mode setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,27 +3,10 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { getPackageVersion } from '../../utils.js';
|
import { getPackageVersion } from '../../utils.js';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
|
|
||||||
const service = restana();
|
|
||||||
const versionRouter = service.newRouter();
|
|
||||||
|
|
||||||
versionRouter.get('/', async (req, res) => {
|
|
||||||
const versionPayload = await getCurrentVersionFromGithub();
|
|
||||||
const localFredyVersion = await getPackageVersion();
|
|
||||||
res.body =
|
|
||||||
versionPayload == null
|
|
||||||
? {
|
|
||||||
newVersion: false,
|
|
||||||
localFredyVersion,
|
|
||||||
}
|
|
||||||
: versionPayload;
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getCurrentVersionFromGithub() {
|
async function getCurrentVersionFromGithub() {
|
||||||
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
|
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
|
||||||
const data = await raw.json();
|
const data = await raw.json();
|
||||||
@@ -40,4 +23,13 @@ async function getCurrentVersionFromGithub() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { versionRouter };
|
/**
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function versionPlugin(fastify) {
|
||||||
|
fastify.get('/', async () => {
|
||||||
|
const versionPayload = await getCurrentVersionFromGithub();
|
||||||
|
const localFredyVersion = await getPackageVersion();
|
||||||
|
return versionPayload ?? { newVersion: false, localFredyVersion };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,53 +4,50 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as userStorage from '../services/storage/userStorage.js';
|
import * as userStorage from '../services/storage/userStorage.js';
|
||||||
import cookieSession from 'cookie-session';
|
|
||||||
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
|
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
|
||||||
const unauthorized = (res) => {
|
|
||||||
return res.send(401);
|
/**
|
||||||
};
|
* Returns true when the request has no valid, non-expired session.
|
||||||
const isUnauthorized = (req) => {
|
* @param {import('fastify').FastifyRequest} request
|
||||||
if (req.session.currentUser == null) return true;
|
* @returns {boolean}
|
||||||
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) {
|
*/
|
||||||
req.session = null;
|
export function isUnauthorized(request) {
|
||||||
return true;
|
if (!request.session?.currentUser) return true;
|
||||||
}
|
if (Date.now() - (request.session.createdAt || 0) > SESSION_MAX_AGE) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
}
|
||||||
const isAdmin = (req) => {
|
|
||||||
if (!isUnauthorized(req)) {
|
/**
|
||||||
const user = userStorage.getUser(req.session.currentUser);
|
* Returns true when the session belongs to an admin user.
|
||||||
return user != null && user.isAdmin;
|
* @param {import('fastify').FastifyRequest} request
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isAdmin(request) {
|
||||||
|
if (isUnauthorized(request)) return false;
|
||||||
|
const user = userStorage.getUser(request.session.currentUser);
|
||||||
|
return user != null && user.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fastify preHandler hook - rejects unauthenticated requests with 401.
|
||||||
|
* @param {import('fastify').FastifyRequest} request
|
||||||
|
* @param {import('fastify').FastifyReply} reply
|
||||||
|
*/
|
||||||
|
export async function authHook(request, reply) {
|
||||||
|
if (isUnauthorized(request)) {
|
||||||
|
reply.code(401).send();
|
||||||
}
|
}
|
||||||
return false;
|
}
|
||||||
};
|
|
||||||
const authInterceptor = () => {
|
/**
|
||||||
return (req, res, next) => {
|
* Fastify preHandler hook - rejects non-admin requests with 401.
|
||||||
if (isUnauthorized(req)) {
|
* Apply after authHook.
|
||||||
return unauthorized(res);
|
* @param {import('fastify').FastifyRequest} request
|
||||||
} else {
|
* @param {import('fastify').FastifyReply} reply
|
||||||
next();
|
*/
|
||||||
}
|
export async function adminHook(request, reply) {
|
||||||
};
|
if (!isAdmin(request)) {
|
||||||
};
|
reply.code(401).send();
|
||||||
const adminInterceptor = () => {
|
}
|
||||||
return (req, res, next) => {
|
}
|
||||||
if (!isAdmin(req)) {
|
|
||||||
return unauthorized(res);
|
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const cookieSession$0 = (secret) => {
|
|
||||||
return cookieSession({
|
|
||||||
name: 'fredy-admin-session',
|
|
||||||
keys: [secret],
|
|
||||||
maxAge: SESSION_MAX_AGE,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
export { cookieSession$0 as cookieSession };
|
|
||||||
export { adminInterceptor };
|
|
||||||
export { authInterceptor };
|
|
||||||
export { isUnauthorized };
|
|
||||||
export { isAdmin };
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
|
|||||||
#### Setup
|
#### Setup
|
||||||
|
|
||||||
1. Open **Claude Desktop**
|
1. Open **Claude Desktop**
|
||||||
2. Go to **Settings → Developer → Edit Config** — this opens the `claude_desktop_config.json` file
|
2. Go to **Settings → Developer → Edit Config** - this opens the `claude_desktop_config.json` file
|
||||||
3. Add the `fredy` server to the `mcpServers` object:
|
3. Add the `fredy` server to the `mcpServers` object:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -158,7 +158,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
|
|||||||
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
|
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
|
||||||
|
|
||||||
4. Save the file and **restart Claude Desktop**
|
4. Save the file and **restart Claude Desktop**
|
||||||
5. You should see a hammer icon (🔨) in the chat input — click it to confirm the Fredy tools are listed
|
5. You should see a hammer icon (🔨) in the chat input - click it to confirm the Fredy tools are listed
|
||||||
|
|
||||||
#### Usage
|
#### Usage
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ Once connected, simply ask Claude about your real estate data:
|
|||||||
|
|
||||||
Claude will automatically call the appropriate Fredy MCP tools.
|
Claude will automatically call the appropriate Fredy MCP tools.
|
||||||
|
|
||||||
> **Note:** Fredy's main web process does not need to be running — the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
|
> **Note:** Fredy's main web process does not need to be running - the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ Example list response:
|
|||||||
```
|
```
|
||||||
**Tool:** list_listings | **Status:** OK
|
**Tool:** list_listings | **Status:** OK
|
||||||
|
|
||||||
Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available — use page=2 to continue.
|
Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available - use page=2 to continue.
|
||||||
|
|
||||||
| ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|
| ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|
||||||
|----|-------|---------|-------|------|----------|--------|---------|-----|
|
|----|-------|---------|-------|------|----------|--------|---------|-----|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function createMcpServer() {
|
|||||||
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
|
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
|
||||||
'and get_listing for full details of a single listing. ' +
|
'and get_listing for full details of a single listing. ' +
|
||||||
'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' +
|
'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' +
|
||||||
'Always present results to the user as soon as you have them — do NOT call the tool again unless you need additional pages or different data.',
|
'Always present results to the user as soon as you have them - do NOT call the tool again unless you need additional pages or different data.',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright (c) 2026 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { createMcpServer } from './mcpAdapter.js';
|
import { createMcpServer } from './mcpAdapter.js';
|
||||||
import { authenticateRequest } from './mcpAuthentication.js';
|
import { authenticateRequest } from './mcpAuthentication.js';
|
||||||
@@ -15,16 +11,13 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Active transports keyed by session id.
|
* Active transports keyed by session id.
|
||||||
* Each session gets its own McpServer + StreamableHTTPServerTransport pair.
|
|
||||||
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
|
||||||
*/
|
*/
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a session for the given session id with authentication.
|
|
||||||
* @param {string|undefined} sessionId
|
* @param {string|undefined} sessionId
|
||||||
* @param {{ userId: string }} auth
|
* @param {{ userId: string }} auth
|
||||||
* @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
|
|
||||||
*/
|
*/
|
||||||
function getOrCreateSession(sessionId, auth) {
|
function getOrCreateSession(sessionId, auth) {
|
||||||
if (sessionId && sessions.has(sessionId)) {
|
if (sessionId && sessions.has(sessionId)) {
|
||||||
@@ -54,77 +47,67 @@ function getOrCreateSession(sessionId, auth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register MCP Streamable HTTP routes on a restana service.
|
* Register MCP Streamable HTTP routes on a fastify instance.
|
||||||
*
|
*
|
||||||
* Mounts handlers at /api/mcp to handle the MCP Streamable HTTP protocol:
|
* POST /api/mcp – JSON-RPC messages
|
||||||
* - POST /api/mcp – JSON-RPC messages (initialize, tool calls, etc.)
|
* GET /api/mcp – SSE stream for server-initiated notifications
|
||||||
* - GET /api/mcp – SSE stream for server-initiated notifications
|
* DELETE /api/mcp – session termination
|
||||||
* - DELETE /api/mcp – session termination
|
|
||||||
*
|
*
|
||||||
* All endpoints require a valid Bearer token in the Authorization header.
|
* All endpoints require a valid Bearer token in the Authorization header.
|
||||||
*
|
*
|
||||||
* @param {import('restana').Service} service - The restana service instance.
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
*/
|
*/
|
||||||
export function registerMcpRoutes(service) {
|
export function registerMcpRoutes(fastify) {
|
||||||
// POST – main JSON-RPC endpoint
|
fastify.post('/api/mcp', async (request, reply) => {
|
||||||
service.post('/api/mcp', async (req, res) => {
|
const auth = authenticateRequest(request.raw);
|
||||||
const auth = authenticateRequest(req);
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
res.statusCode = 401;
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = request.raw.headers['mcp-session-id'];
|
||||||
const { server, transport } = getOrCreateSession(sessionId, auth);
|
const { server, transport } = getOrCreateSession(sessionId, auth);
|
||||||
|
|
||||||
// Connect server to transport if not already connected
|
|
||||||
if (!transport.onmessage) {
|
if (!transport.onmessage) {
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject authInfo so tools can access the authenticated user
|
request.raw.auth = { userId: auth.userId };
|
||||||
req.auth = { userId: auth.userId };
|
|
||||||
|
|
||||||
await transport.handleRequest(req, res, req.body);
|
reply.hijack();
|
||||||
|
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET – SSE stream for server-initiated messages
|
fastify.get('/api/mcp', async (request, reply) => {
|
||||||
service.get('/api/mcp', async (req, res) => {
|
const auth = authenticateRequest(request.raw);
|
||||||
const auth = authenticateRequest(req);
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
res.statusCode = 401;
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = request.raw.headers['mcp-session-id'];
|
||||||
if (!sessionId || !sessions.has(sessionId)) {
|
if (!sessionId || !sessions.has(sessionId)) {
|
||||||
res.statusCode = 400;
|
return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
||||||
return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { transport } = sessions.get(sessionId);
|
const { transport } = sessions.get(sessionId);
|
||||||
await transport.handleRequest(req, res);
|
reply.hijack();
|
||||||
|
await transport.handleRequest(request.raw, reply.raw);
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE – terminate session
|
fastify.delete('/api/mcp', async (request, reply) => {
|
||||||
service.delete('/api/mcp', async (req, res) => {
|
const auth = authenticateRequest(request.raw);
|
||||||
const auth = authenticateRequest(req);
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
res.statusCode = 401;
|
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
||||||
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.headers['mcp-session-id'];
|
const sessionId = request.raw.headers['mcp-session-id'];
|
||||||
if (!sessionId || !sessions.has(sessionId)) {
|
if (!sessionId || !sessions.has(sessionId)) {
|
||||||
res.statusCode = 404;
|
return reply.code(404).send({ error: 'Session not found.' });
|
||||||
return res.send({ error: 'Session not found.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { transport } = sessions.get(sessionId);
|
const { transport } = sessions.get(sessionId);
|
||||||
await transport.close();
|
await transport.close();
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
res.statusCode = 200;
|
return { ok: true };
|
||||||
res.send({ ok: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function normalizeListJobs(queryResult, { page, pageSize }) {
|
|||||||
|
|
||||||
let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
|
let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
|
||||||
md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`;
|
md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`;
|
||||||
if (hasMore) md += ` More pages available — use page=${page + 1} to continue.`;
|
if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
|
||||||
md += '\n\n';
|
md += '\n\n';
|
||||||
|
|
||||||
if (jobs.length > 0) {
|
if (jobs.length > 0) {
|
||||||
@@ -120,7 +120,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
|
|||||||
|
|
||||||
let md = `**Tool:** list_listings | **Status:** OK\n\n`;
|
let md = `**Tool:** list_listings | **Status:** OK\n\n`;
|
||||||
md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`;
|
md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`;
|
||||||
if (hasMore) md += ` More pages available — use page=${page + 1} to continue.`;
|
if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
|
||||||
md += '\n\n';
|
md += '\n\n';
|
||||||
|
|
||||||
if (listings.length > 0) {
|
if (listings.length > 0) {
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
|
||||||
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
|
||||||
return fetch(server, {
|
return fetch(server, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
return [Promise.resolve(console.info(`Found entry from service ${serviceName}, Job: ${jobKey}:`, newListings))];
|
const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/listings/listing/${l.id}`).join(', ') : null;
|
||||||
|
return [
|
||||||
|
Promise.resolve(
|
||||||
|
console.info(
|
||||||
|
`Found entry from service ${serviceName}, Job: ${jobKey}:`,
|
||||||
|
newListings,
|
||||||
|
...(fredyLinks ? [`Open in Fredy: ${fredyLinks}`] : []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ const generateColorFromString = (str) => {
|
|||||||
*
|
*
|
||||||
* @param {string} jobKey - Key of job (used to set embed color)
|
* @param {string} jobKey - Key of job (used to set embed color)
|
||||||
* @param {object} listing - Object holding listing details
|
* @param {object} listing - Object holding listing details
|
||||||
|
* @param baseUrl
|
||||||
* @returns {object} Discord webhook embed
|
* @returns {object} Discord webhook embed
|
||||||
*/
|
*/
|
||||||
const buildEmbed = (jobKey, listing) => {
|
const buildEmbed = (jobKey, listing, baseUrl) => {
|
||||||
const maxTitleLength = 252; // Max embed title length is 256 characters
|
const maxTitleLength = 252; // Max embed title length is 256 characters
|
||||||
let title = String(listing.title ?? 'N/A');
|
let title = String(listing.title ?? 'N/A');
|
||||||
if (title.length > maxTitleLength) {
|
if (title.length > maxTitleLength) {
|
||||||
@@ -79,10 +80,18 @@ const buildEmbed = (jobKey, listing) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (baseUrl && listing.id) {
|
||||||
|
fields.push({
|
||||||
|
name: 'Open in Fredy',
|
||||||
|
value: `[Open in Fredy](${baseUrl}/listings/listing/${listing.id})`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return embed;
|
return embed;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
const webhookUrl = adapter?.fields?.webhookUrl;
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
||||||
@@ -90,7 +99,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job?.name || jobKey;
|
const jobName = job?.name || jobKey;
|
||||||
|
|
||||||
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
|
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing, baseUrl));
|
||||||
|
|
||||||
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
||||||
const webhookPromises = [];
|
const webhookPromises = [];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
|
||||||
const mapListing = (listing) => ({
|
const mapListing = (listing, baseUrl) => ({
|
||||||
address: listing.address,
|
address: listing.address,
|
||||||
description: listing.description,
|
description: listing.description,
|
||||||
id: listing.id,
|
id: listing.id,
|
||||||
@@ -14,12 +14,13 @@ const mapListing = (listing) => ({
|
|||||||
size: listing.size,
|
size: listing.size,
|
||||||
title: listing.title,
|
title: listing.title,
|
||||||
url: listing.link,
|
url: listing.link,
|
||||||
|
fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
|
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||||
|
|
||||||
const listings = newListings.map(mapListing);
|
const listings = newListings.map((l) => mapListing(l, baseUrl));
|
||||||
const body = {
|
const body = {
|
||||||
jobId: jobKey,
|
jobId: jobKey,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const toBase64 = async (url) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
|
||||||
const out = [];
|
const out = [];
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
|
|
||||||
@@ -53,6 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
|||||||
jobKey,
|
jobKey,
|
||||||
hasImage: false,
|
hasImage: false,
|
||||||
imageCid: '',
|
imageCid: '',
|
||||||
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (imgUrl) {
|
if (imgUrl) {
|
||||||
@@ -78,7 +79,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
|||||||
return { listings: out, attachments };
|
return { listings: out, attachments };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||||
(adapter) => adapter.id === config.id,
|
(adapter) => adapter.id === config.id,
|
||||||
).fields;
|
).fields;
|
||||||
@@ -89,7 +90,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
|||||||
.map((r) => ({ Email: r.trim() }))
|
.map((r) => ({ Email: r.trim() }))
|
||||||
.filter((r) => r.Email.length > 0);
|
.filter((r) => r.Email.length > 0);
|
||||||
|
|
||||||
const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings);
|
const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings, baseUrl);
|
||||||
|
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
|
|||||||
@@ -6,15 +6,20 @@
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
||||||
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`;
|
||||||
message += newListings.map(
|
message += newListings.map((o) => {
|
||||||
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n',
|
const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/listings/listing/${o.id}) |` : '';
|
||||||
);
|
return (
|
||||||
|
`| [${o.title}](${o.link}) | ` +
|
||||||
|
[o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') +
|
||||||
|
` |${fredyCell}\n`
|
||||||
|
);
|
||||||
|
});
|
||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
|
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
|
||||||
const message = `
|
const message = `
|
||||||
Address: ${newListing.address}
|
Address: ${newListing.address}
|
||||||
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
|
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
|
||||||
Price: ${newListing.price}
|
Price: ${newListing.price}
|
||||||
Link: ${newListing.link}`;
|
Link: ${newListing.link}${fredyLine}`;
|
||||||
|
|
||||||
const sanitizeHeaderValue = (value) =>
|
const sanitizeHeaderValue = (value) =>
|
||||||
String(value ?? '')
|
String(value ?? '')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
newListings.map(async (newListing) => {
|
newListings.map(async (newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
|
||||||
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('token', token);
|
form.append('token', token);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const __dirname = getDirName();
|
|||||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||||
const emailTemplate = Handlebars.compile(template);
|
const emailTemplate = Handlebars.compile(template);
|
||||||
|
|
||||||
const mapListings = (serviceName, jobKey, listings) =>
|
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||||
listings.map((l) => {
|
listings.map((l) => {
|
||||||
const image = normalizeImageUrl(l.image);
|
const image = normalizeImageUrl(l.image);
|
||||||
return {
|
return {
|
||||||
@@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
|||||||
price: l.price || '',
|
price: l.price || '',
|
||||||
image,
|
image,
|
||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
jobKey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
|
||||||
const to = receiver
|
const to = receiver
|
||||||
@@ -41,7 +42,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
|||||||
|
|
||||||
const resend = new Resend(apiKey);
|
const resend = new Resend(apiKey);
|
||||||
|
|
||||||
const listings = mapListings(serviceName, jobKey, newListings);
|
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||||
|
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import sgMail from '@sendgrid/mail';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
const mapListings = (serviceName, jobKey, listings) =>
|
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||||
listings.map((l) => {
|
listings.map((l) => {
|
||||||
const image = normalizeImageUrl(l.image);
|
const image = normalizeImageUrl(l.image);
|
||||||
return {
|
return {
|
||||||
@@ -20,12 +20,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
|||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
// optional plain text snippet
|
// optional plain text snippet
|
||||||
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
||||||
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
jobKey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
|
||||||
sgMail.setApiKey(apiKey);
|
sgMail.setApiKey(apiKey);
|
||||||
@@ -36,7 +37,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
.map((r) => r.trim())
|
.map((r) => r.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const listings = mapListings(serviceName, jobKey, newListings);
|
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||||
|
|
||||||
const msg = {
|
const msg = {
|
||||||
templateId,
|
templateId,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Slack from 'slack';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
const buildBlocks = (serviceName, jobKey, p) => {
|
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||||
const blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
type: 'header',
|
type: 'header',
|
||||||
@@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (baseUrl && p.id) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'section',
|
||||||
|
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'context',
|
type: 'context',
|
||||||
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||||
@@ -44,7 +51,7 @@ const buildBlocks = (serviceName, jobKey, p) => {
|
|||||||
return blocks;
|
return blocks;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields;
|
const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||||
|
|
||||||
return Promise.allSettled(
|
return Promise.allSettled(
|
||||||
@@ -53,7 +60,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
token,
|
token,
|
||||||
channel,
|
channel,
|
||||||
text: `${serviceName} ${jobKey}: ${p.title}`,
|
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||||
blocks: buildBlocks(serviceName, jobKey, p),
|
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
|
||||||
unfurl_links: false,
|
unfurl_links: false,
|
||||||
unfurl_media: false,
|
unfurl_media: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import fetch from 'node-fetch';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
const buildBlocks = (serviceName, jobKey, p) => {
|
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||||
const blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
type: 'header',
|
type: 'header',
|
||||||
@@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (baseUrl && p.id) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'section',
|
||||||
|
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: 'context',
|
type: 'context',
|
||||||
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||||
@@ -51,7 +58,7 @@ const postJson = (url, body) =>
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const adapter = notificationConfig.find((a) => a.id === config.id);
|
const adapter = notificationConfig.find((a) => a.id === config.id);
|
||||||
const webhookUrl = adapter?.fields?.webhookUrl;
|
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||||
if (!webhookUrl) return Promise.resolve([]);
|
if (!webhookUrl) return Promise.resolve([]);
|
||||||
@@ -59,7 +66,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
const promises = newListings.map((p) => {
|
const promises = newListings.map((p) => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
text: `${serviceName} ${jobKey}: ${p.title}`,
|
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||||
blocks: buildBlocks(serviceName, jobKey, p),
|
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
|
||||||
unfurl_links: false,
|
unfurl_links: false,
|
||||||
unfurl_media: false,
|
unfurl_media: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const __dirname = getDirName();
|
|||||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||||
const emailTemplate = Handlebars.compile(template);
|
const emailTemplate = Handlebars.compile(template);
|
||||||
|
|
||||||
const mapListings = (serviceName, jobKey, listings) =>
|
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||||
listings.map((l) => {
|
listings.map((l) => {
|
||||||
const image = normalizeImageUrl(l.image);
|
const image = normalizeImageUrl(l.image);
|
||||||
return {
|
return {
|
||||||
@@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
|||||||
price: l.price || '',
|
price: l.price || '',
|
||||||
image,
|
image,
|
||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
jobKey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
|
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
|
||||||
(adapter) => adapter.id === config.id,
|
(adapter) => adapter.id === config.id,
|
||||||
).fields;
|
).fields;
|
||||||
@@ -51,7 +52,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const listings = mapListings(serviceName, jobKey, newListings);
|
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||||
|
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Multiple recipients:
|
|||||||
|
|
||||||
Common SMTP settings:
|
Common SMTP settings:
|
||||||
|
|
||||||
- **Gmail** — `smtp.gmail.com`, port 587, secure: false
|
- **Gmail** - `smtp.gmail.com`, port 587, secure: false
|
||||||
- **Outlook** — `smtp.office365.com`, port 587, secure: false
|
- **Outlook** - `smtp.office365.com`, port 587, secure: false
|
||||||
- **Yahoo** — `smtp.mail.yahoo.com`, port 465, secure: true
|
- **Yahoo** - `smtp.mail.yahoo.com`, port 465, secure: true
|
||||||
|
- **Gmx** - `mail.gmx.net`, port 587, secure: true
|
||||||
|
|||||||
@@ -80,12 +80,14 @@ function escapeHtml(s = '') {
|
|||||||
* @param {string} [o.link]
|
* @param {string} [o.link]
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function buildCaption(jobName, serviceName, o) {
|
function buildCaption(jobName, serviceName, o, baseUrl) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
|
const fredyLink =
|
||||||
|
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
|
||||||
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
|
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
|
||||||
o.link || '',
|
o.link || '',
|
||||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}${fredyLink}`.slice(0, 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,13 +97,15 @@ function buildCaption(jobName, serviceName, o) {
|
|||||||
* @param {Object} o - Listing object
|
* @param {Object} o - Listing object
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function buildText(jobName, serviceName, o) {
|
function buildText(jobName, serviceName, o, baseUrl) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
|
const fredyLink =
|
||||||
|
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
|
||||||
return (
|
return (
|
||||||
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
|
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
|
||||||
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
|
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
|
||||||
`${escapeHtml(meta)}`
|
`${escapeHtml(meta)}${fredyLink}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +114,14 @@ function buildText(jobName, serviceName, o) {
|
|||||||
* @param {string} jobName
|
* @param {string} jobName
|
||||||
* @param {string} serviceName
|
* @param {string} serviceName
|
||||||
* @param {Object} o - Listing object
|
* @param {Object} o - Listing object
|
||||||
|
* @param baseUrl
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function buildCaptionPlain(jobName, serviceName, o) {
|
function buildCaptionPlain(jobName, serviceName, o, baseUrl) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}`.slice(0, 4096);
|
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
|
||||||
|
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,10 +131,11 @@ function buildCaptionPlain(jobName, serviceName, o) {
|
|||||||
* @param {Object} o - Listing object
|
* @param {Object} o - Listing object
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function buildTextPlain(jobName, serviceName, o) {
|
function buildTextPlain(jobName, serviceName, o, baseUrl) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}`;
|
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
|
||||||
|
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,7 +150,7 @@ function buildTextPlain(jobName, serviceName, o) {
|
|||||||
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
||||||
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
||||||
*/
|
*/
|
||||||
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey, baseUrl }) => {
|
||||||
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
if (!adapterCfg || !adapterCfg.fields) {
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||||
@@ -189,7 +196,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
const img = normalizeImageUrl(o.image);
|
const img = normalizeImageUrl(o.image);
|
||||||
const textPayload = {
|
const textPayload = {
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
text: plainText ? buildTextPlain(jobName, serviceName, o) : buildText(jobName, serviceName, o),
|
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl),
|
||||||
...(plainText ? {} : { parse_mode: 'HTML' }),
|
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: true,
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
@@ -204,7 +211,9 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
return await throttledCall('sendPhoto', {
|
return await throttledCall('sendPhoto', {
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
photo: img,
|
photo: img,
|
||||||
caption: plainText ? buildCaptionPlain(jobName, serviceName, o) : buildCaption(jobName, serviceName, o),
|
caption: plainText
|
||||||
|
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
||||||
|
: buildCaption(jobName, serviceName, o, baseUrl),
|
||||||
...(plainText ? {} : { parse_mode: 'HTML' }),
|
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
...(message_thread_id ? { message_thread_id } : {}),
|
||||||
}).catch(async (e) => {
|
}).catch(async (e) => {
|
||||||
|
|||||||
@@ -106,6 +106,9 @@
|
|||||||
<![endif]-->
|
<![endif]-->
|
||||||
<!--[if !mso]><!-- -->
|
<!--[if !mso]><!-- -->
|
||||||
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
|
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
|
||||||
|
{{#if this.fredyUrl}}
|
||||||
|
<a href="{{this.fredyUrl}}" class="btn" style="background:#1a6fff;color:#ffffff;margin-left:8px;" target="_blank">Open in Fredy</a>
|
||||||
|
{{/if}}
|
||||||
<!--<![endif]-->
|
<!--<![endif]-->
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ if (adapter.length === 0) {
|
|||||||
const findAdapter = (notificationAdapter) => {
|
const findAdapter = (notificationAdapter) => {
|
||||||
return adapter.find((a) => a.config.id === notificationAdapter.id);
|
return adapter.find((a) => a.config.id === notificationAdapter.id);
|
||||||
};
|
};
|
||||||
export const send = (serviceName, newListings, notificationConfig, jobKey) => {
|
export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => {
|
||||||
//this is not being used in tests, therefore adapter are always set
|
//this is not being used in tests, therefore adapter are always set
|
||||||
return notificationConfig
|
return notificationConfig
|
||||||
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
|
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
|
||||||
.map((notificationAdapter) => findAdapter(notificationAdapter))
|
.map((notificationAdapter) => findAdapter(notificationAdapter))
|
||||||
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey }));
|
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -87,7 +87,19 @@ const config = {
|
|||||||
crawlContainer:
|
crawlContainer:
|
||||||
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
|
'div[data-testid="serp-core-scrollablelistview-testid"]:not(div[data-testid="serp-enlargementlist-testid"] div[data-testid="serp-card-testid"]) div[data-testid="serp-core-classified-card-testid"]',
|
||||||
sortByDateParam: 'order=DateDesc',
|
sortByDateParam: 'order=DateDesc',
|
||||||
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
|
// waitForSelector is null: extract the full page via page.content() so the
|
||||||
|
// Cheerio crawler can search anywhere in the rendered document.
|
||||||
|
// preNavigateUrl visits the homepage first to establish a trusted session
|
||||||
|
// before hitting the search URL; this prevents CDN-level bot challenges that
|
||||||
|
// fire on cold sessions. waitForNetworkIdle (phase 2) then catches React's
|
||||||
|
// listing API round-trip that fires well after domcontentloaded.
|
||||||
|
waitForSelector: null,
|
||||||
|
puppeteerOptions: {
|
||||||
|
puppeteerTimeout: 60_000,
|
||||||
|
preNavigateUrl: 'https://www.immowelt.de/',
|
||||||
|
waitForNetworkIdle: true,
|
||||||
|
waitForNetworkIdleTimeout: 60_000,
|
||||||
|
},
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: 'a@href',
|
id: 'a@href',
|
||||||
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
price: 'div[data-testid="cardmfe-price-testid"] | removeNewline | trim',
|
||||||
|
|||||||
147
lib/services/ensureValidBinary.js
Normal file
147
lib/services/ensureValidBinary.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureBinary } from 'cloakbrowser';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource files required on Linux/Windows — they must live next to the chrome binary.
|
||||||
|
* macOS packages these inside the .app bundle's Frameworks directory so a different
|
||||||
|
* check is used there (see isBinaryComplete).
|
||||||
|
*/
|
||||||
|
const LINUX_WIN_REQUIRED_FILES = ['icudtl.dat', 'resources.pak'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the top-level versioned installation directory for any platform.
|
||||||
|
*
|
||||||
|
* - Linux/Windows: binaryPath is ~/.cloakbrowser/chromium-X.Y.Z/chrome
|
||||||
|
* → dirname ~/.cloakbrowser/chromium-X.Y.Z/
|
||||||
|
* - macOS: binaryPath is ~/.cloakbrowser/chromium-X.Y.Z/Chromium.app/Contents/MacOS/Chromium
|
||||||
|
* → 4 levels up ~/.cloakbrowser/chromium-X.Y.Z/
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getVersionedDir(binaryPath) {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return path.resolve(path.dirname(binaryPath), '../../..');
|
||||||
|
}
|
||||||
|
return path.dirname(binaryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when the binary at binaryPath belongs to a complete installation.
|
||||||
|
*
|
||||||
|
* On macOS the binary lives inside an .app bundle:
|
||||||
|
* Chromium.app/Contents/MacOS/Chromium
|
||||||
|
* Resource files (icudtl.dat etc.) are deep inside
|
||||||
|
* Chromium.app/Contents/Frameworks/…
|
||||||
|
* so checking for them next to the binary is wrong. Instead we verify the two
|
||||||
|
* structural markers that are only present after a full extraction: Info.plist
|
||||||
|
* and the Frameworks directory inside Contents/.
|
||||||
|
*
|
||||||
|
* On Linux/Windows the binary and all resource files are siblings in the same
|
||||||
|
* directory.
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isBinaryComplete(binaryPath) {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const contentsDir = path.resolve(path.dirname(binaryPath), '..');
|
||||||
|
return fs.existsSync(path.join(contentsDir, 'Info.plist')) && fs.existsSync(path.join(contentsDir, 'Frameworks'));
|
||||||
|
}
|
||||||
|
const dir = path.dirname(binaryPath);
|
||||||
|
return LINUX_WIN_REQUIRED_FILES.every((f) => fs.existsSync(path.join(dir, f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a human-readable description of which required files/dirs are missing.
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function missingDescription(binaryPath) {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const contentsDir = path.resolve(path.dirname(binaryPath), '..');
|
||||||
|
return ['Info.plist', 'Frameworks'].filter((f) => !fs.existsSync(path.join(contentsDir, f))).join(', ');
|
||||||
|
}
|
||||||
|
const dir = path.dirname(binaryPath);
|
||||||
|
return LINUX_WIN_REQUIRED_FILES.filter((f) => !fs.existsSync(path.join(dir, f))).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a corrupt binary installation and all `latest_version*` markers from
|
||||||
|
* the CloakBrowser cache so the next `ensureBinary()` call falls back to the
|
||||||
|
* package-bundled version.
|
||||||
|
*
|
||||||
|
* Removes the full versioned directory (e.g. chromium-X.Y.Z/) on all platforms,
|
||||||
|
* not just the subdirectory that contains the binary.
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath - Path to the (corrupt) chrome/Chromium binary.
|
||||||
|
*/
|
||||||
|
function removeCorruptInstallation(binaryPath) {
|
||||||
|
const versionedDir = getVersionedDir(binaryPath);
|
||||||
|
const cacheDir = process.env.CLOAKBROWSER_CACHE_DIR || path.join(os.homedir(), '.cloakbrowser');
|
||||||
|
|
||||||
|
fs.rmSync(versionedDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const entry of fs.readdirSync(cacheDir)) {
|
||||||
|
if (entry.startsWith('latest_version')) {
|
||||||
|
fs.rmSync(path.join(cacheDir, entry), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache dir may not exist if versionedDir was the only entry — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the CloakBrowser stealth Chromium binary is present **and** complete.
|
||||||
|
*
|
||||||
|
* `cloakbrowser`'s own `ensureBinary()` only checks that the chrome/Chromium
|
||||||
|
* file exists. An incomplete extraction (e.g. interrupted download, disk full)
|
||||||
|
* can leave a directory that contains the executable but is missing essential
|
||||||
|
* resource files. Chrome then crashes immediately on launch.
|
||||||
|
*
|
||||||
|
* This wrapper validates the path returned by `ensureBinary()`. If the
|
||||||
|
* installation is incomplete it removes the corrupt directory, clears the
|
||||||
|
* version marker files, and calls `ensureBinary()` again so it falls back to
|
||||||
|
* (or re-downloads) a complete build.
|
||||||
|
*
|
||||||
|
* The validated path is also pinned via `CLOAKBROWSER_BINARY_PATH` so that
|
||||||
|
* CloakBrowser's own internal `ensureBinary()` call inside `launch()` always
|
||||||
|
* picks up the same, verified binary.
|
||||||
|
*
|
||||||
|
* @returns {Promise<string>} Absolute path to the validated binary.
|
||||||
|
* @throws {Error} When even the fallback binary is incomplete.
|
||||||
|
*/
|
||||||
|
export async function ensureValidBinary() {
|
||||||
|
const binaryPath = await ensureBinary();
|
||||||
|
|
||||||
|
if (isBinaryComplete(binaryPath)) {
|
||||||
|
process.env.CLOAKBROWSER_BINARY_PATH = binaryPath;
|
||||||
|
return binaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[fredy] CloakBrowser installation at ${getVersionedDir(binaryPath)} is missing: ${missingDescription(binaryPath)}. Removing and retrying.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
removeCorruptInstallation(binaryPath);
|
||||||
|
|
||||||
|
const fallbackPath = await ensureBinary();
|
||||||
|
if (!isBinaryComplete(fallbackPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`CloakBrowser binary at ${getVersionedDir(fallbackPath)} is still missing required files after re-download: ${missingDescription(fallbackPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.CLOAKBROWSER_BINARY_PATH = fallbackPath;
|
||||||
|
return fallbackPath;
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
// webdriver
|
// webdriver
|
||||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||||
|
|
||||||
// chrome runtime — expose loadTimes, csi and app like real Chrome
|
// chrome runtime - expose loadTimes, csi and app like real Chrome
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.chrome = {
|
window.chrome = {
|
||||||
runtime: {},
|
runtime: {},
|
||||||
@@ -129,7 +129,7 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
|
||||||
});
|
});
|
||||||
|
|
||||||
// plugins — mimic real Chrome's built-in PDF plugins
|
// plugins - mimic real Chrome's built-in PDF plugins
|
||||||
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
|
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
|
||||||
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
|
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
|
||||||
const plugin = { name, filename, description, length: 1, 0: mimeObj };
|
const plugin = { name, filename, description, length: 1, 0: mimeObj };
|
||||||
@@ -274,14 +274,14 @@ export async function applyBotPreventionToPage(page, cfg) {
|
|||||||
//noop
|
//noop
|
||||||
}
|
}
|
||||||
|
|
||||||
// document.hasFocus — headless returns false; real active tabs return true
|
// document.hasFocus - headless returns false; real active tabs return true
|
||||||
try {
|
try {
|
||||||
document.hasFocus = () => true;
|
document.hasFocus = () => true;
|
||||||
} catch {
|
} catch {
|
||||||
//noop
|
//noop
|
||||||
}
|
}
|
||||||
|
|
||||||
// screen color depth — normalise in case headless reports 0
|
// screen color depth - normalise in case headless reports 0
|
||||||
try {
|
try {
|
||||||
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
|
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
|
||||||
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
|
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
|
||||||
|
|||||||
@@ -3,121 +3,133 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer-extra';
|
import { launch } from 'cloakbrowser/puppeteer';
|
||||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
|
||||||
import { debug, botDetected } from './utils.js';
|
import { debug, botDetected } from './utils.js';
|
||||||
import {
|
import { getPreLaunchConfig } from './botPrevention.js';
|
||||||
getPreLaunchConfig,
|
|
||||||
applyBotPreventionToPage,
|
|
||||||
applyLanguagePersistence,
|
|
||||||
applyPostNavigationHumanSignals,
|
|
||||||
} from './botPrevention.js';
|
|
||||||
import logger from '../logger.js';
|
import logger from '../logger.js';
|
||||||
import fs from 'fs';
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
puppeteer.use(StealthPlugin());
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a CloakBrowser/Puppeteer browser instance with stealth and humanizer enabled.
|
||||||
|
*
|
||||||
|
* CloakBrowser applies 49 C++ source-level patches (canvas, WebGL, audio, WebRTC,
|
||||||
|
* navigator.*, automation signals) that are indistinguishable from a real browser.
|
||||||
|
* All fingerprinting and human-behaviour simulation is handled natively; no CDP
|
||||||
|
* overrides (setUserAgent, setExtraHTTPHeaders, evaluateOnNewDocument) are applied
|
||||||
|
* here because they would create detectable inconsistencies on top of the C++ patches.
|
||||||
|
*
|
||||||
|
* @param {string} url - Initial URL (used to derive locale/timezone hints).
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.puppeteerHeadless]
|
||||||
|
* @param {number} [options.puppeteerTimeout]
|
||||||
|
* @param {string} [options.proxyUrl]
|
||||||
|
* @param {string} [options.timezone]
|
||||||
|
* @param {string} [options.acceptLanguage]
|
||||||
|
* @param {object} [options.viewport]
|
||||||
|
* @returns {Promise<import('puppeteer-core').Browser>}
|
||||||
|
*/
|
||||||
export async function launchBrowser(url, options) {
|
export async function launchBrowser(url, options) {
|
||||||
const preCfg = getPreLaunchConfig(url, options || {});
|
const preCfg = getPreLaunchConfig(url, options || {});
|
||||||
const launchArgs = [
|
|
||||||
|
// Docker requires --no-sandbox; CloakBrowser handles all stealth args internally.
|
||||||
|
// --ignore-certificate-errors is needed because CloakBrowser ships its own Chromium
|
||||||
|
// binary with an independent CA bundle that may not trust proxies or interceptors
|
||||||
|
// present in the host environment.
|
||||||
|
const args = [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-gpu',
|
|
||||||
'--disable-setuid-sandbox',
|
'--disable-setuid-sandbox',
|
||||||
'--disable-dev-shm-usage',
|
'--disable-dev-shm-usage',
|
||||||
'--disable-crash-reporter',
|
|
||||||
'--no-first-run',
|
'--no-first-run',
|
||||||
'--no-default-browser-check',
|
'--no-default-browser-check',
|
||||||
preCfg.langArg,
|
'--ignore-certificate-errors',
|
||||||
|
// Disables the zygote process model. Required in some container environments
|
||||||
|
// (e.g. limited kernel namespaces) where the zygote cannot acquire the
|
||||||
|
// locks it needs and exits with "Invalid file descriptor to ICU data received".
|
||||||
|
'--no-zygote',
|
||||||
preCfg.windowSizeArg,
|
preCfg.windowSizeArg,
|
||||||
...preCfg.extraArgs,
|
|
||||||
];
|
];
|
||||||
if (options?.proxyUrl) {
|
|
||||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let userDataDir;
|
const browser = await launch({
|
||||||
let removeUserDataDir = false;
|
|
||||||
if (options && options.userDataDir) {
|
|
||||||
userDataDir = options.userDataDir;
|
|
||||||
} else {
|
|
||||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
|
||||||
userDataDir = fs.mkdtempSync(prefix);
|
|
||||||
removeUserDataDir = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// On ARM64 Docker, Chrome for Testing has no native binary — use system Chromium instead.
|
|
||||||
const executablePath =
|
|
||||||
options?.executablePath ||
|
|
||||||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);
|
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
|
||||||
headless: options?.puppeteerHeadless ?? true,
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
args: launchArgs,
|
humanize: true,
|
||||||
timeout: options?.puppeteerTimeout || 45_000,
|
args,
|
||||||
userDataDir,
|
// locale sets Accept-Language headers and JS navigator.language consistently
|
||||||
executablePath,
|
locale: preCfg.langForFlag,
|
||||||
|
...(options?.proxyUrl ? { proxy: options.proxyUrl } : {}),
|
||||||
|
...(preCfg.timezone ? { timezone: preCfg.timezone } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
browser.__fredy_userDataDir = userDataDir;
|
|
||||||
browser.__fredy_removeUserDataDir = removeUserDataDir;
|
|
||||||
|
|
||||||
return browser;
|
return browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a browser instance returned by {@link launchBrowser}.
|
||||||
|
*
|
||||||
|
* @param {import('puppeteer-core').Browser | null} browser
|
||||||
|
*/
|
||||||
export async function closeBrowser(browser) {
|
export async function closeBrowser(browser) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const userDataDir = browser.__fredy_userDataDir;
|
|
||||||
const removeUserDataDir = browser.__fredy_removeUserDataDir;
|
|
||||||
try {
|
try {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
if (removeUserDataDir && userDataDir) {
|
|
||||||
try {
|
|
||||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a page in a (possibly reused) browser, navigate to `url`, and return the HTML source.
|
||||||
|
* Returns `null` when a bot-detection page is encountered or on timeout.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string | null} waitForSelector
|
||||||
|
* @param {object} [options]
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
export default async function execute(url, waitForSelector, options) {
|
export default async function execute(url, waitForSelector, options) {
|
||||||
let browser = options?.browser;
|
let browser = options?.browser;
|
||||||
let isExternalBrowser = !!browser;
|
let isExternalBrowser = !!browser;
|
||||||
let page;
|
let page;
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
debug(`Sending request to ${url} using Puppeteer.`);
|
debug(`Sending request to ${url} using CloakBrowser.`);
|
||||||
|
|
||||||
if (!isExternalBrowser) {
|
if (!isExternalBrowser) {
|
||||||
browser = await launchBrowser(url, options);
|
browser = await launchBrowser(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
const preCfg = getPreLaunchConfig(url, options || {});
|
|
||||||
await applyBotPreventionToPage(page, preCfg);
|
|
||||||
// Provide languages value before navigation
|
|
||||||
await applyLanguagePersistence(page, preCfg);
|
|
||||||
|
|
||||||
// Optional cookies
|
|
||||||
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
|
if (Array.isArray(options?.cookies) && options.cookies.length > 0) {
|
||||||
await page.setCookie(...options.cookies);
|
await page.setCookie(...options.cookies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation
|
// Warm-up navigation: visit a trusted page first so the site sees an
|
||||||
|
// established session before the actual target URL. Silently ignored on
|
||||||
|
// failure so it never blocks the main request.
|
||||||
|
if (options?.preNavigateUrl) {
|
||||||
|
try {
|
||||||
|
await page.goto(options.preNavigateUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||||
|
await new Promise((r) => setTimeout(r, 1500 + Math.random() * 2000));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await page.goto(url, {
|
const response = await page.goto(url, {
|
||||||
waitUntil: options?.waitUntil || 'domcontentloaded',
|
waitUntil: options?.waitUntil || 'domcontentloaded',
|
||||||
timeout: options?.puppeteerTimeout || 60000,
|
timeout: options?.puppeteerTimeout || 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optionally wait and add subtle human-like interactions
|
// Optional second idle wait: useful for React SPAs that trigger API calls
|
||||||
await applyPostNavigationHumanSignals(page, preCfg);
|
// after domcontentloaded. Times out silently so we use whatever is rendered.
|
||||||
|
if (options?.waitForNetworkIdle) {
|
||||||
|
try {
|
||||||
|
await page.waitForNetworkIdle({ timeout: options?.waitForNetworkIdleTimeout ?? 60_000 });
|
||||||
|
} catch {
|
||||||
|
// ignore — we proceed with whatever the DOM contains at this point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let pageSource;
|
let pageSource;
|
||||||
// if we're extracting data from a SPA, we must wait for the selector
|
|
||||||
if (waitForSelector != null) {
|
if (waitForSelector != null) {
|
||||||
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
const selectorTimeout = options?.puppeteerSelectorTimeout ?? options?.puppeteerTimeout ?? 30_000;
|
||||||
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
await page.waitForSelector(waitForSelector, { timeout: selectorTimeout });
|
||||||
@@ -139,9 +151,9 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name?.includes('Timeout')) {
|
if (error?.name?.includes('Timeout')) {
|
||||||
logger.debug('Error executing with puppeteer executor', error);
|
logger.debug('Error executing with CloakBrowser executor', error);
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Error executing with puppeteer executor', error);
|
logger.warn('Error executing with CloakBrowser executor', error);
|
||||||
}
|
}
|
||||||
result = null;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -168,10 +168,6 @@ export function convertWebToMobile(webUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segments.includes('shape')) {
|
|
||||||
throw new Error('Shape is currently not supported using Immoscout');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { query: rawParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' });
|
const { query: rawParams } = queryString.parseUrl(webUrl, { arrayFormat: 'comma' });
|
||||||
const webParams = Object.fromEntries(
|
const webParams = Object.fromEntries(
|
||||||
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
||||||
@@ -179,18 +175,31 @@ export function convertWebToMobile(webUrl) {
|
|||||||
|
|
||||||
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
|
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
|
||||||
const isRadius = segments.includes('radius');
|
const isRadius = segments.includes('radius');
|
||||||
|
const isShape = segments.includes('shape');
|
||||||
const mobileParams = {
|
const mobileParams = {
|
||||||
searchType: isRadius ? 'radius' : 'region',
|
searchType: isRadius ? 'radius' : isShape ? 'shape' : 'region',
|
||||||
realestatetype: realType,
|
realestatetype: realType,
|
||||||
...(isRadius ? {} : { geocodes }),
|
...(isRadius || isShape ? {} : { geocodes }),
|
||||||
...additionalParamsFromWebPath,
|
...additionalParamsFromWebPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isShape && !webParams.shape) {
|
||||||
|
throw new Error('Shape search URL is missing the required "shape" query parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShape && webParams.shape) {
|
||||||
|
const browserShape = webParams.shape;
|
||||||
|
const normalized = browserShape.replace(/\.\./g, '==').replace(/\./g, '=');
|
||||||
|
const polyline = Buffer.from(normalized, 'base64').toString('utf-8');
|
||||||
|
mobileParams.shape = polyline;
|
||||||
|
}
|
||||||
|
|
||||||
if (webParams.geocoordinates) {
|
if (webParams.geocoordinates) {
|
||||||
mobileParams.geocoordinates = webParams.geocoordinates;
|
mobileParams.geocoordinates = webParams.geocoordinates;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(webParams)) {
|
for (const [key, val] of Object.entries(webParams)) {
|
||||||
|
if (key === 'shape') continue;
|
||||||
if (key === 'equipment') {
|
if (key === 'equipment') {
|
||||||
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
|
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
|
||||||
const currentEquipmentParams = mobileParams[PARAM_NAME_MAP[key]];
|
const currentEquipmentParams = mobileParams[PARAM_NAME_MAP[key]];
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { guessBaseUrl } from '../../../../utils/detectBaseUrl.js';
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
const exists = db.prepare(`SELECT 1 FROM settings WHERE name = 'baseUrl' AND user_id IS NULL LIMIT 1`).get();
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
const portRow = db.prepare(`SELECT value FROM settings WHERE name = 'port' AND user_id IS NULL LIMIT 1`).get();
|
||||||
|
let port = 9998;
|
||||||
|
try {
|
||||||
|
port = JSON.parse(portRow?.value ?? '9998');
|
||||||
|
} catch {
|
||||||
|
/* keep default */
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||||
|
VALUES (@id, @create_date, 'baseUrl', @value, NULL)`,
|
||||||
|
).run({ id: nanoid(), create_date: Date.now(), value: JSON.stringify(guessBaseUrl(port)) });
|
||||||
|
}
|
||||||
55
lib/utils/detectBaseUrl.js
Normal file
55
lib/utils/detectBaseUrl.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import os from 'os';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const DOCKER_BRIDGE_PREFIXES = ['172.17.', '172.18.', '172.19.', '172.20.'];
|
||||||
|
|
||||||
|
export function isRunningInDocker() {
|
||||||
|
if (process.env.FREDY_DOCKER === '1') return true;
|
||||||
|
try {
|
||||||
|
fs.accessSync('/.dockerenv');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* not docker */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cgroup = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||||
|
return /docker|containerd|kubepods/.test(cgroup);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDockerBridgeIp(addr) {
|
||||||
|
return DOCKER_BRIDGE_PREFIXES.some((prefix) => addr.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectLocalIp() {
|
||||||
|
if (isRunningInDocker()) {
|
||||||
|
return process.env.FREDY_HOST_IP ?? '172.17.0.1';
|
||||||
|
}
|
||||||
|
const ifaces = os.networkInterfaces();
|
||||||
|
for (const preferred of ['en0', 'eth0', 'wlan0', 'ens3', 'ens18']) {
|
||||||
|
for (const entry of ifaces[preferred] ?? []) {
|
||||||
|
if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) {
|
||||||
|
return entry.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const iface of Object.values(ifaces)) {
|
||||||
|
for (const entry of iface ?? []) {
|
||||||
|
if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) {
|
||||||
|
return entry.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guessBaseUrl(port) {
|
||||||
|
return `http://${detectLocalIp()}:${port}`;
|
||||||
|
}
|
||||||
50
package.json
50
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "20.3.3",
|
"version": "22.0.0",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -62,9 +62,13 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.95.1",
|
"@douyinfe/semi-icons": "^2.96.1",
|
||||||
"@douyinfe/semi-ui": "2.95.1",
|
"@douyinfe/semi-ui": "2.96.1",
|
||||||
"@douyinfe/semi-ui-19": "^2.95.1",
|
"@douyinfe/semi-ui-19": "^2.96.1",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/helmet": "^13.0.2",
|
||||||
|
"@fastify/session": "^11.1.1",
|
||||||
|
"@fastify/static": "^9.1.3",
|
||||||
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
@@ -72,55 +76,51 @@
|
|||||||
"@vitejs/plugin-react": "6.0.1",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"body-parser": "2.2.2",
|
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"cookie-session": "2.1.1",
|
"cloakbrowser": "^0.3.27",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
"handlebars": "4.7.9",
|
"handlebars": "4.7.9",
|
||||||
"maplibre-gl": "^5.23.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
"nanoid": "5.1.9",
|
"nanoid": "5.1.11",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.7",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.42.0",
|
"puppeteer-core": "^24.43.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "19.2.5",
|
"react": "19.2.6",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.6",
|
||||||
"react-range-slider-input": "^3.3.5",
|
"react-range-slider-input": "^3.3.5",
|
||||||
"react-router": "7.14.1",
|
"react-router": "7.15.0",
|
||||||
"react-router-dom": "7.14.1",
|
"react-router-dom": "7.15.0",
|
||||||
"resend": "^6.12.2",
|
"resend": "^6.12.3",
|
||||||
"restana": "5.2.0",
|
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"serve-static": "2.2.1",
|
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "8.0.9",
|
"vite": "8.0.11",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.29.0",
|
"@babel/core": "7.29.0",
|
||||||
"@babel/eslint-parser": "7.28.6",
|
"@babel/eslint-parser": "7.28.6",
|
||||||
"@babel/preset-env": "7.29.2",
|
"@babel/preset-env": "7.29.5",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "10.2.1",
|
"eslint": "10.3.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.6.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.6.4",
|
"less": "4.6.4",
|
||||||
"lint-staged": "16.4.0",
|
"lint-staged": "16.4.0",
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
test/globalSetup.js
Normal file
18
test/globalSetup.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureValidBinary } from '../lib/services/ensureValidBinary.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vitest global setup — runs once in the main process before any workers start.
|
||||||
|
* Downloads and validates the CloakBrowser stealth Chromium binary.
|
||||||
|
* ensureValidBinary() also removes and re-downloads partial/corrupt installations
|
||||||
|
* so tests never fail with "Invalid file descriptor to ICU data received".
|
||||||
|
* Skipped in offline mode because the browser is fully mocked there.
|
||||||
|
*/
|
||||||
|
export async function setup() {
|
||||||
|
if (process.env.TEST_MODE === 'offline') return;
|
||||||
|
await ensureValidBinary();
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ export function getUserSettings(userId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
return { baseUrl: '' };
|
||||||
|
}
|
||||||
|
|
||||||
export const updateListingDistance = (id, distance) => {
|
export const updateListingDistance = (id, distance) => {
|
||||||
// noop
|
// noop
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,83 +6,89 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import { expect, vi } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||||
import * as mockStore from '../mocks/mockStore.js';
|
import { launchBrowser, closeBrowser } from '../../lib/services/extractor/puppeteerExtractor.js';
|
||||||
|
|
||||||
|
// One browser shared across the whole suite so both requests (search + detail)
|
||||||
|
// come from the same warm session, avoiding double cold-start bot detection.
|
||||||
|
const TEST_TIMEOUT = 120_000;
|
||||||
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
provider.init(providerConfig.immobilienDe, [], []);
|
provider.init(providerConfig.immobilienDe, [], []);
|
||||||
it('should test immobilien.de provider', async () => {
|
|
||||||
const mockedJob = {
|
|
||||||
id: 'test1',
|
|
||||||
notificationAdapter: null,
|
|
||||||
spatialFilter: null,
|
|
||||||
specFilter: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Fredy = await mockFredy();
|
let browser;
|
||||||
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
let liveListings;
|
||||||
const listing = await fredy.execute();
|
|
||||||
|
|
||||||
if (listing == null || listing.length === 0) {
|
beforeAll(async () => {
|
||||||
throw new Error('Listings is empty!');
|
browser = await launchBrowser(providerConfig.immobilienDe.url);
|
||||||
}
|
}, TEST_TIMEOUT);
|
||||||
|
|
||||||
expect(listing).toBeInstanceOf(Array);
|
afterAll(async () => {
|
||||||
const notificationObj = get();
|
await closeBrowser(browser);
|
||||||
expect(notificationObj).toBeTypeOf('object');
|
|
||||||
expect(notificationObj.serviceName).toBe('immobilienDe');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).toBeTypeOf('string');
|
|
||||||
expect(notify.price).toBeTypeOf('string');
|
|
||||||
expect(notify.size).toBeTypeOf('string');
|
|
||||||
expect(notify.title).toBeTypeOf('string');
|
|
||||||
expect(notify.link).toBeTypeOf('string');
|
|
||||||
expect(notify.address).toBeTypeOf('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
expect(notify.price).toContain('€');
|
|
||||||
expect(notify.size).toContain('m²');
|
|
||||||
expect(notify.title).not.toBe('');
|
|
||||||
expect(notify.link).toContain('https://www.immobilien.de');
|
|
||||||
expect(notify.address).not.toBe('');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should test immobilien.de provider',
|
||||||
|
async () => {
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'test1',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, browser);
|
||||||
|
liveListings = await fredy.execute();
|
||||||
|
|
||||||
|
if (liveListings == null || liveListings.length === 0) {
|
||||||
|
throw new Error('Listings is empty!');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(liveListings).toBeInstanceOf(Array);
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
|
expect(notificationObj.serviceName).toBe('immobilienDe');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).toBeTypeOf('string');
|
||||||
|
expect(notify.price).toBeTypeOf('string');
|
||||||
|
expect(notify.size).toBeTypeOf('string');
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.price).toContain('€');
|
||||||
|
expect(notify.size).toContain('m²');
|
||||||
|
expect(notify.title).not.toBe('');
|
||||||
|
expect(notify.link).toContain('https://www.immobilien.de');
|
||||||
|
expect(notify.address).not.toBe('');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
describe('with provider_details enabled', () => {
|
describe('with provider_details enabled', () => {
|
||||||
beforeEach(() => {
|
it(
|
||||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
'should enrich listings with details',
|
||||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
async () => {
|
||||||
});
|
if (!liveListings?.length) throw new Error('No listings from first test to enrich');
|
||||||
|
|
||||||
afterEach(() => {
|
// Call fetchDetails directly on the first live listing — no need to
|
||||||
vi.restoreAllMocks();
|
// re-scrape the search page. The shared browser keeps the session warm.
|
||||||
});
|
const enriched = await provider.config.fetchDetails(liveListings[0], browser);
|
||||||
|
|
||||||
it('should enrich listings with details', async () => {
|
if (enriched == null) return;
|
||||||
const Fredy = await mockFredy();
|
expect(enriched.link).toContain('https://www.immobilien.de');
|
||||||
provider.init(providerConfig.immobilienDe, [], []);
|
expect(enriched.address).toBeTypeOf('string');
|
||||||
const mockedJob = { id: 'test1', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
expect(enriched.address).not.toBe('');
|
||||||
|
|
||||||
const fredy = new Fredy(
|
|
||||||
provider.config,
|
|
||||||
mockedJob,
|
|
||||||
provider.metaInformation.id,
|
|
||||||
{ checkAndAddEntry: () => false },
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const listings = await fredy.execute();
|
|
||||||
if (listings == null) return;
|
|
||||||
expect(listings).toBeInstanceOf(Array);
|
|
||||||
listings.forEach((listing) => {
|
|
||||||
expect(listing.link).toContain('https://www.immobilien.de');
|
|
||||||
expect(listing.address).toBeTypeOf('string');
|
|
||||||
expect(listing.address).not.toBe('');
|
|
||||||
// description may be null if selectors don't match yet — falls back gracefully
|
// description may be null if selectors don't match yet — falls back gracefully
|
||||||
if (listing.description != null) {
|
if (enriched.description != null) {
|
||||||
expect(listing.description).toBeTypeOf('string');
|
expect(enriched.description).toBeTypeOf('string');
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
});
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,85 +3,85 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, vi } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import * as provider from '../../lib/provider/immoscout.js';
|
import * as provider from '../../lib/provider/immoscout.js';
|
||||||
import * as mockStore from '../mocks/mockStore.js';
|
|
||||||
|
// immoscout uses the mobile REST API (fetch-based, no browser). Both tests share
|
||||||
|
// the same module-level listings so the API is only queried once, avoiding
|
||||||
|
// duplicate requests that could trigger rate-limiting.
|
||||||
|
const TEST_TIMEOUT = 120_000;
|
||||||
|
|
||||||
describe('#immoscout provider testsuite()', () => {
|
describe('#immoscout provider testsuite()', () => {
|
||||||
provider.init(providerConfig.immoscout, [], []);
|
provider.init(providerConfig.immoscout, [], []);
|
||||||
it('should test immoscout provider', async () => {
|
|
||||||
const Fredy = await mockFredy();
|
|
||||||
const mockedJob = {
|
|
||||||
id: '',
|
|
||||||
notificationAdapter: null,
|
|
||||||
spatialFilter: null,
|
|
||||||
specFilter: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
let liveListings;
|
||||||
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
|
||||||
fredy.execute().then((listings) => {
|
|
||||||
if (listings == null || listings.length === 0) {
|
|
||||||
reject('Listings is empty!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(listings).toBeInstanceOf(Array);
|
it(
|
||||||
const notificationObj = get();
|
'should test immoscout provider',
|
||||||
expect(notificationObj).toBeTypeOf('object');
|
async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: '',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
// check if there is at least one valid notification
|
return await new Promise((resolve, reject) => {
|
||||||
const hasValidNotification = notificationObj.payload.some((notify) => {
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
||||||
return (
|
fredy.execute().then((listings) => {
|
||||||
typeof notify.id === 'string' &&
|
if (listings == null || listings.length === 0) {
|
||||||
typeof notify.price === 'string' &&
|
reject('Listings is empty!');
|
||||||
notify.price.includes('€') &&
|
return;
|
||||||
typeof notify.size === 'string' &&
|
}
|
||||||
notify.size.includes('m²') &&
|
|
||||||
typeof notify.title === 'string' &&
|
liveListings = listings;
|
||||||
notify.title !== '' &&
|
expect(listings).toBeInstanceOf(Array);
|
||||||
typeof notify.link === 'string' &&
|
const notificationObj = get();
|
||||||
notify.link.includes('https://www.immobilienscout24.de/') &&
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
typeof notify.address === 'string'
|
|
||||||
);
|
// check if there is at least one valid notification
|
||||||
|
const hasValidNotification = notificationObj.payload.some((notify) => {
|
||||||
|
return (
|
||||||
|
typeof notify.id === 'string' &&
|
||||||
|
typeof notify.price === 'string' &&
|
||||||
|
notify.price.includes('€') &&
|
||||||
|
typeof notify.size === 'string' &&
|
||||||
|
notify.size.includes('m²') &&
|
||||||
|
typeof notify.title === 'string' &&
|
||||||
|
notify.title !== '' &&
|
||||||
|
typeof notify.link === 'string' &&
|
||||||
|
notify.link.includes('https://www.immobilienscout24.de/') &&
|
||||||
|
typeof notify.address === 'string'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasValidNotification).toBe(true);
|
||||||
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(hasValidNotification).toBe(true);
|
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
});
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
describe('with provider_details enabled', () => {
|
describe('with provider_details enabled', () => {
|
||||||
beforeEach(() => {
|
it(
|
||||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
'should enrich listings with details',
|
||||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
async () => {
|
||||||
});
|
if (!liveListings?.length) throw new Error('No listings from first test to enrich');
|
||||||
|
|
||||||
afterEach(() => {
|
// Call fetchDetails directly on the first live listing — no need to
|
||||||
vi.restoreAllMocks();
|
// re-query the search API. immoscout uses fetch (no browser).
|
||||||
});
|
const enriched = await provider.config.fetchDetails(liveListings[0]);
|
||||||
|
|
||||||
it('should enrich listings with details', async () => {
|
expect(enriched).toBeTruthy();
|
||||||
const Fredy = await mockFredy();
|
expect(enriched.description).toBeTypeOf('string');
|
||||||
provider.init(providerConfig.immoscout, [], []);
|
expect(enriched.description).not.toBe('');
|
||||||
const mockedJob = { id: '', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
},
|
||||||
const fredy = new Fredy(
|
TEST_TIMEOUT,
|
||||||
provider.config,
|
);
|
||||||
mockedJob,
|
|
||||||
provider.metaInformation.id,
|
|
||||||
{ checkAndAddEntry: () => false },
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const listings = await fredy.execute();
|
|
||||||
expect(listings).toBeInstanceOf(Array);
|
|
||||||
listings.forEach((listing) => {
|
|
||||||
expect(listing.description).toBeTypeOf('string');
|
|
||||||
expect(listing.description).not.toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,87 +6,95 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect, vi } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/immowelt.js';
|
import * as provider from '../../lib/provider/immowelt.js';
|
||||||
import * as mockStore from '../mocks/mockStore.js';
|
import { launchBrowser, closeBrowser } from '../../lib/services/extractor/puppeteerExtractor.js';
|
||||||
|
|
||||||
|
// One browser shared across the whole suite so both requests (search + detail)
|
||||||
|
// come from the same warm session. Immowelt's CDN challenges cold sessions
|
||||||
|
// aggressively; a shared warm browser prevents the second request from being
|
||||||
|
// blocked as a bot hit.
|
||||||
|
const TEST_TIMEOUT = 180_000;
|
||||||
|
|
||||||
describe('#immowelt testsuite()', () => {
|
describe('#immowelt testsuite()', () => {
|
||||||
it('should test immowelt provider', async () => {
|
let browser;
|
||||||
const Fredy = await mockFredy();
|
let liveListings;
|
||||||
const mockedJob = {
|
|
||||||
id: 'immowelt',
|
|
||||||
notificationAdapter: null,
|
|
||||||
spatialFilter: null,
|
|
||||||
specFilter: null,
|
|
||||||
};
|
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
beforeAll(async () => {
|
||||||
|
browser = await launchBrowser(providerConfig.immowelt.url);
|
||||||
|
}, TEST_TIMEOUT);
|
||||||
|
|
||||||
const listing = await fredy.execute();
|
afterAll(async () => {
|
||||||
|
await closeBrowser(browser);
|
||||||
if (listing == null || listing.length === 0) {
|
|
||||||
throw new Error('Listings is empty!');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(listing).toBeInstanceOf(Array);
|
|
||||||
const notificationObj = get();
|
|
||||||
expect(notificationObj).toBeTypeOf('object');
|
|
||||||
expect(notificationObj.serviceName).toBe('immowelt');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).toBeTypeOf('string');
|
|
||||||
if (notify.price != null) {
|
|
||||||
expect(notify.price).toBeTypeOf('string');
|
|
||||||
expect(notify.price).toContain('€');
|
|
||||||
}
|
|
||||||
expect(notify.title).toBeTypeOf('string');
|
|
||||||
expect(notify.link).toBeTypeOf('string');
|
|
||||||
expect(notify.address).toBeTypeOf('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
|
||||||
expect(notify.size).toBeTypeOf('string');
|
|
||||||
expect(notify.size).toContain('m²');
|
|
||||||
}
|
|
||||||
expect(notify.title).not.toBe('');
|
|
||||||
expect(notify.link).toContain('https://www.immowelt.de');
|
|
||||||
expect(notify.address).not.toBe('');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should test immowelt provider',
|
||||||
|
async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'immowelt',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, browser);
|
||||||
|
|
||||||
|
liveListings = await fredy.execute();
|
||||||
|
|
||||||
|
if (liveListings == null || liveListings.length === 0) {
|
||||||
|
throw new Error('Listings is empty!');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(liveListings).toBeInstanceOf(Array);
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
|
expect(notificationObj.serviceName).toBe('immowelt');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).toBeTypeOf('string');
|
||||||
|
if (notify.price != null) {
|
||||||
|
expect(notify.price).toBeTypeOf('string');
|
||||||
|
expect(notify.price).toContain('€');
|
||||||
|
}
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
||||||
|
expect(notify.size).toBeTypeOf('string');
|
||||||
|
expect(notify.size).toContain('m²');
|
||||||
|
}
|
||||||
|
expect(notify.title).not.toBe('');
|
||||||
|
expect(notify.link).toContain('https://www.immowelt.de');
|
||||||
|
expect(notify.address).not.toBe('');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
describe('with provider_details enabled', () => {
|
describe('with provider_details enabled', () => {
|
||||||
beforeEach(() => {
|
it(
|
||||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
'should enrich listings with details',
|
||||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
async () => {
|
||||||
});
|
if (!liveListings?.length) throw new Error('No listings from first test to enrich');
|
||||||
|
|
||||||
afterEach(() => {
|
// Call fetchDetails directly on the first live listing — no need to
|
||||||
vi.restoreAllMocks();
|
// re-scrape the search page. The shared browser keeps the session warm.
|
||||||
});
|
const enriched = await provider.config.fetchDetails(liveListings[0], browser);
|
||||||
|
|
||||||
it('should enrich listings with details', async () => {
|
expect(enriched).toBeTruthy();
|
||||||
const Fredy = await mockFredy();
|
expect(enriched.link).toContain('https://www.immowelt.de');
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
expect(enriched.address).toBeTypeOf('string');
|
||||||
const mockedJob = { id: 'immowelt', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
expect(enriched.address).not.toBe('');
|
||||||
|
|
||||||
const fredy = new Fredy(
|
|
||||||
provider.config,
|
|
||||||
mockedJob,
|
|
||||||
provider.metaInformation.id,
|
|
||||||
{ checkAndAddEntry: () => false },
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const listings = await fredy.execute();
|
|
||||||
expect(listings).toBeInstanceOf(Array);
|
|
||||||
listings.forEach((listing) => {
|
|
||||||
expect(listing.link).toContain('https://www.immowelt.de');
|
|
||||||
expect(listing.address).toBeTypeOf('string');
|
|
||||||
expect(listing.address).not.toBe('');
|
|
||||||
// description is enriched from the detail page; falls back gracefully if blocked
|
// description is enriched from the detail page; falls back gracefully if blocked
|
||||||
if (listing.description != null) {
|
if (enriched.description != null) {
|
||||||
expect(listing.description).toBeTypeOf('string');
|
expect(enriched.description).toBeTypeOf('string');
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
});
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,80 +6,88 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect, vi } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||||
import * as mockStore from '../mocks/mockStore.js';
|
import { launchBrowser, closeBrowser } from '../../lib/services/extractor/puppeteerExtractor.js';
|
||||||
|
|
||||||
|
// One browser shared across the whole suite so both requests (search + detail)
|
||||||
|
// come from the same warm session. Kleinanzeigen rate-limits cold browser
|
||||||
|
// sessions; a shared warm browser prevents the second request from being blocked.
|
||||||
|
const TEST_TIMEOUT = 180_000;
|
||||||
|
|
||||||
describe('#kleinanzeigen testsuite()', () => {
|
describe('#kleinanzeigen testsuite()', () => {
|
||||||
it('should test kleinanzeigen provider', async () => {
|
let browser;
|
||||||
const Fredy = await mockFredy();
|
let liveListings;
|
||||||
const mockedJob = {
|
|
||||||
id: 'kleinanzeigen',
|
|
||||||
notificationAdapter: null,
|
|
||||||
spatialFilter: null,
|
|
||||||
specFilter: null,
|
|
||||||
};
|
|
||||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
|
||||||
|
|
||||||
fredy.execute().then((listing) => {
|
beforeAll(async () => {
|
||||||
if (listing == null || listing.length === 0) {
|
browser = await launchBrowser(providerConfig.kleinanzeigen.url);
|
||||||
reject('Listings is empty!');
|
}, TEST_TIMEOUT);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(listing).toBeInstanceOf(Array);
|
afterAll(async () => {
|
||||||
const notificationObj = get();
|
await closeBrowser(browser);
|
||||||
expect(notificationObj).toBeTypeOf('object');
|
|
||||||
expect(notificationObj.serviceName).toBe('kleinanzeigen');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).toBeTypeOf('string');
|
|
||||||
expect(notify.title).toBeTypeOf('string');
|
|
||||||
expect(notify.link).toBeTypeOf('string');
|
|
||||||
expect(notify.address).toBeTypeOf('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
expect(notify.title).not.toBe('');
|
|
||||||
expect(notify.link).toContain('https://www.kleinanzeigen.de');
|
|
||||||
expect(notify.address).not.toBe('');
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should test kleinanzeigen provider',
|
||||||
|
async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'kleinanzeigen',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, browser);
|
||||||
|
|
||||||
|
fredy.execute().then((listing) => {
|
||||||
|
if (listing == null || listing.length === 0) {
|
||||||
|
reject('Listings is empty!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
liveListings = listing;
|
||||||
|
expect(listing).toBeInstanceOf(Array);
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
|
expect(notificationObj.serviceName).toBe('kleinanzeigen');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).toBeTypeOf('string');
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.title).not.toBe('');
|
||||||
|
expect(notify.link).toContain('https://www.kleinanzeigen.de');
|
||||||
|
expect(notify.address).not.toBe('');
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
describe('with provider_details enabled', () => {
|
describe('with provider_details enabled', () => {
|
||||||
beforeEach(() => {
|
it(
|
||||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
'should enrich listings with details',
|
||||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
async () => {
|
||||||
});
|
if (!liveListings?.length) throw new Error('No listings from first test to enrich');
|
||||||
|
|
||||||
afterEach(() => {
|
// Call fetchDetails directly on the first live listing — no need to
|
||||||
vi.restoreAllMocks();
|
// re-scrape the search page. The shared browser keeps the session warm.
|
||||||
});
|
const enriched = await provider.config.fetchDetails(liveListings[0], browser);
|
||||||
|
|
||||||
it('should enrich listings with details', async () => {
|
expect(enriched).toBeTruthy();
|
||||||
const Fredy = await mockFredy();
|
expect(enriched.link).toContain('https://www.kleinanzeigen.de');
|
||||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
expect(enriched.address).toBeTypeOf('string');
|
||||||
const mockedJob = { id: 'kleinanzeigen', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
expect(enriched.address).not.toBe('');
|
||||||
|
expect(enriched.description).toBeTypeOf('string');
|
||||||
const fredy = new Fredy(
|
expect(enriched.description).not.toBe('');
|
||||||
provider.config,
|
},
|
||||||
mockedJob,
|
TEST_TIMEOUT,
|
||||||
provider.metaInformation.id,
|
);
|
||||||
{ checkAndAddEntry: () => false },
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const listings = await fredy.execute();
|
|
||||||
expect(listings).toBeInstanceOf(Array);
|
|
||||||
listings.forEach((listing) => {
|
|
||||||
expect(listing.link).toContain('https://www.kleinanzeigen.de');
|
|
||||||
expect(listing.address).toBeTypeOf('string');
|
|
||||||
expect(listing.address).not.toBe('');
|
|
||||||
expect(listing.description).toBeTypeOf('string');
|
|
||||||
expect(listing.description).not.toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,81 +9,97 @@ import { mockFredy, providerConfig } from '../utils.js';
|
|||||||
import { expect, vi } from 'vitest';
|
import { expect, vi } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/sparkasse.js';
|
import * as provider from '../../lib/provider/sparkasse.js';
|
||||||
import * as mockStore from '../mocks/mockStore.js';
|
import * as mockStore from '../mocks/mockStore.js';
|
||||||
|
import { launchBrowser, closeBrowser } from '../../lib/services/extractor/puppeteerExtractor.js';
|
||||||
|
|
||||||
|
// One browser shared across the whole suite so both requests (search + detail)
|
||||||
|
// come from the same warm session. This prevents the second request from being
|
||||||
|
// flagged as a cold-start bot hit.
|
||||||
|
const TEST_TIMEOUT = 120_000;
|
||||||
|
|
||||||
describe('#sparkasse testsuite()', () => {
|
describe('#sparkasse testsuite()', () => {
|
||||||
it('should test sparkasse provider', async () => {
|
let browser;
|
||||||
const Fredy = await mockFredy();
|
let liveListings;
|
||||||
const mockedJob = {
|
|
||||||
id: 'sparkasse',
|
|
||||||
notificationAdapter: null,
|
|
||||||
spatialFilter: null,
|
|
||||||
specFilter: null,
|
|
||||||
};
|
|
||||||
provider.init(providerConfig.sparkasse, []);
|
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
beforeAll(async () => {
|
||||||
|
browser = await launchBrowser(providerConfig.sparkasse.url);
|
||||||
|
}, TEST_TIMEOUT);
|
||||||
|
|
||||||
const listing = await fredy.execute();
|
afterAll(async () => {
|
||||||
|
await closeBrowser(browser);
|
||||||
if (listing == null || listing.length === 0) {
|
|
||||||
throw new Error('Listings is empty!');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(listing).toBeInstanceOf(Array);
|
|
||||||
const notificationObj = get();
|
|
||||||
expect(notificationObj).toBeTypeOf('object');
|
|
||||||
expect(notificationObj.serviceName).toBe('sparkasse');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).toBeTypeOf('string');
|
|
||||||
expect(notify.price).toBeTypeOf('string');
|
|
||||||
expect(notify.price).toContain('€');
|
|
||||||
expect(notify.size).toBeTypeOf('string');
|
|
||||||
expect(notify.size).toContain('m²');
|
|
||||||
expect(notify.title).toBeTypeOf('string');
|
|
||||||
expect(notify.link).toBeTypeOf('string');
|
|
||||||
expect(notify.address).toBeTypeOf('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
expect(notify.size).toBeTypeOf('string');
|
|
||||||
expect(notify.title).not.toBe('');
|
|
||||||
expect(notify.address).not.toBe('');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should test sparkasse provider',
|
||||||
|
async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'sparkasse',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
provider.init(providerConfig.sparkasse, []);
|
||||||
|
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, browser);
|
||||||
|
|
||||||
|
liveListings = await fredy.execute();
|
||||||
|
|
||||||
|
if (liveListings == null || liveListings.length === 0) {
|
||||||
|
throw new Error('Listings is empty!');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(liveListings).toBeInstanceOf(Array);
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).toBeTypeOf('object');
|
||||||
|
expect(notificationObj.serviceName).toBe('sparkasse');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).toBeTypeOf('string');
|
||||||
|
expect(notify.price).toBeTypeOf('string');
|
||||||
|
expect(notify.price).toContain('€');
|
||||||
|
expect(notify.size).toBeTypeOf('string');
|
||||||
|
expect(notify.size).toContain('m²');
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
expect(notify.address).toBeTypeOf('string');
|
||||||
|
/** check the values if possible **/
|
||||||
|
expect(notify.size).toBeTypeOf('string');
|
||||||
|
expect(notify.title).not.toBe('');
|
||||||
|
expect(notify.address).not.toBe('');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
describe('with provider_details enabled', () => {
|
describe('with provider_details enabled', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
||||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enrich listings with details', async () => {
|
it(
|
||||||
const Fredy = await mockFredy();
|
'should enrich listings with details',
|
||||||
provider.init(providerConfig.sparkasse, []);
|
async () => {
|
||||||
const mockedJob = { id: 'sparkasse', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
if (!liveListings?.length) throw new Error('No listings from first test to enrich');
|
||||||
|
|
||||||
const fredy = new Fredy(
|
// Call fetchDetails directly on the first live listing — no need to
|
||||||
provider.config,
|
// re-scrape the search page. The shared browser keeps the session warm.
|
||||||
mockedJob,
|
const enriched = await provider.config.fetchDetails(liveListings[0], browser);
|
||||||
provider.metaInformation.id,
|
|
||||||
{ checkAndAddEntry: () => false },
|
expect(enriched).toBeTruthy();
|
||||||
undefined,
|
expect(enriched.link).toContain('https://immobilien.sparkasse.de');
|
||||||
);
|
expect(enriched.address).toBeTypeOf('string');
|
||||||
const listings = await fredy.execute();
|
expect(enriched.address).not.toBe('');
|
||||||
expect(listings).toBeInstanceOf(Array);
|
// description is enriched from the detail page; falls back gracefully if blocked
|
||||||
listings.forEach((listing) => {
|
if (enriched.description != null) {
|
||||||
expect(listing.link).toContain('https://immobilien.sparkasse.de');
|
expect(enriched.description).toBeTypeOf('string');
|
||||||
expect(listing.address).toBeTypeOf('string');
|
expect(enriched.description).not.toBe('');
|
||||||
expect(listing.address).not.toBe('');
|
|
||||||
// description is enriched from the detail page; falls back gracefully if bot-detected
|
|
||||||
if (listing.description != null) {
|
|
||||||
expect(listing.description).toBeTypeOf('string');
|
|
||||||
expect(listing.description).not.toBe('');
|
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
});
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,77 +6,85 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import { expect, vi } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||||
import * as mockStore from '../mocks/mockStore.js';
|
import { launchBrowser, closeBrowser } from '../../lib/services/extractor/puppeteerExtractor.js';
|
||||||
|
|
||||||
|
// One browser shared across the whole suite so both requests (search + detail)
|
||||||
|
// come from the same warm session, avoiding double cold-start bot detection.
|
||||||
|
const TEST_TIMEOUT = 120_000;
|
||||||
|
|
||||||
describe('#wgGesucht testsuite()', () => {
|
describe('#wgGesucht testsuite()', () => {
|
||||||
provider.init(providerConfig.wgGesucht, [], []);
|
provider.init(providerConfig.wgGesucht, [], []);
|
||||||
it('should test wgGesucht provider', { timeout: 120000 }, async () => {
|
|
||||||
const Fredy = await mockFredy();
|
|
||||||
const mockedJob = {
|
|
||||||
id: 'wgGesucht',
|
|
||||||
notificationAdapter: null,
|
|
||||||
spatialFilter: null,
|
|
||||||
specFilter: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
let browser;
|
||||||
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined);
|
let liveListings;
|
||||||
|
|
||||||
fredy.execute().then((listing) => {
|
beforeAll(async () => {
|
||||||
if (listing == null || listing.length === 0) {
|
browser = await launchBrowser(providerConfig.wgGesucht.url);
|
||||||
reject('Listings is empty!');
|
}, TEST_TIMEOUT);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(listing).toBeInstanceOf(Array);
|
afterAll(async () => {
|
||||||
const notificationObj = get();
|
await closeBrowser(browser);
|
||||||
expect(notificationObj.serviceName).toBe('wgGesucht');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
expect(notify).toBeTypeOf('object');
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).toBeTypeOf('string');
|
|
||||||
expect(notify.title).toBeTypeOf('string');
|
|
||||||
// expect(notify.details).toBeTypeOf('string');
|
|
||||||
expect(notify.price).toBeTypeOf('string');
|
|
||||||
expect(notify.price).toContain('€');
|
|
||||||
expect(notify.link).toBeTypeOf('string');
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'should test wgGesucht provider',
|
||||||
|
async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'wgGesucht',
|
||||||
|
notificationAdapter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
specFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, browser);
|
||||||
|
|
||||||
|
fredy.execute().then((listing) => {
|
||||||
|
if (listing == null || listing.length === 0) {
|
||||||
|
reject('Listings is empty!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
liveListings = listing;
|
||||||
|
expect(listing).toBeInstanceOf(Array);
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj.serviceName).toBe('wgGesucht');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
expect(notify).toBeTypeOf('object');
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).toBeTypeOf('string');
|
||||||
|
expect(notify.title).toBeTypeOf('string');
|
||||||
|
// expect(notify.details).toBeTypeOf('string');
|
||||||
|
expect(notify.price).toBeTypeOf('string');
|
||||||
|
expect(notify.price).toContain('€');
|
||||||
|
expect(notify.link).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
describe('with provider_details enabled', () => {
|
describe('with provider_details enabled', () => {
|
||||||
beforeEach(() => {
|
it(
|
||||||
vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] });
|
'should enrich listings with details',
|
||||||
vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]);
|
async () => {
|
||||||
});
|
if (!liveListings?.length) throw new Error('No listings from first test to enrich');
|
||||||
|
|
||||||
afterEach(() => {
|
// Call fetchDetails directly on the first live listing — no need to
|
||||||
vi.restoreAllMocks();
|
// re-scrape the search page. The shared browser keeps the session warm.
|
||||||
});
|
const enriched = await provider.config.fetchDetails(liveListings[0], browser);
|
||||||
|
|
||||||
it('should enrich listings with details', async () => {
|
expect(enriched).toBeTruthy();
|
||||||
const Fredy = await mockFredy();
|
expect(enriched.link).toContain('https://www.wg-gesucht.de');
|
||||||
provider.init(providerConfig.wgGesucht, [], []);
|
expect(enriched.description).toBeTypeOf('string');
|
||||||
const mockedJob = { id: 'wgGesucht', notificationAdapter: null, specFilter: null, spatialFilter: null };
|
expect(enriched.description).not.toBe('');
|
||||||
|
},
|
||||||
const fredy = new Fredy(
|
TEST_TIMEOUT,
|
||||||
provider.config,
|
);
|
||||||
mockedJob,
|
|
||||||
provider.metaInformation.id,
|
|
||||||
{ checkAndAddEntry: () => false },
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const listings = await fredy.execute();
|
|
||||||
expect(listings).toBeInstanceOf(Array);
|
|
||||||
listings.forEach((listing) => {
|
|
||||||
expect(listing.link).toContain('https://www.wg-gesucht.de');
|
|
||||||
expect(listing.description).toBeTypeOf('string');
|
|
||||||
expect(listing.description).not.toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ import { readFile } from 'fs/promises';
|
|||||||
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
||||||
|
|
||||||
describe('#immoscout-mobile URL conversion', () => {
|
describe('#immoscout-mobile URL conversion', () => {
|
||||||
|
// Test shape URL conversion
|
||||||
|
it('should convert a full web URL with shape to mobile URL', () => {
|
||||||
|
const webUrl =
|
||||||
|
'https://www.immobilienscout24.de/Suche/shape/haus-kaufen?shape=aW9yfkhfa3htQXJgUGlnYEBmekhte3BAcXNAfWBsQGNyQ2lkUHVvbEB3eX5Ab25WYn5Fa2BLaGRQY29FaGtTfEhme3xBdHBEdHFMamlHbmdRfHhMcmxPeHlWYnpS&price=-600000.0&ground=240.0-&enteredFrom=result_list';
|
||||||
|
const expectedMobileUrl =
|
||||||
|
'https://api.mobile.immobilienscout24.de/search/list?ground=240.0-&price=-600000.0&realestatetype=housebuy&searchType=shape&shape=ior~H_kxmAr%60Pig%60%40fzHm%7Bp%40qs%40%7D%60l%40crCidPuol%40wy~%40onVb~Ek%60KhdPcoEhkS%7CHf%7B%7CAtpDtqLjiGngQ%7CxLrlOxyVbzR';
|
||||||
|
|
||||||
|
const actualMobileUrl = convertWebToMobile(webUrl);
|
||||||
|
expect(actualMobileUrl).toBe(expectedMobileUrl);
|
||||||
|
});
|
||||||
|
|
||||||
// Test URL conversion
|
// Test URL conversion
|
||||||
it('should convert a full web URL to mobile URL', () => {
|
it('should convert a full web URL to mobile URL', () => {
|
||||||
const webUrl =
|
const webUrl =
|
||||||
|
|||||||
@@ -18,5 +18,9 @@
|
|||||||
"rentHouse": {
|
"rentHouse": {
|
||||||
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-mieten?enteredFrom=one_step_search",
|
"url": "https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/haus-mieten?enteredFrom=one_step_search",
|
||||||
"type": "houserent"
|
"type": "houserent"
|
||||||
|
},
|
||||||
|
"buyHouseWithShape": {
|
||||||
|
"url": "https://www.immobilienscout24.de/Suche/shape/haus-kaufen?shape=aW9yfkhfa3htQXJgUGlnYEBmekhte3BAcXNAfWBsQGNyQ2lkUHVvbEB3eX5Ab25WYn5Fa2BLaGRQY29FaGtTfEhme3xBdHBEdHFMamlHbmdRfHhMcmxPeHlWYnpS&price=-600000.0&ground=240.0-&enteredFrom=result_list",
|
||||||
|
"type": "housebuy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
test/testFixtures/immobilienDe.html
vendored
10
test/testFixtures/immobilienDe.html
vendored
@@ -713,7 +713,7 @@
|
|||||||
|
|
||||||
</div><!-- /srb-filters -->
|
</div><!-- /srb-filters -->
|
||||||
|
|
||||||
<!-- Search button — CMS handles via onclick="{{submit()}}"; srbDoSearch() is
|
<!-- Search button - CMS handles via onclick="{{submit()}}"; srbDoSearch() is
|
||||||
a standalone fallback that skips empty params (used outside CMS context) -->
|
a standalone fallback that skips empty params (used outside CMS context) -->
|
||||||
<button type="button" class="srb-search-btn" id="srbSearchBtn">
|
<button type="button" class="srb-search-btn" id="srbSearchBtn">
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
@@ -740,7 +740,7 @@
|
|||||||
</div><!-- /srb-wrap -->
|
</div><!-- /srb-wrap -->
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════
|
||||||
MODAL — WEITERE FILTER
|
MODAL - WEITERE FILTER
|
||||||
═══════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- Screen-reader live region: announces filter changes and modal state -->
|
<!-- Screen-reader live region: announces filter changes and modal state -->
|
||||||
<div id="srbLiveRegion" role="status" aria-live="polite" aria-atomic="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap"></div>
|
<div id="srbLiveRegion" role="status" aria-live="polite" aria-atomic="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap"></div>
|
||||||
@@ -768,7 +768,7 @@
|
|||||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2"></circle>
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" class="srb-modal-location-input" id="srbModalLocation" placeholder="Ort, Postleitzahl, Stadt" autocomplete="off" b-event="keyup" role="combobox" aria-autocomplete="list" aria-controls="srbModalLocationList" aria-expanded="false">
|
<input type="text" class="srb-modal-location-input" id="srbModalLocation" placeholder="Ort, Postleitzahl, Stadt" autocomplete="off" b-event="keyup" role="combobox" aria-autocomplete="list" aria-controls="srbModalLocationList" aria-expanded="false">
|
||||||
<!-- Mirror of the desktop grbWoHidden — Piglet updates this via b-model
|
<!-- Mirror of the desktop grbWoHidden - Piglet updates this via b-model
|
||||||
when select(entry) fires in the modal controller's own scope. -->
|
when select(entry) fires in the modal controller's own scope. -->
|
||||||
<input type="hidden" id="srbModalWoHidden" value="district:2434,2695,2621,2700,2967,2734,2909,2955,2392,2746,2767,2982,2904,2612,2892,2587,2871,2975,2591,2887,2569,2640,2735">
|
<input type="hidden" id="srbModalWoHidden" value="district:2434,2695,2621,2700,2967,2734,2909,2955,2392,2746,2767,2982,2904,2612,2892,2587,2871,2975,2591,2887,2569,2640,2735">
|
||||||
<div b-redrawable="autocomplete" class="srb-autocomplete-wrapper">
|
<div b-redrawable="autocomplete" class="srb-autocomplete-wrapper">
|
||||||
@@ -899,7 +899,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Additional criteria — CMS server-side rendered per typ+objektart ── -->
|
<!-- ── Additional criteria - CMS server-side rendered per typ+objektart ── -->
|
||||||
<div class="srb-modal-criteria-groups">
|
<div class="srb-modal-criteria-groups">
|
||||||
<div class="srb-modal-section srb-modal-section--animated srb-modal-section--criteria" data-valid-searches="["kaufen_grundstueck","kaufen_haus","kaufen_rendite","kaufen_wohnung","mieten_grundstueck","mieten_haus","mieten_waz","mieten_wohnung"]">
|
<div class="srb-modal-section srb-modal-section--animated srb-modal-section--criteria" data-valid-searches="["kaufen_grundstueck","kaufen_haus","kaufen_rendite","kaufen_wohnung","mieten_grundstueck","mieten_haus","mieten_waz","mieten_wohnung"]">
|
||||||
<div class="srb-section-body">
|
<div class="srb-section-body">
|
||||||
@@ -4393,7 +4393,7 @@
|
|||||||
void g.offsetHeight;
|
void g.offsetHeight;
|
||||||
g.classList.remove('lr-card__gallery--no-transition');
|
g.classList.remove('lr-card__gallery--no-transition');
|
||||||
|
|
||||||
// counter total (CMS doesn't evaluate expressions in attrs — read from hidden span)
|
// counter total (CMS doesn't evaluate expressions in attrs - read from hidden span)
|
||||||
var totalEl = g.querySelector('.lr-card__gallery-total');
|
var totalEl = g.querySelector('.lr-card__gallery-total');
|
||||||
var counter = g.querySelector('.lr-card__gallery-counter');
|
var counter = g.querySelector('.lr-card__gallery-counter');
|
||||||
if (counter && totalEl) counter.dataset.total = totalEl.textContent.trim();
|
if (counter && totalEl) counter.dataset.total = totalEl.textContent.trim();
|
||||||
|
|||||||
2
test/testFixtures/ohneMakler.html
vendored
2
test/testFixtures/ohneMakler.html
vendored
@@ -1523,7 +1523,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="hidden print:flex print:items-center print:justify-between mb-2">
|
<div class="hidden print:flex print:items-center print:justify-between mb-2">
|
||||||
<img src="/static/img/brand/logo.b187f1e54302.svg" class="w-32" alt="ohne-makler — Immobilien selbst vermarkten">
|
<img src="/static/img/brand/logo.b187f1e54302.svg" class="w-32" alt="ohne-makler - Immobilien selbst vermarkten">
|
||||||
<br>
|
<br>
|
||||||
Diese Seite wurde ausgedruckt von:<br>
|
Diese Seite wurde ausgedruckt von:<br>
|
||||||
https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/
|
https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ vi.mock('../lib/services/extractor/puppeteerExtractor.js', async (importOriginal
|
|||||||
const { readFixture } = await import('./offlineFixtures.js');
|
const { readFixture } = await import('./offlineFixtures.js');
|
||||||
return {
|
return {
|
||||||
default: (url) => readFixture(url),
|
default: (url) => readFixture(url),
|
||||||
launchBrowser: async () => ({ close: async () => {}, __fredy_removeUserDataDir: false }),
|
launchBrowser: async () => ({ close: async () => {}, isConnected: () => true }),
|
||||||
closeBrowser: async () => {},
|
closeBrowser: async () => {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
132
test/utils/detectBaseUrl.test.js
Normal file
132
test/utils/detectBaseUrl.test.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('fs');
|
||||||
|
vi.mock('os');
|
||||||
|
|
||||||
|
import * as fsMock from 'fs';
|
||||||
|
import * as osMock from 'os';
|
||||||
|
import { isRunningInDocker, detectLocalIp, guessBaseUrl } from '../../lib/utils/detectBaseUrl.js';
|
||||||
|
|
||||||
|
describe('detectBaseUrl', () => {
|
||||||
|
let originalEnv;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalEnv = { ...process.env };
|
||||||
|
vi.clearAllMocks();
|
||||||
|
delete process.env.FREDY_DOCKER;
|
||||||
|
delete process.env.FREDY_HOST_IP;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRunningInDocker', () => {
|
||||||
|
it('returns true when FREDY_DOCKER=1', () => {
|
||||||
|
process.env.FREDY_DOCKER = '1';
|
||||||
|
expect(isRunningInDocker()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when no Docker signals present', () => {
|
||||||
|
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||||
|
throw new Error('not found');
|
||||||
|
});
|
||||||
|
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||||
|
throw new Error('not found');
|
||||||
|
});
|
||||||
|
expect(isRunningInDocker()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when /.dockerenv is accessible', () => {
|
||||||
|
vi.mocked(fsMock.accessSync).mockReturnValue(undefined);
|
||||||
|
expect(isRunningInDocker()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when /proc/self/cgroup contains docker', () => {
|
||||||
|
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||||
|
throw new Error('not found');
|
||||||
|
});
|
||||||
|
vi.mocked(fsMock.readFileSync).mockReturnValue('12:cpu:/docker/abc123');
|
||||||
|
expect(isRunningInDocker()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when /proc/self/cgroup contains containerd', () => {
|
||||||
|
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||||
|
throw new Error('not found');
|
||||||
|
});
|
||||||
|
vi.mocked(fsMock.readFileSync).mockReturnValue('0::/../containerd/abc');
|
||||||
|
expect(isRunningInDocker()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectLocalIp', () => {
|
||||||
|
it('returns 172.17.0.1 when running in Docker (default)', () => {
|
||||||
|
process.env.FREDY_DOCKER = '1';
|
||||||
|
expect(detectLocalIp()).toBe('172.17.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns FREDY_HOST_IP when set in Docker', () => {
|
||||||
|
process.env.FREDY_DOCKER = '1';
|
||||||
|
process.env.FREDY_HOST_IP = '192.168.1.50';
|
||||||
|
expect(detectLocalIp()).toBe('192.168.1.50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips docker bridge IPs and returns real LAN IP', () => {
|
||||||
|
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
vi.mocked(osMock.networkInterfaces).mockReturnValue({
|
||||||
|
docker0: [{ family: 'IPv4', address: '172.17.0.1', internal: false }],
|
||||||
|
en0: [{ family: 'IPv4', address: '192.168.1.100', internal: false }],
|
||||||
|
});
|
||||||
|
expect(detectLocalIp()).toBe('192.168.1.100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers en0 over arbitrary interfaces', () => {
|
||||||
|
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
vi.mocked(osMock.networkInterfaces).mockReturnValue({
|
||||||
|
utun3: [{ family: 'IPv4', address: '10.8.0.1', internal: false }],
|
||||||
|
en0: [{ family: 'IPv4', address: '192.168.178.50', internal: false }],
|
||||||
|
});
|
||||||
|
expect(detectLocalIp()).toBe('192.168.178.50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to localhost when no suitable interface found', () => {
|
||||||
|
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
vi.mocked(osMock.networkInterfaces).mockReturnValue({
|
||||||
|
lo: [{ family: 'IPv4', address: '127.0.0.1', internal: true }],
|
||||||
|
});
|
||||||
|
expect(detectLocalIp()).toBe('localhost');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guessBaseUrl', () => {
|
||||||
|
it('returns correctly formatted URL', () => {
|
||||||
|
process.env.FREDY_DOCKER = '1';
|
||||||
|
expect(guessBaseUrl(9998)).toBe('http://172.17.0.1:9998');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes custom port', () => {
|
||||||
|
process.env.FREDY_DOCKER = '1';
|
||||||
|
expect(guessBaseUrl(8080)).toBe('http://172.17.0.1:8080');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
229
tools/devMock.js
Normal file
229
tools/devMock.js
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight dev mock server on port 9998.
|
||||||
|
* Vite proxies /api to this. Run with: node tools/devMock.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const users = [{ id: 1, username: 'admin', isAdmin: true, lastLogin: now, numberOfJobs: 2, mcpToken: 'tok_abc123' }];
|
||||||
|
|
||||||
|
const jobs = [
|
||||||
|
{
|
||||||
|
id: 'job1',
|
||||||
|
name: 'Munich Apartments',
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
blacklist: [],
|
||||||
|
provider: [
|
||||||
|
{
|
||||||
|
id: 'immoscout',
|
||||||
|
name: 'ImmobilienScout24',
|
||||||
|
url: 'https://www.immobilienscout24.de/Suche/S-T/Wohnung-Miete/Bayern/Muenchen',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notificationAdapter: [],
|
||||||
|
specFilter: { maxPrice: 1500, minSize: 50 },
|
||||||
|
numberOfFoundListings: 2,
|
||||||
|
isOnlyShared: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'job2',
|
||||||
|
name: 'Berlin Rentals',
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
blacklist: ['keller', 'EG'],
|
||||||
|
provider: [{ id: 'immo', name: 'Immowelt', url: 'https://www.immowelt.de/suche/berlin/wohnungen/mieten' }],
|
||||||
|
notificationAdapter: [],
|
||||||
|
specFilter: {},
|
||||||
|
numberOfFoundListings: 2,
|
||||||
|
isOnlyShared: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const listings = [
|
||||||
|
{
|
||||||
|
id: 'l1',
|
||||||
|
title: '3-Zimmer-Wohnung in Schwabing',
|
||||||
|
price: 1350,
|
||||||
|
address: 'Leopoldstr. 42, München',
|
||||||
|
provider: 'ImmobilienScout24',
|
||||||
|
createdAt: now - 3600000,
|
||||||
|
created_at: now - 3600000,
|
||||||
|
image_url: null,
|
||||||
|
link: 'https://example.com/l1',
|
||||||
|
is_active: true,
|
||||||
|
isWatched: 0,
|
||||||
|
jobId: 'job1',
|
||||||
|
job_name: 'Munich Apartments',
|
||||||
|
size: 72,
|
||||||
|
rooms: 3,
|
||||||
|
description: 'Schöne 3-Zimmer-Wohnung in bester Lage in Schwabing. Balkon, Parkett, moderne Küche.',
|
||||||
|
latitude: 48.1598,
|
||||||
|
longitude: 11.5876,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'l2',
|
||||||
|
title: 'Helle 2-Zimmer near Ostbahnhof',
|
||||||
|
price: 980,
|
||||||
|
address: 'Rosenheimer Str. 15, München',
|
||||||
|
provider: 'ImmobilienScout24',
|
||||||
|
createdAt: now - 7200000,
|
||||||
|
created_at: now - 7200000,
|
||||||
|
image_url: null,
|
||||||
|
link: 'https://example.com/l2',
|
||||||
|
is_active: true,
|
||||||
|
isWatched: 1,
|
||||||
|
jobId: 'job1',
|
||||||
|
job_name: 'Munich Apartments',
|
||||||
|
size: 55,
|
||||||
|
rooms: 2,
|
||||||
|
description: 'Helle 2-Zimmer-Wohnung nahe Ostbahnhof. Ruhige Lage, gute Anbindung.',
|
||||||
|
latitude: 48.1285,
|
||||||
|
longitude: 11.6005,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'l3',
|
||||||
|
title: 'Altbau in Prenzlauer Berg',
|
||||||
|
price: 1100,
|
||||||
|
address: 'Kastanienallee 28, Berlin',
|
||||||
|
provider: 'Immowelt',
|
||||||
|
createdAt: now - 86400000,
|
||||||
|
created_at: now - 86400000,
|
||||||
|
image_url: null,
|
||||||
|
link: 'https://example.com/l3',
|
||||||
|
is_active: false,
|
||||||
|
isWatched: 0,
|
||||||
|
jobId: 'job2',
|
||||||
|
job_name: 'Berlin Rentals',
|
||||||
|
size: 65,
|
||||||
|
rooms: 2,
|
||||||
|
description: 'Charmante Altbauwohnung in Prenzlauer Berg. Hohe Decken, Stuck, Holzdielen.',
|
||||||
|
latitude: 52.5397,
|
||||||
|
longitude: 13.4098,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'l4',
|
||||||
|
title: '4-Zimmer Neubau Mitte',
|
||||||
|
price: 2200,
|
||||||
|
address: 'Karl-Liebknecht-Str. 5, Berlin',
|
||||||
|
provider: 'Immowelt',
|
||||||
|
createdAt: now - 172800000,
|
||||||
|
created_at: now - 172800000,
|
||||||
|
image_url: null,
|
||||||
|
link: 'https://example.com/l4',
|
||||||
|
is_active: true,
|
||||||
|
isWatched: 1,
|
||||||
|
jobId: 'job2',
|
||||||
|
job_name: 'Berlin Rentals',
|
||||||
|
size: 95,
|
||||||
|
rooms: 4,
|
||||||
|
description: 'Moderner Neubau im Herzen von Berlin Mitte. Fußbodenheizung, Aufzug, Tiefgarage.',
|
||||||
|
latitude: 52.5219,
|
||||||
|
longitude: 13.4132,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const dashboard = {
|
||||||
|
general: { interval: 30, lastRun: now - 1800000, nextRun: now + 1800000 },
|
||||||
|
kpis: { totalJobs: 2, totalListings: 4, numberOfActiveListings: 3, medianPriceOfListings: 1225 },
|
||||||
|
pie: [
|
||||||
|
{ type: 'ImmobilienScout24', value: 50 },
|
||||||
|
{ type: 'Immowelt', value: 50 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
'GET /api/login/user': { userId: 1, username: 'admin', isAdmin: true },
|
||||||
|
'GET /api/admin/users': users,
|
||||||
|
'GET /api/jobs/provider': [
|
||||||
|
{ id: 'immoscout', name: 'ImmobilienScout24', baseUrl: 'https://www.immobilienscout24.de' },
|
||||||
|
{ id: 'immo', name: 'Immowelt', baseUrl: 'https://www.immowelt.de' },
|
||||||
|
],
|
||||||
|
'GET /api/jobs': jobs,
|
||||||
|
'GET /api/jobs/shareableUserList': [],
|
||||||
|
'GET /api/jobs/notificationAdapter': [],
|
||||||
|
'GET /api/admin/generalSettings': { demoMode: false, analyticsEnabled: true, interval: 30 },
|
||||||
|
'GET /api/user/settings': {},
|
||||||
|
'GET /api/version': { newVersion: null },
|
||||||
|
'GET /api/tracking/trackingPois': [],
|
||||||
|
'GET /api/dashboard': dashboard,
|
||||||
|
'GET /api/demo': { demoMode: false },
|
||||||
|
'POST /api/user/settings/news-hash': {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const origin = req.headers.origin || 'http://localhost:5175';
|
||||||
|
const path = req.url.split('?')[0];
|
||||||
|
const key = req.method + ' ' + path;
|
||||||
|
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/jobs/events') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||||
|
res.write(': connected\n\n');
|
||||||
|
const interval = setInterval(() => res.write(': ping\n\n'), 15000);
|
||||||
|
req.on('close', () => clearInterval(interval));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
const userMatch = path.match(/^\/api\/admin\/users\/(\d+)$/);
|
||||||
|
if (req.method === 'GET' && userMatch) {
|
||||||
|
const user = users.find((u) => u.id === parseInt(userMatch[1]));
|
||||||
|
res.writeHead(user ? 200 : 404);
|
||||||
|
res.end(JSON.stringify(user || { message: 'Not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listingMatch = path.match(/^\/api\/listings\/([^/]+)$/);
|
||||||
|
if (
|
||||||
|
req.method === 'GET' &&
|
||||||
|
listingMatch &&
|
||||||
|
!path.includes('/table') &&
|
||||||
|
!path.includes('/map') &&
|
||||||
|
!path.includes('/watch')
|
||||||
|
) {
|
||||||
|
const listing = listings.find((l) => l.id === listingMatch[1]);
|
||||||
|
res.writeHead(listing ? 200 : 404);
|
||||||
|
res.end(JSON.stringify(listing || { message: 'Not found' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/api/jobs/data')) {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify({ result: jobs, totalNumber: jobs.length, page: 1 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith('/api/listings/table')) {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify({ result: listings, totalNumber: listings.length, page: 1 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith('/api/listings/map')) {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify({ listings: listings.filter((l) => l.is_active), maxPrice: 2200 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = routes[key];
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify(data !== undefined ? data : {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(9998, () => console.warn('Dev mock ready on :9998'));
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import './tokens.less';
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -14,18 +16,14 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 24px;
|
padding: @space-6;
|
||||||
background-color: var(--semi-color-bg-0);
|
background-color: transparent;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
padding: 12px;
|
padding: @space-3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,10 +6,15 @@
|
|||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
||||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
import { LocaleProvider, semiGlobal } from '@douyinfe/semi-ui-19';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './Index.less';
|
import './Index.less';
|
||||||
|
|
||||||
|
// Semi UI uses react-dom (not react-dom/client) internally for imperative renders
|
||||||
|
// like Toast, Notification, etc. In React 19, createRoot was removed from react-dom
|
||||||
|
// and lives only in react-dom/client - inject it so Toast can create its own root.
|
||||||
|
semiGlobal.config.createRoot = createRoot;
|
||||||
|
|
||||||
const container = document.getElementById('fredy');
|
const container = document.getElementById('fredy');
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,117 @@
|
|||||||
|
@import './tokens.less';
|
||||||
|
|
||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #232429;
|
font-family: @font-ui;
|
||||||
|
background-color: @color-base;
|
||||||
|
background-image: radial-gradient(ellipse at 60% 0%, rgba(224,74,56,0.05) 0%, transparent 55%);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body, html {
|
||||||
|
background-attachment: scroll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi UI theme overrides
|
||||||
|
body {
|
||||||
|
--semi-color-bg-0: @color-base !important;
|
||||||
|
--semi-color-bg-1: @color-surface !important;
|
||||||
|
--semi-color-bg-2: @color-elevated !important;
|
||||||
|
--semi-color-bg-3: @color-border !important;
|
||||||
|
--semi-color-border: @color-border !important;
|
||||||
|
--semi-color-primary: @color-accent !important;
|
||||||
|
--semi-color-primary-hover: @color-accent-dim !important;
|
||||||
|
--semi-color-primary-active: @color-accent-dim !important;
|
||||||
|
--semi-color-primary-light-default: rgba(224,74,56,0.12) !important;
|
||||||
|
--semi-color-primary-light-hover: rgba(224,74,56,0.18) !important;
|
||||||
|
--semi-color-primary-light-active: rgba(224,74,56,0.22) !important;
|
||||||
|
--semi-color-text-0: @color-text !important;
|
||||||
|
--semi-color-text-1: @color-text !important;
|
||||||
|
--semi-color-text-2: @color-muted !important;
|
||||||
|
--semi-color-text-3: @color-faint !important;
|
||||||
|
--semi-color-fill-0: rgba(255,255,255,0.04) !important;
|
||||||
|
--semi-color-fill-1: rgba(255,255,255,0.06) !important;
|
||||||
|
--semi-color-fill-2: rgba(255,255,255,0.08) !important;
|
||||||
|
--semi-font-family: @font-ui !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi table row overrides
|
||||||
.semi-table-row-head {
|
.semi-table-row-head {
|
||||||
background-color: #2b2b2b !important;
|
background-color: rgba(255,255,255,0.06) !important;
|
||||||
color: #fff !important;
|
}
|
||||||
|
.semi-table-row-head .semi-table-row-cell {
|
||||||
|
background-color: rgba(255,255,255,0.06) !important;
|
||||||
|
color: @color-muted !important;
|
||||||
|
font-size: @text-xs;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.semi-table-row-cell {
|
||||||
|
background-color: @color-surface !important;
|
||||||
|
}
|
||||||
|
.semi-table-tbody .semi-table-row:nth-child(even) .semi-table-row-cell {
|
||||||
|
background-color: @color-base !important;
|
||||||
|
}
|
||||||
|
.semi-table-tbody .semi-table-row:hover .semi-table-row-cell {
|
||||||
|
background-color: @color-elevated !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.semi-table-row-cell {
|
// Scrollbar
|
||||||
background-color: #333333 !important;
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: @color-surface; }
|
||||||
|
::-webkit-scrollbar-thumb { background: @color-border-bright; border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: @color-muted; }
|
||||||
|
|
||||||
|
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon):not(.semi-checkbox .semi-icon) {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress focus outlines - Semi uses --semi-color-primary (our red) for all rings
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible,
|
||||||
|
.semi-button:focus,
|
||||||
|
.semi-button:focus-visible,
|
||||||
|
.semi-input-wrapper:focus-within,
|
||||||
|
.semi-select:focus-within,
|
||||||
|
[tabindex]:focus {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-input-wrapper-focus {
|
||||||
|
border-color: @color-border-bright !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi Toast dark theme overrides
|
||||||
|
.semi-toast-content {
|
||||||
|
background-color: @color-elevated !important;
|
||||||
|
border: 1px solid @color-border-bright !important;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi Modal dark theme overrides
|
||||||
|
.semi-modal-content {
|
||||||
|
background: #161616 !important;
|
||||||
|
border: 1px solid @color-border !important;
|
||||||
|
border-top: 3px solid @color-accent !important;
|
||||||
|
border-radius: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-modal-header {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: 1px solid @color-border !important;
|
||||||
|
border-radius: 14px 14px 0 0 !important;
|
||||||
|
padding: 20px 24px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-modal-mask {
|
||||||
|
background: rgba(0,0,0,0.7) !important;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
ui/src/assets/news/1.png
Normal file
BIN
ui/src/assets/news/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 835 KiB |
BIN
ui/src/assets/news/2.png
Normal file
BIN
ui/src/assets/news/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
@@ -1,11 +1,16 @@
|
|||||||
{
|
{
|
||||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876511",
|
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876221",
|
||||||
"content":
|
"content":
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"title": "Fredy goes AI",
|
"title": "Table overview for listings",
|
||||||
"text": "With Fredy v20.0.0, we are introducing Fredy’s own MCP server. This brings a powerful new capability: you can connect your local LLM directly to Fredy and explore the data it collects in a much more flexible way.<br/><br/>The MCP server exposes Fredy’s tools and findings through a structured interface, allowing your LLM to query listings, inspect collected details, and analyze results programmatically. Instead of manually searching through the data, you can simply ask your model questions and let it dig into what Fredy has discovered for you.<br/><br/>In practice, this means your local LLM can interact with Fredy almost like an assistant: investigating properties, summarizing listings, filtering results, or helping you identify interesting opportunities based on the data Fredy gathered.",
|
"text": "Thanks to https://github.com/datenwurm, we now have a table overview for listings. If you decide to use the table view, the decision will be stored.",
|
||||||
"media": "news.mp4"
|
"media": "1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Table overview for jobs",
|
||||||
|
"text": "Based on datenwurm's, work, I created a table overview for jobs. If you decide to use the table view, the decision will be stored.",
|
||||||
|
"media": "2.png"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 242 KiB |
BIN
ui/src/assets/no_image.png
Normal file
BIN
ui/src/assets/no_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -18,6 +18,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
height: 14rem;
|
height: 14rem;
|
||||||
opacity: .7;
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,34 @@
|
|||||||
@import './DashboardCardColors.less';
|
@import './DashboardCardColors.less';
|
||||||
|
|
||||||
|
@keyframes card-glow-rotate {
|
||||||
|
0% { box-shadow: 3px 3px 14px -4px var(--card-glow); }
|
||||||
|
25% { box-shadow: -3px 3px 14px -4px var(--card-glow); }
|
||||||
|
50% { box-shadow: -3px -3px 14px -4px var(--card-glow); }
|
||||||
|
75% { box-shadow: 3px -3px 14px -4px var(--card-glow); }
|
||||||
|
100% { box-shadow: 3px 3px 14px -4px var(--card-glow); }
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-card {
|
.dashboard-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
height: 112px;
|
||||||
margin-bottom: 16px;
|
border-radius: @radius-card !important;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
border: 1px solid @color-border !important;
|
||||||
background-color: #181b26;
|
background-color: @color-surface !important;
|
||||||
border: 1px solid #232735;
|
transition: box-shadow @transition-card;
|
||||||
border-radius: 10px;
|
|
||||||
--pulse-color: rgba(255, 255, 255, 0.08);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
border-radius: inherit;
|
|
||||||
box-shadow: 0 4px 25px -2px var(--pulse-color);
|
|
||||||
opacity: 0;
|
|
||||||
animation: pulse 5s infinite ease-in-out;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
will-change: opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--card-accent, #94a3b8);
|
color: var(--card-accent, @color-gray-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
color: var(--semi-color-text-2) !important;
|
color: rgba(148, 163, 184, 0.7) !important;
|
||||||
font-size: 12px !important;
|
font-size: @text-xs !important;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -49,61 +38,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
|
font-size: 1.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: var(--card-accent, var(--semi-color-text-0));
|
color: var(--card-accent, @color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__desc {
|
&__desc {
|
||||||
color: var(--semi-color-text-3) !important;
|
color: @color-faint !important;
|
||||||
|
font-size: @text-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.blue {
|
&.blue {
|
||||||
--pulse-color: @color-blue-border;
|
|
||||||
--card-accent: @color-blue-text;
|
--card-accent: @color-blue-text;
|
||||||
background-color: @color-blue-bg;
|
--card-glow: @color-blue-border;
|
||||||
border-color: @color-blue-border;
|
background-color: @color-blue-bg !important;
|
||||||
box-shadow: 0 2px 16px -6px @color-blue-border;
|
border-color: @color-blue-border !important;
|
||||||
|
animation: card-glow-rotate 8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.orange {
|
&.orange {
|
||||||
--pulse-color: @color-orange-border;
|
|
||||||
--card-accent: @color-orange-text;
|
--card-accent: @color-orange-text;
|
||||||
background-color: @color-orange-bg;
|
--card-glow: @color-orange-border;
|
||||||
border-color: @color-orange-border;
|
background-color: @color-orange-bg !important;
|
||||||
box-shadow: 0 2px 16px -6px @color-orange-border;
|
border-color: @color-orange-border !important;
|
||||||
|
animation: card-glow-rotate 8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.green {
|
&.green {
|
||||||
--pulse-color: @color-green-border;
|
|
||||||
--card-accent: @color-green-text;
|
--card-accent: @color-green-text;
|
||||||
background-color: @color-green-bg;
|
--card-glow: @color-green-border;
|
||||||
border-color: @color-green-border;
|
background-color: @color-green-bg !important;
|
||||||
box-shadow: 0 2px 16px -6px @color-green-border;
|
border-color: @color-green-border !important;
|
||||||
|
animation: card-glow-rotate 8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.purple {
|
&.purple {
|
||||||
--pulse-color: @color-purple-border;
|
|
||||||
--card-accent: @color-purple-text;
|
--card-accent: @color-purple-text;
|
||||||
background-color: @color-purple-bg;
|
--card-glow: @color-purple-border;
|
||||||
border-color: @color-purple-border;
|
background-color: @color-purple-bg !important;
|
||||||
box-shadow: 0 2px 16px -6px @color-purple-border;
|
border-color: @color-purple-border !important;
|
||||||
|
animation: card-glow-rotate 8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.gray {
|
&.gray {
|
||||||
--pulse-color: @color-gray-border;
|
|
||||||
--card-accent: @color-gray-text;
|
--card-accent: @color-gray-text;
|
||||||
background-color: @color-gray-bg;
|
--card-glow: @color-gray-border;
|
||||||
border-color: @color-gray-border;
|
background-color: @color-gray-bg !important;
|
||||||
box-shadow: 0 2px 16px -6px @color-gray-border;
|
border-color: @color-gray-border !important;
|
||||||
}
|
animation: card-glow-rotate 8s linear infinite;
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% {
|
|
||||||
opacity: 0.1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1 @@
|
|||||||
@color-blue-bg: rgba(96, 165, 250, 0.10);
|
@import '../../tokens.less';
|
||||||
@color-blue-border: #3b6ea8;
|
|
||||||
@color-blue-text: #60a5fa;
|
|
||||||
|
|
||||||
@color-orange-bg: rgba(251, 146, 60, 0.10);
|
|
||||||
@color-orange-border: #c2622a;
|
|
||||||
@color-orange-text: #fb923c;
|
|
||||||
|
|
||||||
@color-green-bg: rgba(52, 211, 153, 0.10);
|
|
||||||
@color-green-border: #2a8a61;
|
|
||||||
@color-green-text: #34d399;
|
|
||||||
|
|
||||||
@color-purple-bg: rgba(167, 139, 250, 0.10);
|
|
||||||
@color-purple-border: #6d4fc2;
|
|
||||||
@color-purple-text: #a78bfa;
|
|
||||||
|
|
||||||
@color-gray-bg: rgba(148, 163, 184, 0.10);
|
|
||||||
@color-gray-border: #323a47;
|
|
||||||
@color-gray-text: #94a3b8;
|
|
||||||
|
|||||||
@@ -6,15 +6,7 @@
|
|||||||
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||||
import './DashboardCard.less';
|
import './DashboardCard.less';
|
||||||
|
|
||||||
export default function KpiCard({
|
export default function KpiCard({ title, icon, value, description, color = 'gray', children }) {
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
value,
|
|
||||||
valueFontSize = '1.5rem',
|
|
||||||
description,
|
|
||||||
color = 'gray',
|
|
||||||
children,
|
|
||||||
}) {
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
return (
|
return (
|
||||||
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
|
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
|
||||||
@@ -26,12 +18,12 @@ export default function KpiCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<div className="dashboard-card__content">
|
<div className="dashboard-card__content">
|
||||||
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
<div className="dashboard-card__value">
|
||||||
{value}
|
{value}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<Text size="small" type="tertiary" className="dashboard-card__desc">
|
<Text size="small" className="dashboard-card__desc">
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,23 +5,21 @@
|
|||||||
|
|
||||||
import './FredyFooter.less';
|
import './FredyFooter.less';
|
||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
import { Layout } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
export default function FredyFooter() {
|
export default function FredyFooter() {
|
||||||
const { Text } = Typography;
|
|
||||||
const { Footer } = Layout;
|
const { Footer } = Layout;
|
||||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Footer className="fredyFooter">
|
<Footer className="fredyFooter">
|
||||||
<Space split={<Divider layout="vertical" />}>
|
<span className="fredyFooter__version">Fredy v{version?.localFredyVersion || 'N/A'}</span>
|
||||||
<Text type="tertiary" size="small">
|
<span className="fredyFooter__credit">
|
||||||
Fredy V{version?.localFredyVersion || 'N/A'}
|
Made with ❤️ by{' '}
|
||||||
</Text>
|
<a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
|
||||||
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
|
Christian Kellner
|
||||||
Made with ❤️
|
</a>
|
||||||
</Text>
|
</span>
|
||||||
</Space>
|
|
||||||
</Footer>
|
</Footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
.fredyFooter {
|
.fredyFooter {
|
||||||
background-color: var(--semi-color-bg-1);
|
background-color: @color-base;
|
||||||
|
border-top: 1px solid @color-border;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 1rem;
|
padding: 10px 24px;
|
||||||
height: 32px;
|
height: 36px;
|
||||||
border-top: 1px solid var(--semi-color-border);
|
|
||||||
z-index: 1000;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&__version {
|
||||||
|
font-size: @text-xs;
|
||||||
|
color: @color-faint;
|
||||||
|
font-family: @font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__credit {
|
||||||
|
font-size: @text-xs;
|
||||||
|
color: @color-faint;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: @color-muted;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color @transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Empty,
|
Empty,
|
||||||
Radio,
|
Radio,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
|
Tooltip,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -32,10 +33,11 @@ import {
|
|||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconBell,
|
IconBell,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconPlusCircle,
|
|
||||||
IconArrowUp,
|
IconArrowUp,
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
IconHome,
|
IconHome,
|
||||||
|
IconGridView,
|
||||||
|
IconList,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
@@ -43,6 +45,7 @@ import { useActions, useSelector } from '../../../services/state/store.js';
|
|||||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||||
import { debounce } from '../../../utils';
|
import { debounce } from '../../../utils';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import JobsTable from '../../table/JobsTable.jsx';
|
||||||
|
|
||||||
import './JobGrid.less';
|
import './JobGrid.less';
|
||||||
|
|
||||||
@@ -55,6 +58,9 @@ const JobGrid = () => {
|
|||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
|
const viewMode = userSettings?.jobs_view_mode ?? 'grid';
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
|
|
||||||
@@ -202,10 +208,6 @@ const JobGrid = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="jobGrid">
|
<div className="jobGrid">
|
||||||
<div className="jobGrid__topbar">
|
<div className="jobGrid__topbar">
|
||||||
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
|
||||||
New Job
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="jobGrid__topbar__search"
|
className="jobGrid__topbar__search"
|
||||||
prefix={<IconSearch />}
|
prefix={<IconSearch />}
|
||||||
@@ -239,6 +241,27 @@ const JobGrid = () => {
|
|||||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="jobGrid__topbar__view-toggle">
|
||||||
|
<Tooltip content="Grid view">
|
||||||
|
<Button
|
||||||
|
icon={<IconGridView />}
|
||||||
|
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
||||||
|
onClick={() => actions.userSettings.setJobsViewMode('grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
aria-pressed={viewMode === 'grid'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Table view">
|
||||||
|
<Button
|
||||||
|
icon={<IconList />}
|
||||||
|
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
||||||
|
onClick={() => actions.userSettings.setJobsViewMode('table')}
|
||||||
|
aria-label="Table view"
|
||||||
|
aria-pressed={viewMode === 'table'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(jobsData?.result || []).length === 0 && (
|
{(jobsData?.result || []).length === 0 && (
|
||||||
@@ -249,136 +272,144 @@ const JobGrid = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
{viewMode === 'grid' ? (
|
||||||
{(jobsData?.result || []).map((job) => (
|
<Row gutter={[16, 16]}>
|
||||||
<Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
{(jobsData?.result || []).map((job) => (
|
||||||
<Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
|
<Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
||||||
<div className="jobGrid__card__header">
|
<Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
|
||||||
<div className="jobGrid__card__name">
|
<div className="jobGrid__card__header">
|
||||||
<span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
|
<div className="jobGrid__card__name">
|
||||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
<span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
|
||||||
{job.name}
|
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||||
</Title>
|
{job.name}
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
|
{job.isOnlyShared && (
|
||||||
|
<Popover content={getPopoverContent('This job has been shared with you — read only.')}>
|
||||||
|
<div>
|
||||||
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
{job.running && (
|
||||||
|
<Tag color="green" variant="light" size="small">
|
||||||
|
RUNNING
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
|
||||||
{job.isOnlyShared && (
|
<div className="jobGrid__card__stats">
|
||||||
<Popover
|
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
|
||||||
content={getPopoverContent(
|
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
|
||||||
'This job has been shared with you by another user, therefor it is read-only.',
|
<span className="jobGrid__card__stat__label">
|
||||||
)}
|
<IconHome size="small" /> Listings
|
||||||
>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
|
||||||
|
<span className="jobGrid__card__stat__number">{job.provider?.length || 0}</span>
|
||||||
|
<span className="jobGrid__card__stat__label">
|
||||||
|
<IconBriefcase size="small" /> Providers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
|
||||||
|
<span className="jobGrid__card__stat__number">{job.notificationAdapter?.length || 0}</span>
|
||||||
|
<span className="jobGrid__card__stat__label">
|
||||||
|
<IconBell size="small" /> Adapters
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider margin="12px" />
|
||||||
|
|
||||||
|
<div className="jobGrid__card__footer">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Switch
|
||||||
|
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||||
|
checked={job.enabled}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Text type="secondary" size="small">
|
||||||
|
Active
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__actions">
|
||||||
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
<div>
|
<div>
|
||||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{ background: '#21aa21b5' }}
|
||||||
|
size="small"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconPlayCircle />}
|
||||||
|
disabled={job.isOnlyShared || job.running}
|
||||||
|
onClick={() => onJobRun(job.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
{job.running && (
|
<div>
|
||||||
<Tag color="green" variant="light" size="small">
|
<Button
|
||||||
RUNNING
|
type="secondary"
|
||||||
</Tag>
|
size="small"
|
||||||
)}
|
icon={<IconEdit />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Clone Job')}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="tertiary"
|
||||||
|
size="small"
|
||||||
|
icon={<IconCopy />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
icon={<IconDescend2 />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onListingRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
</Col>
|
||||||
<div className="jobGrid__card__stats">
|
))}
|
||||||
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
|
</Row>
|
||||||
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
|
) : (
|
||||||
<span className="jobGrid__card__stat__label">
|
<JobsTable
|
||||||
<IconHome size="small" /> Listings
|
jobs={jobsData?.result || []}
|
||||||
</span>
|
onRun={onJobRun}
|
||||||
</div>
|
onEdit={(id) => navigate(`/jobs/edit/${id}`)}
|
||||||
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
|
onClone={(id) => navigate('/jobs/new', { state: { cloneFrom: id } })}
|
||||||
<span className="jobGrid__card__stat__number">{job.provider.length || 0}</span>
|
onDeleteListings={onListingRemoval}
|
||||||
<span className="jobGrid__card__stat__label">
|
onDeleteJob={onJobRemoval}
|
||||||
<IconBriefcase size="small" /> Providers
|
onStatusChange={onJobStatusChanged}
|
||||||
</span>
|
/>
|
||||||
</div>
|
)}
|
||||||
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
|
|
||||||
<span className="jobGrid__card__stat__number">{job.notificationAdapter.length || 0}</span>
|
|
||||||
<span className="jobGrid__card__stat__label">
|
|
||||||
<IconBell size="small" /> Adapters
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider margin="12px" />
|
|
||||||
|
|
||||||
<div className="jobGrid__card__footer">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Switch
|
|
||||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
|
||||||
checked={job.enabled}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<Text type="secondary" size="small">
|
|
||||||
Active
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="jobGrid__actions">
|
|
||||||
<Popover content={getPopoverContent('Run Job')}>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
style={{ background: '#21aa21b5' }}
|
|
||||||
size="small"
|
|
||||||
theme="solid"
|
|
||||||
icon={<IconPlayCircle />}
|
|
||||||
disabled={job.isOnlyShared || job.running}
|
|
||||||
onClick={() => onJobRun(job.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="secondary"
|
|
||||||
size="small"
|
|
||||||
icon={<IconEdit />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Clone Job')}>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="tertiary"
|
|
||||||
size="small"
|
|
||||||
icon={<IconCopy />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="danger"
|
|
||||||
size="small"
|
|
||||||
icon={<IconDescend2 />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onListingRemoval(job.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Delete Job')}>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
type="danger"
|
|
||||||
size="small"
|
|
||||||
icon={<IconDelete />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onJobRemoval(job.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
|
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
|
||||||
<div className="jobGrid__pagination">
|
<div className="jobGrid__pagination">
|
||||||
<Pagination
|
<Pagination
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
@import '../../cards/DashboardCardColors.less';
|
@import '../../cards/DashboardCardColors.less';
|
||||||
|
@import '../../../tokens.less';
|
||||||
|
|
||||||
.jobGrid {
|
.jobGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&__topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: @space-3;
|
||||||
|
margin-bottom: @space-4;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
background-color: @color-surface !important;
|
||||||
background-color: rgba(36, 36, 36, 0.9);
|
border: 1px solid @color-border !important;
|
||||||
backdrop-filter: blur(8px);
|
border-radius: @radius-card !important;
|
||||||
border: 1px solid var(--semi-color-border);
|
transition: transform @transition-card, box-shadow @transition-card;
|
||||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
box-shadow: 0 4px 20px -4px rgba(0,0,0,0.5);
|
||||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
|
||||||
background-color: rgba(36, 36, 36, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
@@ -35,10 +56,10 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background-color: var(--semi-color-text-3);
|
background-color: rgba(251,113,133,0.7);
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background-color: #21aa21;
|
background-color: rgba(52,211,153,0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,21 +73,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255,255,255,0.04);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--semi-border-radius-small);
|
border-radius: @radius-chip;
|
||||||
padding: 10px 4px 8px;
|
padding: 10px 4px 8px;
|
||||||
|
|
||||||
&__number {
|
&__number {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--semi-color-text-0);
|
color: @color-text;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: 11px;
|
font-size: @text-xs;
|
||||||
color: var(--semi-color-text-3);
|
color: @color-faint;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
@@ -102,59 +123,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__topbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
.jobGrid__topbar__search {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.semi-button:first-child {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.jobGrid__topbar__search {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.semi-radio-group {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.semi-select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100px;
|
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin-bottom: 0 !important;
|
color: @color-text !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__pagination {
|
&__pagination {
|
||||||
margin-top: 2rem;
|
margin-top: @space-4;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.jobPopoverContent {
|
.jobPopoverContent {
|
||||||
padding: .4rem;
|
font-size: @text-sm;
|
||||||
color: var(--semi-color-white);
|
padding: 4px 8px;
|
||||||
|
color: @color-text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,367 +3,126 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { Button, Tooltip } from '@douyinfe/semi-ui-19';
|
||||||
import {
|
|
||||||
useSearchParamState,
|
|
||||||
parseNumber,
|
|
||||||
parseString,
|
|
||||||
parseNullableBoolean,
|
|
||||||
} from '../../../hooks/useSearchParamState.js';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
Row,
|
|
||||||
Image,
|
|
||||||
Button,
|
|
||||||
Typography,
|
|
||||||
Pagination,
|
|
||||||
Toast,
|
|
||||||
Divider,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
Empty,
|
|
||||||
Radio,
|
|
||||||
RadioGroup,
|
|
||||||
Space,
|
|
||||||
} from '@douyinfe/semi-ui-19';
|
|
||||||
import {
|
import {
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconCart,
|
IconCart,
|
||||||
IconClock,
|
|
||||||
IconDelete,
|
IconDelete,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconMapPin,
|
IconMapPin,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarStroked,
|
IconStarStroked,
|
||||||
IconSearch,
|
|
||||||
IconActivity,
|
|
||||||
IconEyeOpened,
|
IconEyeOpened,
|
||||||
IconArrowUp,
|
|
||||||
IconArrowDown,
|
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import no_image from '../../../assets/no_image.png';
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
|
||||||
import { debounce } from '../../../utils';
|
|
||||||
|
|
||||||
import './ListingsGrid.less';
|
import './ListingsGrid.less';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
/**
|
||||||
|
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
|
||||||
const ListingsGrid = () => {
|
*/
|
||||||
const listingsData = useSelector((state) => state.listingsData);
|
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
|
||||||
const providers = useSelector((state) => state.provider);
|
<div className="listingsGrid__grid">
|
||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
{listings.map((item) => (
|
||||||
const actions = useActions();
|
<div
|
||||||
const navigate = useNavigate();
|
key={item.id}
|
||||||
const sp = useSearchParams();
|
className="listingsGrid__card"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
role="button"
|
||||||
const pageSize = 40;
|
tabIndex={0}
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
onKeyDown={(e) => {
|
||||||
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
|
||||||
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
|
||||||
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
|
||||||
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
|
||||||
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
|
||||||
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
|
||||||
|
|
||||||
const loadData = () => {
|
|
||||||
actions.listingsData.getListingsData({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
sortfield: sortField,
|
|
||||||
sortdir: sortDir,
|
|
||||||
freeTextFilter,
|
|
||||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
|
||||||
|
|
||||||
const handleFilterChange = useMemo(
|
|
||||||
() =>
|
|
||||||
debounce((value) => {
|
|
||||||
setFreeTextFilter(value || null);
|
|
||||||
setPage(1);
|
|
||||||
}, 500),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
// cleanup debounced handler to avoid memory leaks
|
|
||||||
handleFilterChange.cancel && handleFilterChange.cancel();
|
|
||||||
};
|
|
||||||
}, [handleFilterChange]);
|
|
||||||
|
|
||||||
const handleWatch = async (e, item) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await xhrPost('/api/listings/watch', { listingId: item.id });
|
|
||||||
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
|
||||||
loadData();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
Toast.error('Failed to operate Watchlist');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (_page) => {
|
|
||||||
setPage(_page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
|
||||||
try {
|
|
||||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
|
||||||
Toast.success('Listing successfully removed');
|
|
||||||
loadData();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error.message || 'Error deleting listing');
|
|
||||||
} finally {
|
|
||||||
setDeleteModalVisible(false);
|
|
||||||
setListingToDelete(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cap = (val) => {
|
|
||||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="listingsGrid">
|
|
||||||
<div className="listingsGrid__topbar">
|
|
||||||
<Input
|
|
||||||
className="listingsGrid__topbar__search"
|
|
||||||
prefix={<IconSearch />}
|
|
||||||
showClear
|
|
||||||
placeholder="Search"
|
|
||||||
defaultValue={freeTextFilter ?? ''}
|
|
||||||
onChange={handleFilterChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioGroup
|
|
||||||
type="button"
|
|
||||||
buttonSize="middle"
|
|
||||||
value={activityFilter === null ? 'all' : String(activityFilter)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Radio value="all">All</Radio>
|
|
||||||
<Radio value="true">Active</Radio>
|
|
||||||
<Radio value="false">Inactive</Radio>
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
<RadioGroup
|
|
||||||
type="button"
|
|
||||||
buttonSize="middle"
|
|
||||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
setWatchListFilter(v === 'all' ? null : v === 'true');
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Radio value="all">All</Radio>
|
|
||||||
<Radio value="true">Watched</Radio>
|
|
||||||
<Radio value="false">Unwatched</Radio>
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
placeholder="Provider"
|
|
||||||
showClear
|
|
||||||
onChange={(val) => {
|
|
||||||
setProviderFilter(val);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
value={providerFilter}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
>
|
|
||||||
{providers?.map((p) => (
|
|
||||||
<Select.Option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
placeholder="Job"
|
|
||||||
showClear
|
|
||||||
onChange={(val) => {
|
|
||||||
setJobNameFilter(val);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
value={jobNameFilter}
|
|
||||||
style={{ width: 130 }}
|
|
||||||
>
|
|
||||||
{jobs?.map((j) => (
|
|
||||||
<Select.Option key={j.id} value={j.id}>
|
|
||||||
{j.name}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
|
|
||||||
<Select.Option value="job_name">Job Name</Select.Option>
|
|
||||||
<Select.Option value="created_at">Listing Date</Select.Option>
|
|
||||||
<Select.Option value="price">Price</Select.Option>
|
|
||||||
<Select.Option value="provider">Provider</Select.Option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
|
||||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
|
||||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(listingsData?.result || []).length === 0 && (
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationNoResult />}
|
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
|
||||||
description="No listings available yet..."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
{(listingsData?.result || []).map((item) => (
|
|
||||||
<Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
|
||||||
<Card
|
|
||||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
|
||||||
cover={
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<div className="listingsGrid__imageContainer">
|
|
||||||
<Image
|
|
||||||
src={item.image_url || no_image}
|
|
||||||
fallback={no_image}
|
|
||||||
width="100%"
|
|
||||||
height={180}
|
|
||||||
style={{ objectFit: 'cover' }}
|
|
||||||
preview={false}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
item.isWatched === 1 ? (
|
|
||||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
|
||||||
) : (
|
|
||||||
<IconStarStroked />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
theme="light"
|
|
||||||
shape="circle"
|
|
||||||
size="small"
|
|
||||||
className="listingsGrid__watchButton"
|
|
||||||
onClick={(e) => handleWatch(e, item)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
bodyStyle={{ padding: '12px' }}
|
|
||||||
>
|
|
||||||
<div className="listingsGrid__content">
|
|
||||||
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
|
||||||
{cap(item.title)}
|
|
||||||
</Text>
|
|
||||||
<div className="listingsGrid__price">
|
|
||||||
<IconCart size="small" />
|
|
||||||
{item.price} €
|
|
||||||
</div>
|
|
||||||
<div className="listingsGrid__meta">
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
icon={<IconMapPin />}
|
|
||||||
size="small"
|
|
||||||
ellipsis={{ showTooltip: true }}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{item.address || 'No address provided'}
|
|
||||||
</Text>
|
|
||||||
<Space spacing={12} wrap>
|
|
||||||
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
|
||||||
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
|
||||||
</Text>
|
|
||||||
<Text type="tertiary" size="small" icon={<IconClock />}>
|
|
||||||
{timeService.format(item.created_at, false)}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
{item.distance_to_destination ? (
|
|
||||||
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
|
||||||
{item.distance_to_destination} m to chosen address
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
|
||||||
Distance cannot be calculated
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Divider margin=".6rem" />
|
|
||||||
<div className="listingsGrid__actions">
|
|
||||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
|
||||||
<IconLink />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="secondary"
|
|
||||||
size="small"
|
|
||||||
title="View Details"
|
|
||||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
|
||||||
icon={<IconEyeOpened />}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Remove"
|
|
||||||
type="danger"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setListingToDelete(item.id);
|
|
||||||
setDeleteModalVisible(true);
|
|
||||||
}}
|
|
||||||
icon={<IconDelete />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
{(listingsData?.result || []).length > 0 && (
|
|
||||||
<div className="listingsGrid__pagination">
|
|
||||||
<Pagination
|
|
||||||
currentPage={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
total={listingsData?.totalNumber || 0}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
showSizeChanger={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ListingDeletionModal
|
|
||||||
visible={deleteModalVisible}
|
|
||||||
onConfirm={confirmDeletion}
|
|
||||||
onCancel={() => {
|
|
||||||
setDeleteModalVisible(false);
|
|
||||||
setListingToDelete(null);
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<div className="listingsGrid__card__image-wrapper">
|
||||||
);
|
<img
|
||||||
};
|
src={item.image_url || no_image}
|
||||||
|
alt={item.title}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src = no_image;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!item.is_active && (
|
||||||
|
<div className="listingsGrid__card__inactive-watermark">
|
||||||
|
<span>Inactive</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="listingsGrid__card__star"
|
||||||
|
onClick={(e) => onWatch(e, item)}
|
||||||
|
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||||
|
>
|
||||||
|
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="listingsGrid__card__body">
|
||||||
|
<div className="listingsGrid__card__title" title={item.title}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
{item.price && (
|
||||||
|
<div className="listingsGrid__card__price">
|
||||||
|
<IconCart size="small" />
|
||||||
|
{item.price}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.address && (
|
||||||
|
<div className="listingsGrid__card__meta">
|
||||||
|
<IconMapPin />
|
||||||
|
{item.address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="listingsGrid__card__meta">
|
||||||
|
<IconBriefcase />
|
||||||
|
{item.provider}
|
||||||
|
</div>
|
||||||
|
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Tooltip content="Original Listing">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconLink />}
|
||||||
|
style={{ color: '#60a5fa' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(item.link, '_blank');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="View in Fredy">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconEyeOpened />}
|
||||||
|
style={{ color: '#34d399' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNavigate(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Remove">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
style={{ color: '#fb7185' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default ListingsGrid;
|
export default ListingsGrid;
|
||||||
|
|||||||
@@ -1,185 +1,143 @@
|
|||||||
@import '../../cards/DashboardCardColors.less';
|
@import '../../../tokens.less';
|
||||||
|
|
||||||
.listingsGrid {
|
.listingsGrid__grid {
|
||||||
&__imageContainer {
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listingsGrid__card {
|
||||||
|
background: @color-elevated !important;
|
||||||
|
border: 1px solid @color-border !important;
|
||||||
|
border-radius: @radius-card !important;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform @transition-card, box-shadow @transition-card;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 180px;
|
height: 160px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__topbar {
|
&__inactive-watermark {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
margin-bottom: 16px;
|
background: rgba(0,0,0,0.35);
|
||||||
|
|
||||||
.listingsGrid__topbar__search {
|
span {
|
||||||
flex: 1;
|
font-size: 18px;
|
||||||
min-width: 200px;
|
font-weight: 800;
|
||||||
}
|
color: rgba(251,113,133,0.9);
|
||||||
|
text-transform: uppercase;
|
||||||
@media (max-width: 768px) {
|
letter-spacing: 0.15em;
|
||||||
.listingsGrid__topbar__search {
|
transform: rotate(-30deg);
|
||||||
width: 100%;
|
border: 2px solid rgba(251,113,133,0.5);
|
||||||
flex: unset;
|
padding: 4px 12px;
|
||||||
}
|
border-radius: @radius-chip;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
.semi-radio-group {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.semi-select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100px;
|
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__watchButton {
|
&__star {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
background-color: white !important;
|
background: rgba(0,0,0,0.5);
|
||||||
box-shadow: var(--semi-shadow-elevated);
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
&:hover {
|
width: 28px;
|
||||||
background-color: var(--semi-color-fill-0) !important;
|
height: 28px;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__statusTag {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 8px;
|
|
||||||
left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card {
|
|
||||||
height: 100%;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
background-color: rgba(36, 36, 36, 0.9);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid var(--semi-color-border);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: var(--semi-shadow-elevated);
|
|
||||||
background-color: rgba(36, 36, 36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--inactive {
|
|
||||||
|
|
||||||
.listingsGrid__imageContainer,
|
|
||||||
.listingsGrid__content {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__inactiveOverlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 70px;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
cursor: pointer;
|
||||||
z-index: 10;
|
transition: background @transition-fast;
|
||||||
color: var(--semi-color-danger);
|
padding: 0;
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
transform: rotate(-30deg);
|
|
||||||
padding: 5px;
|
|
||||||
max-height: fit-content;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__titleLink {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--semi-color-primary);
|
background: rgba(0,0,0,0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: @color-accent;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
display: block;
|
font-weight: 700;
|
||||||
height: 1.5em;
|
font-size: @text-sm;
|
||||||
}
|
color: @color-text;
|
||||||
|
overflow: hidden;
|
||||||
&__pagination {
|
text-overflow: ellipsis;
|
||||||
margin-top: 2rem;
|
white-space: nowrap;
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__price {
|
&__price {
|
||||||
font-size: 18px;
|
font-size: @text-base;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: @color-green-text;
|
color: @color-success;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 4px;
|
||||||
margin: 8px 0 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__meta {
|
||||||
|
font-size: @text-xs;
|
||||||
|
color: @color-muted;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 4px;
|
||||||
width: 100%;
|
|
||||||
|
.semi-icon {
|
||||||
|
font-size: 11px;
|
||||||
|
color: @color-faint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__provider {
|
||||||
|
font-size: @text-xs;
|
||||||
|
color: @color-faint;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-around;
|
||||||
align-items: center;
|
padding: 8px 12px;
|
||||||
}
|
border-top: 1px solid @color-border;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: auto;
|
||||||
|
|
||||||
&__setupButton {
|
button {
|
||||||
margin-bottom: 1rem;
|
flex: 1;
|
||||||
}
|
border: none !important;
|
||||||
|
border-radius: @radius-chip !important;
|
||||||
&__linkButton {
|
|
||||||
background: var(--semi-color-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--semi-color-primary-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure icons and text are vertically aligned
|
|
||||||
.semi-typography {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.semi-typography-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 1px; // Minor nudge if needed, but flex should handle most
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Typography } from '@douyinfe/semi-ui-19';
|
import './Headline.less';
|
||||||
|
|
||||||
export default function Headline({ text, size = 3 } = {}) {
|
export default function Headline({ text, actions } = {}) {
|
||||||
const { Title } = Typography;
|
|
||||||
return (
|
return (
|
||||||
<Title heading={size} style={{ marginBottom: '1rem' }}>
|
<div className="page-heading">
|
||||||
{text}
|
<div className="page-heading__row">
|
||||||
</Title>
|
<h1 className="page-heading__title">{text}</h1>
|
||||||
|
{actions && <div>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="page-heading__line" />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
ui/src/components/headline/Headline.less
Normal file
27
ui/src/components/headline/Headline.less
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
margin-bottom: @space-6;
|
||||||
|
margin-top: 0;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: @text-lg !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: @color-text !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, rgba(224,74,56,0.5) 0%, rgba(224,74,56,0) 100%);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
264
ui/src/components/listings/ListingsOverview.jsx
Normal file
264
ui/src/components/listings/ListingsOverview.jsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useSearchParamState,
|
||||||
|
parseNumber,
|
||||||
|
parseString,
|
||||||
|
parseNullableBoolean,
|
||||||
|
} from '../../hooks/useSearchParamState.js';
|
||||||
|
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
|
||||||
|
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import ListingDeletionModal from '../ListingDeletionModal.jsx';
|
||||||
|
import { xhrDelete, xhrPost } from '../../services/xhr.js';
|
||||||
|
import { useActions, useSelector } from '../../services/state/store.js';
|
||||||
|
import { debounce } from '../../utils';
|
||||||
|
import ListingsGrid from '../grid/listings/ListingsGrid.jsx';
|
||||||
|
import ListingsTable from '../table/ListingsTable.jsx';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
|
import './ListingsOverview.less';
|
||||||
|
|
||||||
|
const ListingsOverview = () => {
|
||||||
|
const listingsData = useSelector((state) => state.listingsData);
|
||||||
|
const providers = useSelector((state) => state.provider);
|
||||||
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
|
const actions = useActions();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const sp = useSearchParams();
|
||||||
|
|
||||||
|
const viewMode = userSettings?.listings_view_mode ?? 'grid';
|
||||||
|
|
||||||
|
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
||||||
|
const pageSize = 40;
|
||||||
|
|
||||||
|
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
||||||
|
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
||||||
|
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
||||||
|
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
||||||
|
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
||||||
|
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||||
|
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
actions.listingsData.getListingsData({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortfield: sortField,
|
||||||
|
sortdir: sortDir,
|
||||||
|
freeTextFilter,
|
||||||
|
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((value) => {
|
||||||
|
setFreeTextFilter(value || null);
|
||||||
|
setPage(1);
|
||||||
|
}, 500),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||||
|
};
|
||||||
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
|
const handleWatch = async (e, item) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/listings/watch', { listingId: item.id });
|
||||||
|
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error('Failed to operate Watchlist');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
setListingToDelete(id);
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
||||||
|
|
||||||
|
const confirmDeletion = async (hardDelete) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||||
|
Toast.success('Listing successfully removed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error.message || 'Error deleting listing');
|
||||||
|
} finally {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listings = listingsData?.result || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="listingsOverview">
|
||||||
|
<div className="listingsOverview__topbar">
|
||||||
|
<Input
|
||||||
|
className="listingsOverview__topbar__search"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
showClear
|
||||||
|
placeholder="Search"
|
||||||
|
defaultValue={freeTextFilter ?? ''}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
type="button"
|
||||||
|
buttonSize="middle"
|
||||||
|
value={activityFilter === null ? 'all' : String(activityFilter)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="all">All</Radio>
|
||||||
|
<Radio value="true">Active</Radio>
|
||||||
|
<Radio value="false">Inactive</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
type="button"
|
||||||
|
buttonSize="middle"
|
||||||
|
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="all">All</Radio>
|
||||||
|
<Radio value="true">Watched</Radio>
|
||||||
|
<Radio value="false">Unwatched</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Provider"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => {
|
||||||
|
setProviderFilter(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
value={providerFilter}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
>
|
||||||
|
{providers?.map((p) => (
|
||||||
|
<Select.Option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Job"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => {
|
||||||
|
setJobNameFilter(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
value={jobNameFilter}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
>
|
||||||
|
{jobs?.map((j) => (
|
||||||
|
<Select.Option key={j.id} value={j.id}>
|
||||||
|
{j.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
|
||||||
|
<Select.Option value="job_name">Job Name</Select.Option>
|
||||||
|
<Select.Option value="created_at">Listing Date</Select.Option>
|
||||||
|
<Select.Option value="price">Price</Select.Option>
|
||||||
|
<Select.Option value="provider">Provider</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||||
|
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
||||||
|
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="listingsOverview__topbar__view-toggle">
|
||||||
|
<Tooltip content="Grid view">
|
||||||
|
<Button
|
||||||
|
icon={<IconGridView />}
|
||||||
|
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
||||||
|
onClick={() => actions.userSettings.setListingsViewMode('grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
aria-pressed={viewMode === 'grid'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Table view">
|
||||||
|
<Button
|
||||||
|
icon={<IconList />}
|
||||||
|
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
||||||
|
onClick={() => actions.userSettings.setListingsViewMode('table')}
|
||||||
|
aria-label="Table view"
|
||||||
|
aria-pressed={viewMode === 'table'}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listings.length === 0 && (
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
|
description="No listings available yet..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'grid' ? (
|
||||||
|
<ListingsGrid listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
|
||||||
|
) : (
|
||||||
|
<ListingsTable listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listings.length > 0 && (
|
||||||
|
<div className="listingsOverview__pagination">
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={listingsData?.totalNumber || 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
showSizeChanger={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ListingDeletionModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
onConfirm={confirmDeletion}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListingsOverview;
|
||||||
45
ui/src/components/listings/ListingsOverview.less
Normal file
45
ui/src/components/listings/ListingsOverview.less
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
|
.listingsOverview {
|
||||||
|
&__topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: @space-3;
|
||||||
|
margin-bottom: @space-4;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
min-width: 200px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.listingsOverview__topbar__search {
|
||||||
|
width: 100%;
|
||||||
|
flex: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-radio-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
margin-top: @space-4;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,25 +3,20 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Button } from '@douyinfe/semi-ui-19';
|
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { IconUser } from '@douyinfe/semi-icons';
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const Logout = function Logout({ text }) {
|
const Logout = function Logout({ text }) {
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await xhrPost('/api/login/logout');
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<button className={`navigate__logout-btn${!text ? ' navigate__logout-btn--icon-only' : ''}`} onClick={handleLogout}>
|
||||||
<Button
|
<IconUser size="default" />
|
||||||
icon={<IconUser />}
|
{text && 'Logout'}
|
||||||
type="danger"
|
</button>
|
||||||
theme="solid"
|
|
||||||
onClick={async () => {
|
|
||||||
await xhrPost('/api/login/logout');
|
|
||||||
location.reload();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text && 'Logout'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,218 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
.navigate {
|
.navigate {
|
||||||
&__footer {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: @color-surface;
|
||||||
|
border-right: 1px solid @color-border;
|
||||||
|
transition: @transition-sidebar;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
padding: 20px 16px 16px;
|
||||||
gap: 0.5rem;
|
min-height: 64px;
|
||||||
width: 100%;
|
flex-shrink: 0;
|
||||||
display: flex;
|
|
||||||
padding-bottom: 12px;
|
img {
|
||||||
|
transition: width @transition-fast, opacity @transition-fast;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 1rem 8px 10px !important;
|
||||||
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid @color-border;
|
||||||
|
|
||||||
|
&--collapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 8px 10px !important;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logout-btn {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(251,113,133,0.25);
|
||||||
|
background: rgba(251,113,133,0.06);
|
||||||
|
color: #fb7185;
|
||||||
|
border-radius: @radius-btn;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: @text-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: @font-ui;
|
||||||
|
transition: background @transition-fast, border-color @transition-fast;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(251,113,133,0.12);
|
||||||
|
border-color: rgba(251,113,133,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--icon-only {
|
||||||
|
flex: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: @radius-btn;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(251,113,133,0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toggle-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: @color-faint;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: @radius-btn;
|
||||||
|
transition: background @transition-fast, color @transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: @color-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi Nav overrides
|
||||||
|
.semi-navigation {
|
||||||
|
background: @color-surface !important;
|
||||||
|
border-right: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-item {
|
||||||
|
border-radius: @radius-btn !important;
|
||||||
|
color: @color-muted !important;
|
||||||
|
transition: background @transition-fast, color @transition-fast !important;
|
||||||
|
margin: 2px 8px !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @color-text !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.semi-navigation-item-selected,
|
||||||
|
&[aria-selected="true"] {
|
||||||
|
background: rgba(224,74,56,0.12) !important;
|
||||||
|
border: 1px solid rgba(224,74,56,0.25) !important;
|
||||||
|
color: @color-text !important;
|
||||||
|
|
||||||
|
.semi-navigation-item-icon {
|
||||||
|
color: @color-accent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-sub-title {
|
||||||
|
color: @color-muted !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed state - icons perfectly centered
|
||||||
|
.semi-navigation-collapsed {
|
||||||
|
// Text span is display:block and takes up flex space - must be removed so justify-content:center works
|
||||||
|
.semi-navigation-item-text {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-item,
|
||||||
|
.semi-navigation-sub-title {
|
||||||
|
margin: 2px 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 36px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-item-inner,
|
||||||
|
.semi-navigation-sub-title-inner {
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi adds margin-right to icons for text spacing - remove it when collapsed
|
||||||
|
.semi-navigation-item-icon,
|
||||||
|
.semi-navigation-sub-title-icon {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi Nav.Footer - full width, no extra padding (our BEM class controls it)
|
||||||
|
.semi-navigation-footer {
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed submenu popup - actual class used by Semi UI is .semi-navigation-popover
|
||||||
|
.semi-navigation-popover {
|
||||||
|
background: @color-elevated !important;
|
||||||
|
border: 1px solid @color-border !important;
|
||||||
|
border-radius: @radius-card !important;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5) !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
|
||||||
|
.semi-navigation-item {
|
||||||
|
margin: 2px 6px !important;
|
||||||
|
color: @color-muted !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: @radius-btn !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255,255,255,0.06) !important;
|
||||||
|
color: @color-text !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.semi-navigation-item-selected,
|
||||||
|
&.semi-dropdown-item-active {
|
||||||
|
background: rgba(224,74,56,0.10) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: @color-text !important;
|
||||||
|
|
||||||
|
.semi-navigation-item-icon {
|
||||||
|
color: @color-accent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Nav } from '@douyinfe/semi-ui-19';
|
import { Nav } from '@douyinfe/semi-ui-19';
|
||||||
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
import heart from '../../assets/heart.png';
|
import heart from '../../assets/heart.png';
|
||||||
@@ -65,20 +65,32 @@ export default function Navigation({ isAdmin }) {
|
|||||||
return '/' + split[0];
|
return '/' + split[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sidebarWidth = collapsed ? '60px' : '220px';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Nav
|
<Nav
|
||||||
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
|
style={{ height: '100%', width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||||
items={items}
|
items={items}
|
||||||
isCollapsed={collapsed}
|
isCollapsed={collapsed}
|
||||||
selectedKeys={[parsePathName(location.pathname)]}
|
selectedKeys={[parsePathName(location.pathname)]}
|
||||||
onSelect={(key) => {
|
onSelect={(key) => {
|
||||||
navigate(key.itemKey);
|
navigate(key.itemKey);
|
||||||
}}
|
}}
|
||||||
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
|
header={
|
||||||
|
<div className="navigate__header">
|
||||||
|
<img src={collapsed ? heart : logoWhite} width={collapsed ? 30 : 160} alt="Fredy Logo" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
footer={
|
footer={
|
||||||
<Nav.Footer className="navigate__footer">
|
<Nav.Footer className={`navigate__footer${collapsed ? ' navigate__footer--collapsed' : ''}`}>
|
||||||
<Logout text={!collapsed} />
|
<Logout text={!collapsed} />
|
||||||
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
|
<button
|
||||||
|
className="navigate__toggle-btn"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
<IconSidebar size="default" />
|
||||||
|
</button>
|
||||||
</Nav.Footer>
|
</Nav.Footer>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,89 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
.segmentParts {
|
.segmentParts {
|
||||||
border: 1px solid #323232 !important;
|
background: rgba(255,255,255,0.03) !important;
|
||||||
border-radius: .9rem !important;
|
border: 1px solid @color-border !important;
|
||||||
color: rgba(var(--semi-grey-8), 1);
|
border-radius: @radius-card !important;
|
||||||
background: rgb(53, 54, 60);
|
margin-bottom: @space-4;
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
|
// Semi Card header
|
||||||
|
.semi-card-header {
|
||||||
|
border-bottom: 1px solid @color-border !important;
|
||||||
|
padding: 16px 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-card-header-wrapper {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-card-meta-title {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: @color-text !important;
|
||||||
|
font-size: @text-base !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-card-meta-description {
|
||||||
|
color: #b8b8b8 !important;
|
||||||
|
font-size: @text-sm !important;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-card-body {
|
||||||
|
padding: 16px 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semi input focus - subtle, not accent
|
||||||
|
.semi-input-wrapper:focus-within,
|
||||||
|
.semi-select:focus-within {
|
||||||
|
border-color: @color-border-bright !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon in card header
|
||||||
|
.semi-card-meta-avatar {
|
||||||
|
color: @color-accent !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inputs inside segment cards
|
||||||
|
.semi-input,
|
||||||
|
.semi-input-number-wrapper {
|
||||||
|
background: rgba(255,255,255,0.06) !important;
|
||||||
|
border: 1px solid rgba(255,255,255,0.10) !important;
|
||||||
|
border-radius: @radius-input !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagInput
|
||||||
|
.semi-tagInput-wrapper {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12) !important;
|
||||||
|
border-radius: @radius-input !important;
|
||||||
|
min-height: 38px;
|
||||||
|
outline: none !important;
|
||||||
|
&:focus-within {
|
||||||
|
border-color: @color-border-bright !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.semi-tagInput {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
// Tag chips inside TagInput
|
||||||
|
.semi-tag {
|
||||||
|
background: @color-elevated !important;
|
||||||
|
border: 1px solid @color-border-bright !important;
|
||||||
|
color: @color-text !important;
|
||||||
|
border-radius: @radius-chip !important;
|
||||||
|
font-size: @text-sm !important;
|
||||||
|
height: 24px !important;
|
||||||
|
line-height: 22px !important;
|
||||||
|
}
|
||||||
|
.semi-tag-close {
|
||||||
|
color: @color-muted !important;
|
||||||
|
&:hover {
|
||||||
|
color: @color-text !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
ui/src/components/table/JobsTable.jsx
Normal file
128
ui/src/components/table/JobsTable.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button, Tag, Tooltip, Switch } from '@douyinfe/semi-ui-19';
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconBell,
|
||||||
|
IconBriefcase,
|
||||||
|
IconCopy,
|
||||||
|
IconDelete,
|
||||||
|
IconDescend2,
|
||||||
|
IconEdit,
|
||||||
|
IconHome,
|
||||||
|
IconPlayCircle,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
import './JobsTable.less';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ jobs: object[], onRun: Function, onEdit: Function, onClone: Function, onDeleteListings: Function, onDeleteJob: Function, onStatusChange: Function }} props
|
||||||
|
*/
|
||||||
|
const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob, onStatusChange }) => (
|
||||||
|
<div className="jobsTable">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
|
||||||
|
<div className="jobsTable__row__dot">
|
||||||
|
<span
|
||||||
|
className={`jobsTable__row__dot__indicator${job.enabled ? ' jobsTable__row__dot__indicator--active' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jobsTable__row__name" title={job.name}>
|
||||||
|
{job.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jobsTable__row__stat jobsTable__row__stat--blue">
|
||||||
|
<IconHome size="small" />
|
||||||
|
{job.numberOfFoundListings || 0}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jobsTable__row__stat jobsTable__row__stat--orange">
|
||||||
|
<IconBriefcase size="small" />
|
||||||
|
{job.provider?.length || 0}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jobsTable__row__stat jobsTable__row__stat--purple">
|
||||||
|
<IconBell size="small" />
|
||||||
|
{job.notificationAdapter?.length || 0}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jobsTable__row__badges">
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={job.enabled}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onChange={(checked) => onStatusChange(job.id, checked)}
|
||||||
|
/>
|
||||||
|
{job.running && (
|
||||||
|
<Tag color="green" variant="light" size="small">
|
||||||
|
RUNNING
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{job.isOnlyShared && (
|
||||||
|
<Tooltip content="Shared with you — read only">
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="jobsTable__row__actions">
|
||||||
|
<Tooltip content="Run Job">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{ background: '#21aa21b5' }}
|
||||||
|
size="small"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconPlayCircle />}
|
||||||
|
disabled={job.isOnlyShared || job.running}
|
||||||
|
onClick={() => onRun(job.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Edit Job">
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
size="small"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onEdit(job.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Clone Job">
|
||||||
|
<Button
|
||||||
|
type="tertiary"
|
||||||
|
size="small"
|
||||||
|
icon={<IconCopy />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onClone(job.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Delete all found Listings">
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
icon={<IconDescend2 />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onDeleteListings(job.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Delete Job">
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onDeleteJob(job.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default JobsTable;
|
||||||
105
ui/src/components/table/JobsTable.less
Normal file
105
ui/src/components/table/JobsTable.less
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
|
.jobsTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 1fr 80px 80px 80px auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: @space-3;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: @color-elevated;
|
||||||
|
border: 1px solid @color-border;
|
||||||
|
border-radius: @radius-chip;
|
||||||
|
transition: background @transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--inactive {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: rgba(251, 113, 133, 0.7);
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background-color: rgba(52, 211, 153, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: @text-sm;
|
||||||
|
color: @color-text;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
font-size: @text-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
color: @color-blue-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--orange {
|
||||||
|
color: @color-orange-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
color: @color-purple-text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
grid-template-columns: 24px 1fr 80px auto auto;
|
||||||
|
|
||||||
|
.jobsTable__row__stat--orange,
|
||||||
|
.jobsTable__row__stat--purple {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
grid-template-columns: 24px 1fr auto auto;
|
||||||
|
|
||||||
|
.jobsTable__row__stat--blue {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
ui/src/components/table/ListingsTable.jsx
Normal file
132
ui/src/components/table/ListingsTable.jsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button, Tooltip } from '@douyinfe/semi-ui-19';
|
||||||
|
import {
|
||||||
|
IconBriefcase,
|
||||||
|
IconCart,
|
||||||
|
IconDelete,
|
||||||
|
IconLink,
|
||||||
|
IconMapPin,
|
||||||
|
IconStar,
|
||||||
|
IconStarStroked,
|
||||||
|
IconEyeOpened,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import no_image from '../../assets/no_image.png';
|
||||||
|
import * as timeService from '../../services/time/timeService.js';
|
||||||
|
|
||||||
|
import './ListingsTable.less';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
|
||||||
|
*/
|
||||||
|
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
|
||||||
|
<div className="listingsTable">
|
||||||
|
{listings.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`listingsTable__row${!item.is_active ? ' listingsTable__row--inactive' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="listingsTable__row__thumb">
|
||||||
|
<img
|
||||||
|
src={item.image_url || no_image}
|
||||||
|
alt={item.title}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src = no_image;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="listingsTable__row__title" title={item.title}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="listingsTable__row__price">
|
||||||
|
{item.price ? (
|
||||||
|
<>
|
||||||
|
<IconCart size="small" />
|
||||||
|
{item.price}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="listingsTable__row__empty">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="listingsTable__row__address">
|
||||||
|
{item.address ? (
|
||||||
|
<>
|
||||||
|
<IconMapPin size="small" />
|
||||||
|
{item.address}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="listingsTable__row__empty">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="listingsTable__row__meta">
|
||||||
|
<IconBriefcase size="small" />
|
||||||
|
{item.provider}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
|
||||||
|
|
||||||
|
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="listingsTable__row__star"
|
||||||
|
onClick={(e) => onWatch(e, item)}
|
||||||
|
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||||
|
>
|
||||||
|
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||||
|
</button>
|
||||||
|
<Tooltip content="Original Listing">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconLink />}
|
||||||
|
style={{ color: '#60a5fa' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(item.link, '_blank');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="View in Fredy">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconEyeOpened />}
|
||||||
|
style={{ color: '#34d399' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNavigate(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Remove">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
style={{ color: '#fb7185' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ListingsTable;
|
||||||
142
ui/src/components/table/ListingsTable.less
Normal file
142
ui/src/components/table/ListingsTable.less
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
@import '../../tokens.less';
|
||||||
|
|
||||||
|
.listingsTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: @space-3;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: @color-elevated;
|
||||||
|
border: 1px solid @color-border;
|
||||||
|
border-radius: @radius-chip;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background @transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #252525;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--inactive {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__thumb {
|
||||||
|
width: 56px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: @radius-chip;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: @text-sm;
|
||||||
|
color: @color-text;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
font-size: @text-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @color-success;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__address {
|
||||||
|
font-size: @text-xs;
|
||||||
|
color: @color-muted;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
font-size: @text-xs;
|
||||||
|
color: @color-muted;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: @text-xs;
|
||||||
|
color: @color-faint;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__star {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background @transition-fast;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: @color-accent;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
color: @color-faint;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
grid-template-columns: 56px 1fr 120px auto;
|
||||||
|
|
||||||
|
.listingsTable__row__address,
|
||||||
|
.listingsTable__row__meta,
|
||||||
|
.listingsTable__row__date {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
grid-template-columns: 56px 1fr auto;
|
||||||
|
|
||||||
|
.listingsTable__row__price {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,11 @@
|
|||||||
|
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
|
import { Table, Button, Empty, Tag } from '@douyinfe/semi-ui-19';
|
||||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const empty = (
|
const empty = (
|
||||||
<Empty
|
<Empty image={<IllustrationNoResult />} darkModeImage={<IllustrationNoResultDark />} description="No users found." />
|
||||||
image={<IllustrationNoResult />}
|
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
|
||||||
description={'No users found.'}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
||||||
@@ -23,47 +19,73 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
|
|||||||
empty={empty}
|
empty={empty}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'Username',
|
title: 'User',
|
||||||
dataIndex: 'username',
|
dataIndex: 'username',
|
||||||
|
render: (value, record) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span style={{ color: '#efefef', fontWeight: 500 }}>{value}</span>
|
||||||
|
{record.isAdmin && (
|
||||||
|
<Tag
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(224,74,56,0.12)',
|
||||||
|
border: '1px solid rgba(224,74,56,0.35)',
|
||||||
|
color: '#e04a38',
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
padding: '0 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ADMIN
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Last login',
|
title: 'Last login',
|
||||||
dataIndex: 'lastLogin',
|
dataIndex: 'lastLogin',
|
||||||
render: (value) => {
|
render: (value) => (value == null ? '---' : format(value)),
|
||||||
return format(value);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Number of jobs',
|
title: 'Jobs',
|
||||||
dataIndex: 'numberOfJobs',
|
dataIndex: 'numberOfJobs',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'MCP Token',
|
title: 'MCP Token',
|
||||||
dataIndex: 'mcpToken',
|
dataIndex: 'mcpToken',
|
||||||
render: (value) => {
|
render: (value) => (
|
||||||
return (
|
<span
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.85em', wordBreak: 'break-all' }}>
|
style={{
|
||||||
{value || '---'}
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
</span>
|
fontSize: '0.85em',
|
||||||
);
|
wordBreak: 'break-all',
|
||||||
},
|
color: '#505050',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value || '---'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
dataIndex: 'tools',
|
dataIndex: 'tools',
|
||||||
render: (value, user) => {
|
render: (_, record) => (
|
||||||
return (
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
<div style={{ float: 'right' }}>
|
<Button
|
||||||
<Button
|
style={{
|
||||||
type="danger"
|
background: 'transparent',
|
||||||
icon={<IconDelete />}
|
border: '1px solid rgba(251,113,133,0.2)',
|
||||||
onClick={() => onUserRemoval(user.id)}
|
color: '#fb7185',
|
||||||
style={{ marginRight: '1rem' }}
|
}}
|
||||||
/>
|
icon={<IconDelete />}
|
||||||
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} />
|
onClick={() => onUserRemoval(record.id)}
|
||||||
</div>
|
/>
|
||||||
);
|
<Button type="primary" theme="solid" icon={<IconEdit />} onClick={() => onUserEdit(record.id)} />
|
||||||
},
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
dataSource={user}
|
dataSource={user}
|
||||||
|
|||||||
@@ -321,6 +321,34 @@ export const useFredyState = create(
|
|||||||
throw Exception;
|
throw Exception;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async setListingsViewMode(listings_view_mode) {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode });
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
settings: { ...state.userSettings.settings, listings_view_mode },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to update listings view mode setting. Error:', Exception);
|
||||||
|
throw Exception;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setJobsViewMode(jobs_view_mode) {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/user/settings/jobs-view-mode', { jobs_view_mode });
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
settings: { ...state.userSettings.settings, jobs_view_mode },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to update jobs view mode setting. Error:', Exception);
|
||||||
|
throw Exception;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user