pivotgrid-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ """
2
+ server.py — DB proxy and config storage
3
+
4
+ Endpoints:
5
+ POST /query → execute SELECT, return JSON array
6
+ GET /configs → list config names
7
+ GET /configs/{name} → get config by name
8
+ POST /configs/{name} → save config by name
9
+ GET /server-config → DB settings (no password) + connector list
10
+ POST /server-config → save DB settings to .env
11
+
12
+ Connectors: add a file to ./connectors/ with NAME and execute_query(query).
13
+ Configs: stored in ./configs/{name}.json
14
+ Settings: stored in .env
15
+
16
+ Installation (for PostgreSQL):
17
+ pip install psycopg2-binary
18
+
19
+ Start:
20
+ python server.py
21
+ """
22
+
23
+ import json
24
+ import gzip
25
+ import os
26
+ import re
27
+ import importlib.util
28
+ from http.server import HTTPServer, BaseHTTPRequestHandler
29
+ from socketserver import ThreadingMixIn
30
+
31
+
32
+ # ── Paths ─────────────────────────────────────────────────────────────────────
33
+
34
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
35
+ ENV_PATH = os.path.join(BASE_DIR, '.env')
36
+ CONFIGS_DIR = os.path.join(BASE_DIR, 'configs')
37
+ CONNECTORS_DIR = os.path.join(BASE_DIR, 'connectors')
38
+
39
+ os.makedirs(CONFIGS_DIR, exist_ok=True)
40
+
41
+ # ── Load .env ──────────────────────────────────────────────────────────────────
42
+
43
+ def load_env(path):
44
+ if not os.path.exists(path):
45
+ return
46
+ with open(path, 'r', encoding='utf-8') as f:
47
+ for line in f:
48
+ line = line.strip()
49
+ if not line or line.startswith('#') or '=' not in line:
50
+ continue
51
+ key, _, val = line.partition('=')
52
+ os.environ[key.strip()] = val.strip()
53
+
54
+ load_env(ENV_PATH)
55
+
56
+ # ── Connectors ─────────────────────────────────────────────────────────────────
57
+
58
+ def load_connectors():
59
+ """Scans connectors/ and loads all modules that have NAME and execute_query."""
60
+ connectors = {}
61
+ if not os.path.exists(CONNECTORS_DIR):
62
+ return connectors
63
+ for fname in sorted(os.listdir(CONNECTORS_DIR)):
64
+ if not fname.endswith('.py') or fname.startswith('_'):
65
+ continue
66
+ module_name = fname[:-3]
67
+ path = os.path.join(CONNECTORS_DIR, fname)
68
+ try:
69
+ spec = importlib.util.spec_from_file_location(module_name, path)
70
+ module = importlib.util.module_from_spec(spec)
71
+ spec.loader.exec_module(module)
72
+ if hasattr(module, 'NAME') and hasattr(module, 'execute_query'):
73
+ connectors[module_name] = module
74
+ print(f'Connector loaded: {module.NAME} ({module_name})')
75
+ except Exception as e:
76
+ print(f'Connector error [{module_name}]: {e}')
77
+ return connectors
78
+
79
+ CONNECTORS = load_connectors()
80
+
81
+ def get_active_connector():
82
+ """Returns the active connector based on DB_CONNECTOR from .env."""
83
+ name = os.getenv('DB_CONNECTOR', '')
84
+ if name and name in CONNECTORS:
85
+ return CONNECTORS[name]
86
+ # Fall back to the first available
87
+ if CONNECTORS:
88
+ return next(iter(CONNECTORS.values()))
89
+ raise RuntimeError('No connectors available in ./connectors/')
90
+
91
+ # ── Config ──────────────────────────────────────────────────────────────────────
92
+
93
+ PORT = int(os.getenv("PORT", "8000"))
94
+
95
+ CORS_HEADERS = {
96
+ 'Access-Control-Allow-Origin': '*',
97
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
98
+ 'Access-Control-Allow-Headers': 'Content-Type',
99
+ }
100
+
101
+ def valid_config_name(name):
102
+ return bool(re.match(r'^[\w\-]+$', name))
103
+
104
+ def config_path(name):
105
+ return os.path.join(CONFIGS_DIR, f"{name}.json")
106
+
107
+ def save_env(data):
108
+ """Saves settings to .env without touching other variables."""
109
+ lines = []
110
+ protected = {'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_CONNECTOR'}
111
+ if os.path.exists(ENV_PATH):
112
+ with open(ENV_PATH, 'r', encoding='utf-8') as f:
113
+ for line in f:
114
+ key = line.partition('=')[0].strip()
115
+ if key not in protected:
116
+ lines.append(line.rstrip('\n'))
117
+
118
+ if 'host' in data: lines.append(f"DB_HOST={data['host']}")
119
+ if 'port' in data: lines.append(f"DB_PORT={data['port']}")
120
+ if 'dbname' in data: lines.append(f"DB_NAME={data['dbname']}")
121
+ if 'user' in data: lines.append(f"DB_USER={data['user']}")
122
+ if 'password' in data and data['password']:
123
+ lines.append(f"DB_PASSWORD={data['password']}")
124
+ if 'connector' in data: lines.append(f"DB_CONNECTOR={data['connector']}")
125
+
126
+ with open(ENV_PATH, 'w', encoding='utf-8') as f:
127
+ f.write('\n'.join(lines) + '\n')
128
+
129
+ # Update os.environ immediately
130
+ for key, env_key in [('host','DB_HOST'),('port','DB_PORT'),('dbname','DB_NAME'),
131
+ ('user','DB_USER'),('connector','DB_CONNECTOR')]:
132
+ if key in data:
133
+ os.environ[env_key] = str(data[key])
134
+ if 'password' in data and data['password']:
135
+ os.environ['DB_PASSWORD'] = data['password']
136
+
137
+ # ── Handler ───────────────────────────────────────────────────────────────────────
138
+
139
+ class Handler(BaseHTTPRequestHandler):
140
+ protocol_version = 'HTTP/1.1'
141
+
142
+ def do_OPTIONS(self):
143
+ self._send(200, '')
144
+
145
+ def do_DELETE(self):
146
+ m = re.match(r'^/configs/([\w\-]+)$', self.path)
147
+ if m:
148
+ name = m.group(1)
149
+ if not valid_config_name(name):
150
+ self._send(400, json.dumps({'error': 'Invalid config name'}))
151
+ return
152
+ path = config_path(name)
153
+ if not os.path.exists(path):
154
+ self._send(404, json.dumps({'error': f'Config "{name}" not found'}))
155
+ return
156
+ os.remove(path)
157
+ print(f'Config deleted: {name}')
158
+ self._send(200, json.dumps({'ok': True, 'name': name}))
159
+ return
160
+ self._send(404, json.dumps({'error': 'Not found'}))
161
+ # ── GET ───────────────────────────────────────────────────────────────────
162
+
163
+ def do_GET(self):
164
+ # GET /configs → list of config names
165
+ if self.path == '/configs':
166
+ names = sorted(f[:-5] for f in os.listdir(CONFIGS_DIR) if f.endswith('.json'))
167
+ self._send(200, json.dumps(names))
168
+ return
169
+
170
+ # GET /configs/{name} → config by name
171
+ m = re.match(r'^/configs/([\w\-]+)$', self.path)
172
+ if m:
173
+ name = m.group(1)
174
+ if not valid_config_name(name):
175
+ self._send(400, json.dumps({'error': 'Invalid config name'}))
176
+ return
177
+ path = config_path(name)
178
+ if not os.path.exists(path):
179
+ self._send(404, json.dumps({'error': f'Config "{name}" not found'}))
180
+ return
181
+ with open(path, 'r', encoding='utf-8') as f:
182
+ self._send(200, f.read())
183
+ return
184
+
185
+ # GET /server-config → DB settings (no password) + connectors
186
+ if self.path == '/server-config':
187
+ active = os.getenv('DB_CONNECTOR', next(iter(CONNECTORS), ''))
188
+ result = {
189
+ 'host': os.getenv('DB_HOST', 'localhost'),
190
+ 'port': os.getenv('DB_PORT', '5432'),
191
+ 'dbname': os.getenv('DB_NAME', 'postgres'),
192
+ 'user': os.getenv('DB_USER', 'postgres'),
193
+ 'connector': active,
194
+ 'connectors': {k: v.NAME for k, v in CONNECTORS.items()},
195
+ }
196
+ self._send(200, json.dumps(result))
197
+ return
198
+
199
+ self._send(404, json.dumps({'error': 'Not found'}))
200
+
201
+ # ── POST ──────────────────────────────────────────────────────────────────
202
+
203
+ def do_POST(self):
204
+ length = int(self.headers.get('Content-Length', 0))
205
+ body = self.rfile.read(length)
206
+
207
+ if self.path == '/query':
208
+ self._handle_query(body)
209
+ return
210
+
211
+ if self.path == '/server-config':
212
+ self._handle_save_server_config(body)
213
+ return
214
+
215
+ if self.path == '/test-connection':
216
+ self._handle_test_connection(body)
217
+ return
218
+
219
+ m = re.match(r'^/configs/([\w\-]+)$', self.path)
220
+ if m:
221
+ self._handle_save_config(m.group(1), body)
222
+ return
223
+
224
+ self._send(404, json.dumps({'error': 'Not found'}))
225
+
226
+ # ── SQL query ────────────────────────────────────────────────────────────
227
+
228
+ def _handle_query(self, body):
229
+ try:
230
+ payload = json.loads(body)
231
+ except Exception:
232
+ self._send(400, json.dumps({'error': 'Invalid JSON'}))
233
+ return
234
+
235
+ query = payload.get('query', '').strip()
236
+ if not query.upper().startswith('SELECT'):
237
+ self._send(400, json.dumps({'error': 'Only SELECT allowed'}))
238
+ return
239
+
240
+ try:
241
+ connector = get_active_connector()
242
+ rows = connector.execute_query(query)
243
+ self._send(200, json.dumps(rows, ensure_ascii=False, default=str))
244
+ except Exception as e:
245
+ self._send(500, json.dumps({'error': str(e)}))
246
+
247
+ # ── Save config ──────────────────────────────────────────────────────────
248
+
249
+ def _handle_save_config(self, name, body):
250
+ if not valid_config_name(name):
251
+ self._send(400, json.dumps({'error': 'Invalid config name'}))
252
+ return
253
+ try:
254
+ data = json.loads(body)
255
+ with open(config_path(name), 'w', encoding='utf-8') as f:
256
+ json.dump(data, f, ensure_ascii=False, indent=2)
257
+ print(f'Config saved: {name}')
258
+ self._send(200, json.dumps({'ok': True, 'name': name}))
259
+ except json.JSONDecodeError:
260
+ self._send(400, json.dumps({'error': 'Invalid JSON'}))
261
+ except Exception as e:
262
+ self._send(500, json.dumps({'error': str(e)}))
263
+
264
+ # ── Save DB settings ─────────────────────────────────────────────────────
265
+
266
+ def _handle_save_server_config(self, body):
267
+ try:
268
+ data = json.loads(body)
269
+ save_env(data)
270
+ print('Server config saved')
271
+ self._send(200, json.dumps({'ok': True}))
272
+ except json.JSONDecodeError:
273
+ self._send(400, json.dumps({'error': 'Invalid JSON'}))
274
+ except Exception as e:
275
+ self._send(500, json.dumps({'error': str(e)}))
276
+
277
+ def _handle_test_connection(self, body):
278
+ try:
279
+ data = json.loads(body)
280
+ connector = get_active_connector()
281
+ if not hasattr(connector, 'test_connection'):
282
+ self._send(400, json.dumps({'error': 'Connector does not support connection test'}))
283
+ return
284
+ connector.test_connection(
285
+ host=data.get('host', os.getenv('DB_HOST', 'localhost')),
286
+ port=data.get('port', os.getenv('DB_PORT', '5432')),
287
+ dbname=data.get('dbname', os.getenv('DB_NAME', 'postgres')),
288
+ user=data.get('user', os.getenv('DB_USER', 'postgres')),
289
+ password=data.get('password', os.getenv('DB_PASSWORD', '')),
290
+ )
291
+ self._send(200, json.dumps({'ok': True}))
292
+ except Exception as e:
293
+ self._send(500, json.dumps({'error': str(e)}))
294
+
295
+ # ── HTTP response ─────────────────────────────────────────────────────────
296
+
297
+ def _send(self, status, body, content_type='application/json; charset=utf-8'):
298
+ encoded = body.encode('utf-8') if isinstance(body, str) else body
299
+ accept_encoding = self.headers.get('Accept-Encoding', '')
300
+ use_gzip = 'gzip' in accept_encoding and len(encoded) > 1024
301
+ if use_gzip:
302
+ encoded = gzip.compress(encoded, compresslevel=6)
303
+ self.send_response(status)
304
+ for k, v in CORS_HEADERS.items():
305
+ self.send_header(k, v)
306
+ self.send_header('Content-Type', content_type)
307
+ self.send_header('Content-Length', str(len(encoded)))
308
+ self.send_header('Connection', 'keep-alive')
309
+ if use_gzip:
310
+ self.send_header('Content-Encoding', 'gzip')
311
+ self.end_headers()
312
+ if encoded:
313
+ self.wfile.write(encoded)
314
+
315
+ def log_message(self, fmt, *args):
316
+ print(f'{self.address_string()} — {fmt % args}')
317
+
318
+ # ── Start ───────────────────────────────────────────────────────────────────────
319
+
320
+ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
321
+ daemon_threads = True
322
+
323
+ if __name__ == '__main__':
324
+ server = ThreadingHTTPServer(('localhost', PORT), Handler)
325
+ print(f'Server: http://localhost:{PORT}')
326
+ print(f'Configs: {CONFIGS_DIR}')
327
+ print(f'Connectors: {list(CONNECTORS.keys())}')
328
+ server.serve_forever()
@@ -0,0 +1,167 @@
1
+ .fz-zone--filters { flex: 1.5; }
2
+
3
+ [data-fz-zone="filters"] .fz-chip {
4
+ background: #fff8f0;
5
+ border-color: #f4c27a;
6
+ color: #7a4200;
7
+ }
8
+
9
+ [data-fz-zone="filters"] .fz-chip:hover {
10
+ border-color: #e67e22;
11
+ color: #5d3100;
12
+ box-shadow: 0 1px 4px rgba(230,126,34,0.2);
13
+ }
14
+
15
+ [data-fz-zone="filters"] .fz-chip.fz-chip--active {
16
+ background: #fff0dc;
17
+ border-color: #e67e22;
18
+ font-weight: 500;
19
+ }
20
+
21
+ .fz-chip-label { cursor: pointer; }
22
+
23
+ /* ── Field Zones ────────────────────────────────────────────────────── */
24
+
25
+ .field-zones {
26
+ display: flex;
27
+ gap: 8px;
28
+ padding: 8px 12px;
29
+ background: #fff;
30
+ border: 1px solid #e0e0e0;
31
+ border-radius: 8px;
32
+ min-height: 48px;
33
+ align-items: stretch;
34
+ margin-bottom: 8px;
35
+ flex-shrink: 0;
36
+ }
37
+
38
+ .fz-zone {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 4px;
42
+ flex: 1;
43
+ min-width: 0;
44
+ }
45
+
46
+ .fz-zone--rows { flex: 2; }
47
+ .fz-zone--columns { flex: 1.5; }
48
+ .fz-zone--free { flex: 1.5; }
49
+
50
+ .fz-zone-label {
51
+ font-size: 10px;
52
+ font-weight: 600;
53
+ color: #999;
54
+ text-transform: uppercase;
55
+ letter-spacing: 0.06em;
56
+ padding: 0 4px;
57
+ }
58
+
59
+ .fz-zone-body {
60
+ display: flex;
61
+ flex-wrap: wrap;
62
+ gap: 4px;
63
+ min-height: 33px;
64
+ max-height: 66px;
65
+ overflow-y: auto;
66
+ padding: 4px 6px;
67
+ border: 1.5px dashed #d0d0d0;
68
+ border-radius: 6px;
69
+ background: #fff;
70
+ transition: background 0.15s, border-color 0.15s;
71
+ }
72
+
73
+ .fz-zone-body.fz-zone--over {
74
+ background: #e8f0fe;
75
+ border-color: #1a73e8;
76
+ }
77
+
78
+ /* Chips */
79
+ .fz-chip {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 4px;
83
+ padding: 3px 8px;
84
+ background: #fff;
85
+ border: 1px solid #d0d0d0;
86
+ border-radius: 4px;
87
+ font-size: 12px;
88
+ color: #333;
89
+ cursor: grab;
90
+ user-select: none;
91
+ white-space: nowrap;
92
+ transition: box-shadow 0.1s, border-color 0.1s;
93
+ }
94
+
95
+ .fz-chip:hover {
96
+ border-color: #1a73e8;
97
+ color: #1a73e8;
98
+ box-shadow: 0 1px 4px rgba(26,115,232,0.15);
99
+ }
100
+
101
+ /* Chips in rows/columns — slightly coloured */
102
+ [data-fz-zone="rows"] .fz-chip {
103
+ background: #f0f4ff;
104
+ border-color: #c5d3f7;
105
+ color: #2c4bad;
106
+ }
107
+
108
+ [data-fz-zone="columns"] .fz-chip {
109
+ background: #f0fdf4;
110
+ border-color: #a7d7b8;
111
+ color: #1a6638;
112
+ }
113
+
114
+ .fz-chip--dragging {
115
+ opacity: 0.35;
116
+ cursor: grabbing;
117
+ }
118
+
119
+ .fz-chip--ghost {
120
+ cursor: grabbing;
121
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
122
+ }
123
+
124
+ .fz-chip-remove {
125
+ font-size: 13px;
126
+ line-height: 1;
127
+ color: #999;
128
+ cursor: pointer;
129
+ padding: 0 1px;
130
+ border-radius: 2px;
131
+ }
132
+
133
+ .fz-chip-remove:hover {
134
+ color: #e53935;
135
+ background: #fde;
136
+ }
137
+
138
+ .fz-chip--filtered {
139
+ border-color: #6172f3;
140
+ }
141
+
142
+ .fz-chip-hint {
143
+ font-size: 10px;
144
+ font-weight: 600;
145
+ color: #6172f3;
146
+ background: #eff4ff;
147
+ border-radius: 4px;
148
+ padding: 0 4px;
149
+ margin-left: 2px;
150
+ pointer-events: none;
151
+ }
152
+
153
+ .fz-tooltip {
154
+ display: none;
155
+ position: fixed;
156
+ z-index: 10000;
157
+ background: #fff;
158
+ color: #344054;
159
+ font-size: 12px;
160
+ padding: 6px 10px;
161
+ border: 1px solid #d0d5dd;
162
+ border-radius: 6px;
163
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
164
+ white-space: pre-wrap;
165
+ max-width: 260px;
166
+ pointer-events: none;
167
+ }