Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23fb25dcd5 | |||
| 8ddcb56f90 | |||
| 9d186ece0a | |||
| 55cb4880ce | |||
| 171aae7095 | |||
| 9aad9c0c2a | |||
| b91d3a9341 | |||
| 590e4bb039 | |||
| c4bef0d443 | |||
| 7f05b3b52d | |||
| 1fc1de6691 | |||
| 70c43ceae3 | |||
| cf247117d4 | |||
| 35ffde9253 | |||
| 531ec5a2d0 | |||
| cde6ccd841 | |||
| f181f662d8 | |||
| 85f26c69d6 | |||
| 9d1471a363 | |||
| 6db2e3ff4f | |||
| bbbc0958f4 | |||
| 52c9c7251e | |||
| a1409a4d57 | |||
| 61359bc712 | |||
| 889f84d666 | |||
| a188fcbe85 | |||
| 53b730914c | |||
| 51b4312cc0 | |||
| 668839887f | |||
| 567551b544 | |||
| d5c265860c | |||
| 0f6bfe45aa | |||
| 33a97b410e | |||
| bd6bce04c9 | |||
| 204253f78c | |||
| d1c19389d7 | |||
| 77630cf540 | |||
| b835318a3b | |||
| 91194e1c0d | |||
| 01c77d9427 | |||
| bf451e7a17 | |||
| 75f82780e4 |
@@ -2,6 +2,17 @@
|
||||
"@git.zone/tswatch": {
|
||||
"preset": "element"
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_bundle/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "wcc",
|
||||
"module": {
|
||||
138
changelog.md
138
changelog.md
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-07 - 2.12.2 - fix(ts_web)
|
||||
adjust route card section background color in dark theme
|
||||
|
||||
- Updates the dark theme background for route card sections from #0a0a0a to #101010 for improved visual consistency.
|
||||
|
||||
## 2026-04-07 - 2.12.1 - fix(ts_web)
|
||||
handle slotted config section content visibility and field row borders correctly
|
||||
|
||||
- track whether the default slot has assigned content and hide the slot container when empty
|
||||
- wrap rendered fields in a dedicated list so the last field row border is removed correctly when slot content is present
|
||||
|
||||
## 2026-04-07 - 2.12.0 - feat(elements)
|
||||
standardize dashboard and detail views on dees tile and stats grid components
|
||||
|
||||
- replace custom stat cards with dees-statsgrid across status and network views and remove the obsolete sz-stat-card element
|
||||
- migrate multiple detail and settings views to dees-tile headers and footers for consistent action placement and styling
|
||||
- extend sz-config-section with footer links and actions plus improved collapsed tile behavior
|
||||
|
||||
## 2026-04-05 - 2.11.2 - fix(route-card)
|
||||
align route card with source profile metadata and vpnOnly route configuration
|
||||
|
||||
- rename linked route metadata fields from security profile to source profile in rendering and feature detection
|
||||
- simplify VPN display logic to use the boolean vpnOnly flag instead of the previous nested VPN configuration object
|
||||
|
||||
## 2026-04-04 - 2.11.1 - fix(route-card)
|
||||
clarify VPN mode badge labels in route cards
|
||||
|
||||
- Renames VPN badge text from "VPN Only"/"VPN + Public" to "VPN Mandatory"/"VPN Voluntary" for clearer route mode descriptions.
|
||||
|
||||
## 2026-04-02 - 2.11.0 - feat(route-ui)
|
||||
add VPN details and conditional card actions to route cards
|
||||
|
||||
- Extend route card data and rendering to display VPN access mode and allowed client tags.
|
||||
- Add optional Edit and Delete action buttons that emit route-edit and route-delete events.
|
||||
- Allow the route list view to control action visibility per route via a showActionsFilter callback.
|
||||
- Include VPN as a visible route feature indicator in the card summary.
|
||||
|
||||
## 2026-04-02 - 2.10.0 - feat(docs)
|
||||
document newly available catalog components and updated build configuration details
|
||||
|
||||
- Update component counts and add documentation for App Store, Routes, MTA/Email, and Configuration views
|
||||
- Expand the README with new component tables and exported TypeScript types
|
||||
- Refresh build notes to reference .smartconfig.json and the renamed config file
|
||||
|
||||
## 2026-04-02 - 2.9.1 - fix(build)
|
||||
migrate build configuration to .smartconfig and update toolchain dependencies
|
||||
|
||||
- replace npmextra.json with .smartconfig.json and add tsbundle bundle configuration
|
||||
- simplify the build script to use the centralized tsbundle configuration
|
||||
- update build and UI package dependencies and switch TypeScript to explicit node types
|
||||
|
||||
## 2026-03-19 - 2.9.0 - feat(app-store-view)
|
||||
add app details action to store cards
|
||||
|
||||
- adds a new "View Details" button alongside the existing deploy action
|
||||
- dispatches a bubbling "view-app" event with the selected app payload for parent handlers
|
||||
- updates card action layout and button styling to support multiple actions
|
||||
|
||||
## 2026-03-18 - 2.8.0 - feat(elements)
|
||||
add app store view component for browsing and deploying app templates
|
||||
|
||||
- introduces a new sz-app-store-view element with app card rendering, search/filter empty states, and deploy action events
|
||||
- exports the new app store view from the elements index for public consumption
|
||||
|
||||
## 2026-03-17 - 2.7.0 - feat(sz-service-detail-view)
|
||||
replace the custom logs panel with dees-chart-log in the service detail view
|
||||
|
||||
- Removes the bespoke log streaming UI styles and markup in favor of a shared log chart component.
|
||||
- Maps service log entries to structured timestamp, level, and message data for the new component.
|
||||
- Enables auto-scrolling, metrics display, and a higher log entry limit in the embedded log viewer.
|
||||
|
||||
## 2026-03-16 - 2.6.2 - fix(platform-service-detail-view)
|
||||
wrap service logs chart in a full-width container to preserve layout
|
||||
|
||||
- Places the logs component inside a container spanning all grid columns.
|
||||
- Keeps the service logs view aligned correctly within the detail page layout.
|
||||
|
||||
## 2026-03-16 - 2.6.1 - fix(platform-service-detail-view)
|
||||
replace custom service log markup with dees-chart-log in the platform service detail view
|
||||
|
||||
- Removes bespoke log container styles and rendering logic in favor of the shared dees-chart-log component
|
||||
- Normalizes log timestamps to ISO format before passing entries to the chart component
|
||||
- Enables log auto-scroll, entry limits, and metrics display for service logs
|
||||
|
||||
## 2026-03-16 - 2.6.0 - feat(service-create-view)
|
||||
add platform service toggles for MongoDB, S3, and ClickHouse provisioning
|
||||
|
||||
- Adds a new Platform Services section to the service creation view with dedicated toggles for managed infrastructure dependencies.
|
||||
- Includes MongoDB, S3-compatible storage, and ClickHouse selections in the emitted create-service configuration payload.
|
||||
- Resets selected platform services after form submission to keep create flow state consistent.
|
||||
|
||||
## 2026-02-23 - 2.5.0 - feat(sz-config-section)
|
||||
add header action buttons to sz-config-section allowing configurable actions/events
|
||||
|
||||
- Introduce IConfigSectionAction interface (label, icon, event, detail).
|
||||
- Add actions property to SzConfigSection and render header action buttons in the component template.
|
||||
- Add styles for .header-action and hover state to match design system.
|
||||
- Dispatch CustomEvent when an action is clicked, using action.event (defaults to 'action') and action.detail.
|
||||
- Update demo (sz-demo-view-config) to include a sample 'View Routes' action showing usage.
|
||||
|
||||
## 2026-02-23 - 2.4.0 - feat(elements)
|
||||
add configuration overview and section components with demo view and index exports
|
||||
|
||||
- Adds new sz-config-section component (IConfigField interface, rich renderers for boolean, pills, badge, code, link and 'Not configured' handling).
|
||||
- Adds new sz-config-overview wrapper component with heading/info banner and slot styling.
|
||||
- Adds demo view sz-demo-view-config that supplies example configuration groups and fields for System, Proxy, Email, DNS, TLS, Cache, RADIUS and Remote Ingress.
|
||||
- Exports new components from ts_web/elements/index.ts so they are available to the element registry.
|
||||
|
||||
## 2026-02-22 - 2.3.0 - feat(routes)
|
||||
add route UI components and demo view with list/card and app-shell integration
|
||||
|
||||
- Add new route UI components: sz-route-card, sz-route-list-view, and sz-demo-view-routes under ts_web/elements
|
||||
- Export new components from ts_web/elements/index.ts and register demo view in the demo app shell menu (ts_web/pages/sz-demo-app-shell.ts)
|
||||
- sz-route-card introduces route types/interfaces (IRouteConfig, IRouteAction, IRouteMatch, IRouteTls, IRouteSecurity) and rich rendering (ports/domains formatting, feature icons, security and headers display)
|
||||
- sz-route-list-view provides demo data, search, filtering (by action and enabled state), results count, grid rendering of sz-route-card, and emits a 'route-click' event
|
||||
- Demo view integrates with app UI secondary menu (actions and statistics) and wires up route-click handling for interactivity
|
||||
|
||||
## 2026-02-21 - 2.2.0 - feat(demo-mta)
|
||||
add MTA / Email demo views and components and integrate into demo app shell
|
||||
|
||||
- Add sz-mta-list-view component with search, direction/status filters, and a results table
|
||||
- Add sz-demo-view-mta demo page with sample emails and detailed SMTP log data; handles list -> detail navigation
|
||||
- Export new MTA elements from ts_web/elements/index.ts and register sz-demo-view-mta
|
||||
- Integrate MTA view into sz-demo-app-shell navigation and include it in the Infrastructure section
|
||||
|
||||
## 2026-02-20 - 2.1.0 - feat(catalog)
|
||||
add comprehensive README documenting package purpose, components, usage, development workflow, and legal information
|
||||
|
||||
- Adds new readme.md at repository root for @serve.zone/catalog describing 30+ UI components, tags, and example usage.
|
||||
- Includes installation instructions, import examples, demo app shell info, development scripts (pnpm install/watch/build/test) and project structure overview.
|
||||
- Documents TypeScript interfaces exported, component architecture patterns, and license/trademark/company contact details.
|
||||
|
||||
## 2026-02-20 - 2.0.1 - fix(catalog)
|
||||
no changes detected
|
||||
|
||||
- No files changed in the diff
|
||||
- package.json version remains 2.0.0
|
||||
|
||||
## 2026-02-20 - 2.0.0 - BREAKING CHANGE(elements)
|
||||
rename Onebox registry component to Registry Advertisement (sz-registry-onebox-view → sz-registry-advertisement) and update exports, demos and docs
|
||||
|
||||
|
||||
21
license
Normal file
21
license
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/catalog",
|
||||
"version": "2.0.0",
|
||||
"version": "2.12.2",
|
||||
"private": false,
|
||||
"description": "UI component catalog for serve.zone",
|
||||
"main": "dist_ts_web/index.js",
|
||||
@@ -8,35 +8,34 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/",
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@design.estate/dees-catalog": "^3.43.0",
|
||||
"@design.estate/dees-domtools": "^2.3.8",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@design.estate/dees-catalog": "^3.67.1",
|
||||
"@design.estate/dees-domtools": "^2.5.4",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@design.estate/dees-wcctools": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@types/node": "^25.3.0"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsbundle": "^2.10.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@types/node": "^25.5.2"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
|
||||
3420
pnpm-lock.yaml
generated
3420
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Structure
|
||||
- `html/index.ts` - WccTools setup with sections for Pages and Elements
|
||||
- `ts_web/elements/` - All web components (27 elements + 6 demo-view wrappers)
|
||||
- `ts_web/elements/` - All web components (33 elements + 9 demo-view wrappers)
|
||||
- `ts_web/elements/index.ts` - Barrel export for all element components
|
||||
- `ts_web/pages/` - Page components
|
||||
|
||||
@@ -16,13 +16,18 @@
|
||||
## Demo Groups
|
||||
| Group | Elements |
|
||||
|-------|----------|
|
||||
| Dashboard | sz-dashboard-view, sz-stat-card, sz-resource-usage-card, sz-traffic-card, sz-quick-actions-card |
|
||||
| Dashboard | sz-dashboard-view, sz-resource-usage-card, sz-traffic-card, sz-quick-actions-card |
|
||||
| Dashboard Grids | sz-status-grid-cluster, sz-status-grid-services, sz-status-grid-network, sz-status-grid-infra |
|
||||
| Platform | sz-platform-services-card, sz-platform-service-detail-view |
|
||||
| Network | sz-network-proxy-view, sz-network-dns-view, sz-network-domains-view, sz-reverse-proxy-card, sz-dns-ssl-card, sz-certificates-card, sz-domain-detail-view |
|
||||
| Routes | sz-route-list-view, sz-route-card |
|
||||
| Services | sz-services-list-view, sz-services-backups-view, sz-service-detail-view, sz-service-create-view |
|
||||
| App Store | sz-app-store-view |
|
||||
| MTA / Email | sz-mta-list-view, sz-mta-detail-view |
|
||||
| Configuration | sz-config-overview, sz-config-section |
|
||||
| Auth & Settings | sz-login-view, sz-tokens-view, sz-settings-view, sz-registry-advertisement, sz-registry-external-view |
|
||||
|
||||
## Build
|
||||
- `pnpm run build` - tsbuild tsfolders + tsbundle element --production
|
||||
- `pnpm run build` - tsbuild tsfolders + tsbundle (reads from .smartconfig.json)
|
||||
- `pnpm run watch` - starts wcctools dev server
|
||||
- Config file: `.smartconfig.json` (renamed from npmextra.json)
|
||||
|
||||
273
readme.md
Normal file
273
readme.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# @serve.zone/catalog
|
||||
|
||||
The complete UI component library for **serve.zone** — a full-featured admin and management interface for onebox server management, built as a collection of reusable web components.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @serve.zone/catalog
|
||||
```
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## 🚀 What It Does
|
||||
|
||||
`@serve.zone/catalog` provides **34 production-ready web components** covering every aspect of server management:
|
||||
|
||||
- 📊 **Dashboard** — Real-time cluster overview, resource usage, traffic metrics, quick actions
|
||||
- 🐳 **Services** — Docker container management, deployment, logs, live stats, backups, and an integrated IDE workspace
|
||||
- 🛒 **App Store** — Browse and deploy pre-configured application templates (WordPress, Gitea, etc.)
|
||||
- 🌐 **Network** — Reverse proxy configuration, DNS record management, domain & SSL certificate monitoring
|
||||
- 🔀 **Routes** — SmartProxy route configuration, match criteria, TLS modes, security profiles, forwarding targets
|
||||
- 📧 **MTA / Email** — Inbound and outbound email management, SMTP transaction logs, authentication results
|
||||
- 📦 **Registries** — Container registry management (onebox + external registries like Docker Hub, GHCR, ECR)
|
||||
- 🔑 **Auth** — Login view, API token management (global + CI tokens)
|
||||
- ⚙️ **Settings** — Appearance, Cloudflare integration, SSL/TLS config, network settings, account management
|
||||
- 🏗️ **Platform Services** — MongoDB, MinIO, ClickHouse, Redis, Caddy monitoring and control
|
||||
- 📋 **Configuration** — Read-only overview of the running server configuration with collapsible sections
|
||||
|
||||
Every component supports **light and dark themes** out of the box and communicates via standard `CustomEvent` dispatching.
|
||||
|
||||
## 📦 Usage
|
||||
|
||||
### Import
|
||||
|
||||
```typescript
|
||||
// Import everything
|
||||
import * as szCatalog from '@serve.zone/catalog';
|
||||
|
||||
// Or import specific components
|
||||
import { SzDashboardView, SzLoginView, SzServiceDetailView } from '@serve.zone/catalog';
|
||||
```
|
||||
|
||||
Components auto-register as custom elements when imported. Use them directly in your HTML:
|
||||
|
||||
```html
|
||||
<sz-dashboard-view .data="${dashboardData}"></sz-dashboard-view>
|
||||
<sz-login-view @login="${handleLogin}"></sz-login-view>
|
||||
<sz-service-detail-view .service="${serviceData}" .logs="${logEntries}"></sz-service-detail-view>
|
||||
```
|
||||
|
||||
### Full Application Shell
|
||||
|
||||
For a complete app experience, use the demo app shell which wires up sidebar navigation, app bar menus, and all views via `dees-appui`:
|
||||
|
||||
```typescript
|
||||
import '@serve.zone/catalog';
|
||||
// Then use <sz-demo-app-shell> for a fully configured application
|
||||
```
|
||||
|
||||
## 🧩 Component Reference
|
||||
|
||||
### Dashboard
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzDashboardView` | `<sz-dashboard-view>` | Main dashboard orchestrating all grid sections — cluster, services, network, infrastructure |
|
||||
| `SzStatCard` | `<sz-stat-card>` | Single statistic card with label, value, icon, and color variant |
|
||||
| `SzResourceUsageCard` | `<sz-resource-usage-card>` | CPU/memory progress bars, network I/O, top memory consumers |
|
||||
| `SzTrafficCard` | `<sz-traffic-card>` | HTTP traffic metrics — requests, errors, response time, status distribution |
|
||||
| `SzQuickActionsCard` | `<sz-quick-actions-card>` | Configurable action button grid |
|
||||
|
||||
### Dashboard Grids
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzStatusGridCluster` | `<sz-status-grid-cluster>` | 4-column stat card grid — total/running/stopped services, Docker status |
|
||||
| `SzStatusGridServices` | `<sz-status-grid-services>` | Resource usage + platform services side by side |
|
||||
| `SzStatusGridNetwork` | `<sz-status-grid-network>` | Traffic, reverse proxy, and certificates in a responsive grid |
|
||||
| `SzStatusGridInfra` | `<sz-status-grid-infra>` | DNS/SSL status + quick actions |
|
||||
|
||||
### Services
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzServicesListView` | `<sz-services-list-view>` | Table of deployed services with status badges and action buttons |
|
||||
| `SzServiceDetailView` | `<sz-service-detail-view>` | Full service detail page — info, logs, live stats, actions, backups, and integrated workspace/IDE mode |
|
||||
| `SzServiceCreateView` | `<sz-service-create-view>` | Service deployment form — image, ports, env vars, volumes, resource limits |
|
||||
| `SzServicesBackupsView` | `<sz-services-backups-view>` | Backup schedule and backup history management |
|
||||
|
||||
### App Store
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzAppStoreView` | `<sz-app-store-view>` | App marketplace for deploying pre-configured templates (WordPress, Gitea, etc.) with category filtering |
|
||||
|
||||
### Platform Services
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzPlatformServicesCard` | `<sz-platform-services-card>` | Lists infrastructure services (MongoDB, MinIO, etc.) with status indicators |
|
||||
| `SzPlatformServiceDetailView` | `<sz-platform-service-detail-view>` | Detailed platform service view — connection info, config, metrics, logs |
|
||||
|
||||
### Network
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzNetworkProxyView` | `<sz-network-proxy-view>` | Reverse proxy management — traffic targets table and access log viewer |
|
||||
| `SzNetworkDnsView` | `<sz-network-dns-view>` | DNS record management with Cloudflare sync |
|
||||
| `SzNetworkDomainsView` | `<sz-network-domains-view>` | Domain list with certificate status and provider info |
|
||||
| `SzDomainDetailView` | `<sz-domain-detail-view>` | Domain detail — SSL certificate info, proxy routes, DNS records |
|
||||
| `SzReverseProxyCard` | `<sz-reverse-proxy-card>` | Compact proxy status card (HTTP/HTTPS ports, route count) |
|
||||
| `SzDnsSslCard` | `<sz-dns-ssl-card>` | Cloudflare DNS and ACME config status |
|
||||
| `SzCertificatesCard` | `<sz-certificates-card>` | Certificate status counts — valid, expiring, expired |
|
||||
|
||||
### Routes
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzRouteListView` | `<sz-route-list-view>` | Route configuration list with type filtering (HTTPS, email, DNS, etc.) |
|
||||
| `SzRouteCard` | `<sz-route-card>` | Single route card — match criteria, action type, TLS mode, targets, security profile |
|
||||
|
||||
### MTA / Email
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzMtaListView` | `<sz-mta-list-view>` | Email management — inbound/outbound messages with status badges and filtering |
|
||||
| `SzMtaDetailView` | `<sz-mta-detail-view>` | Email detail — SMTP transaction log, TLS info, SPF/DKIM/DMARC results, headers, body |
|
||||
|
||||
### Registries
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzRegistryAdvertisement` | `<sz-registry-advertisement>` | Onebox registry info card with docker quick-start commands |
|
||||
| `SzRegistryExternalView` | `<sz-registry-external-view>` | External registry management (Docker Hub, GHCR, GCR, ECR) |
|
||||
|
||||
### Auth & Settings
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzLoginView` | `<sz-login-view>` | Login page with serve.zone branding, credentials form, error display |
|
||||
| `SzTokensView` | `<sz-tokens-view>` | API token management — global and CI tokens with copy/regenerate/delete |
|
||||
| `SzSettingsView` | `<sz-settings-view>` | Full settings panel — appearance, Cloudflare, SSL/TLS, network, account |
|
||||
|
||||
### Configuration
|
||||
|
||||
| Component | Tag | Description |
|
||||
|-----------|-----|-------------|
|
||||
| `SzConfigOverview` | `<sz-config-overview>` | Top-level configuration overview with informational banner |
|
||||
| `SzConfigSection` | `<sz-config-section>` | Collapsible config section — icon, enabled/disabled badge, key-value fields, action buttons |
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Component Pattern
|
||||
|
||||
All components follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
import { DeesElement, customElement, html, css, cssManager, property } from '@design.estate/dees-element';
|
||||
|
||||
@customElement('sz-my-component')
|
||||
export class SzMyComponent extends DeesElement {
|
||||
// TC39 standard decorators with accessor keyword
|
||||
@property({ type: String })
|
||||
public accessor label: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
/* Light/dark theme support */
|
||||
:host { color: ${cssManager.bdTheme('#18181b', '#fafafa')}; }
|
||||
`,
|
||||
];
|
||||
|
||||
// Events via CustomEvent (bubbles + composed for shadow DOM)
|
||||
private handleClick() {
|
||||
this.dispatchEvent(new CustomEvent('action', {
|
||||
detail: { id: this.id },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
@serve.zone/catalog/
|
||||
├── html/ # WccTools dev server entry point
|
||||
│ ├── index.html # HTML shell
|
||||
│ └── index.ts # WccTools config (pages + elements sections)
|
||||
├── ts_web/
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── elements/ # All web components
|
||||
│ │ ├── index.ts # Element barrel export
|
||||
│ │ ├── sz-*.ts # Individual components
|
||||
│ │ └── sz-demo-view-*.ts # Demo orchestration wrappers
|
||||
│ └── pages/ # Page-level components
|
||||
│ ├── sz-demo-app-shell.ts # Full app shell (dees-appui)
|
||||
│ └── ...
|
||||
└── dist_ts_web/ # Compiled output (npm entry point)
|
||||
```
|
||||
|
||||
### TypeScript Interfaces
|
||||
|
||||
The library exports comprehensive TypeScript interfaces for all data structures:
|
||||
|
||||
```typescript
|
||||
// Dashboard
|
||||
import type { IDashboardData, IResourceUsage, ITrafficData, IClusterStats } from '@serve.zone/catalog';
|
||||
|
||||
// Services
|
||||
import type { IServiceDetail, IServiceStats, ILogEntry, IServiceBackup } from '@serve.zone/catalog';
|
||||
|
||||
// Network
|
||||
import type { IDomainDetail, ICertificateDetail, IDnsRecord, ITrafficTarget } from '@serve.zone/catalog';
|
||||
|
||||
// Routes
|
||||
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteSecurity } from '@serve.zone/catalog';
|
||||
|
||||
// MTA / Email
|
||||
import type { IEmail, IEmailDetail, ISmtpLogEntry, IConnectionInfo, IAuthenticationResults } from '@serve.zone/catalog';
|
||||
|
||||
// Configuration
|
||||
import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog';
|
||||
|
||||
// Settings & Auth
|
||||
import type { ISettings, IToken, IExternalRegistry } from '@serve.zone/catalog';
|
||||
|
||||
// App Store
|
||||
import type { IAppTemplate } from '@serve.zone/catalog';
|
||||
```
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start dev server (wcctools dashboard with live reload)
|
||||
pnpm run watch
|
||||
|
||||
# Production build
|
||||
pnpm run build
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
The **wcctools dev server** provides an interactive dashboard where every component is rendered with demo data. Components are organized by group (Dashboard, Services, Network, etc.) in the sidebar. Demo view wrappers (`sz-demo-view-*`) are filtered out from the element list — they serve as full-page orchestration demos accessible through the Pages section.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/catalog',
|
||||
version: '2.0.0',
|
||||
version: '2.12.2',
|
||||
description: 'UI component catalog for serve.zone'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Dashboard Cards
|
||||
export * from './sz-stat-card.js';
|
||||
export * from './sz-resource-usage-card.js';
|
||||
export * from './sz-traffic-card.js';
|
||||
export * from './sz-platform-services-card.js';
|
||||
@@ -28,6 +27,7 @@ export * from './sz-registry-external-view.js';
|
||||
export * from './sz-services-list-view.js';
|
||||
export * from './sz-services-backups-view.js';
|
||||
export * from './sz-service-detail-view.js';
|
||||
export * from './sz-app-store-view.js';
|
||||
|
||||
// Tokens View
|
||||
export * from './sz-tokens-view.js';
|
||||
@@ -45,6 +45,18 @@ export * from './sz-service-create-view.js';
|
||||
export * from './sz-platform-service-detail-view.js';
|
||||
export * from './sz-domain-detail-view.js';
|
||||
|
||||
// MTA Email Views
|
||||
export * from './sz-mta-list-view.js';
|
||||
export * from './sz-mta-detail-view.js';
|
||||
|
||||
// Route Configuration Views
|
||||
export * from './sz-route-card.js';
|
||||
export * from './sz-route-list-view.js';
|
||||
|
||||
// Config Views
|
||||
export * from './sz-config-section.js';
|
||||
export * from './sz-config-overview.js';
|
||||
|
||||
// Demo Views
|
||||
export * from './sz-demo-view-dashboard.js';
|
||||
export * from './sz-demo-view-services.js';
|
||||
@@ -52,3 +64,6 @@ export * from './sz-demo-view-network.js';
|
||||
export * from './sz-demo-view-registries.js';
|
||||
export * from './sz-demo-view-tokens.js';
|
||||
export * from './sz-demo-view-settings.js';
|
||||
export * from './sz-demo-view-mta.js';
|
||||
export * from './sz-demo-view-routes.js';
|
||||
export * from './sz-demo-view-config.js';
|
||||
|
||||
611
ts_web/elements/sz-app-store-view.ts
Normal file
611
ts_web/elements/sz-app-store-view.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-app-store-view': SzAppStoreView;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAppTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
iconUrl?: string;
|
||||
iconName?: string;
|
||||
image: string;
|
||||
port: number;
|
||||
envVars?: Array<{ key: string; value: string; description: string; required?: boolean }>;
|
||||
volumes?: string[];
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
enableClickHouse?: boolean;
|
||||
}
|
||||
|
||||
@customElement('sz-app-store-view')
|
||||
export class SzAppStoreView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px;">
|
||||
<sz-app-store-view
|
||||
.apps=${[
|
||||
{
|
||||
id: 'wordpress',
|
||||
name: 'WordPress',
|
||||
description: 'The world\'s most popular open-source content management system for building websites and blogs.',
|
||||
category: 'CMS',
|
||||
iconName: 'file-text',
|
||||
image: 'wordpress:latest',
|
||||
port: 80,
|
||||
envVars: [
|
||||
{ key: 'WORDPRESS_DB_HOST', value: '', description: 'Database host address', required: true },
|
||||
{ key: 'WORDPRESS_DB_USER', value: 'wordpress', description: 'Database username', required: true },
|
||||
{ key: 'WORDPRESS_DB_PASSWORD', value: '', description: 'Database password', required: true },
|
||||
{ key: 'WORDPRESS_DB_NAME', value: 'wordpress', description: 'Database name', required: true },
|
||||
],
|
||||
volumes: ['/var/www/html'],
|
||||
enableMongoDB: false,
|
||||
enableS3: false,
|
||||
},
|
||||
{
|
||||
id: 'gitea',
|
||||
name: 'Gitea',
|
||||
description: 'A lightweight, self-hosted Git service. Painless setup for your own code hosting platform.',
|
||||
category: 'Development',
|
||||
iconName: 'git-branch',
|
||||
image: 'gitea/gitea:latest',
|
||||
port: 3000,
|
||||
envVars: [
|
||||
{ key: 'GITEA__database__DB_TYPE', value: 'sqlite3', description: 'Database type', required: true },
|
||||
{ key: 'GITEA__server__ROOT_URL', value: '', description: 'Public URL of the Gitea instance', required: false },
|
||||
],
|
||||
volumes: ['/data'],
|
||||
},
|
||||
{
|
||||
id: 'ghost',
|
||||
name: 'Ghost',
|
||||
description: 'A powerful open-source publishing platform for professional bloggers and content creators.',
|
||||
category: 'CMS',
|
||||
iconName: 'book-open',
|
||||
image: 'ghost:latest',
|
||||
port: 2368,
|
||||
envVars: [
|
||||
{ key: 'url', value: '', description: 'Public URL for the Ghost site', required: true },
|
||||
{ key: 'database__client', value: 'sqlite3', description: 'Database client type', required: false },
|
||||
],
|
||||
volumes: ['/var/lib/ghost/content'],
|
||||
},
|
||||
{
|
||||
id: 'nginx',
|
||||
name: 'Nginx',
|
||||
description: 'High-performance HTTP server and reverse proxy with low resource consumption.',
|
||||
category: 'Web Server',
|
||||
iconName: 'globe',
|
||||
image: 'nginx:alpine',
|
||||
port: 80,
|
||||
volumes: ['/usr/share/nginx/html', '/etc/nginx/conf.d'],
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
description: 'In-memory data store used as a database, cache, streaming engine, and message broker.',
|
||||
category: 'Database',
|
||||
iconName: 'database',
|
||||
image: 'redis:alpine',
|
||||
port: 6379,
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
description: 'Advanced open-source relational database with strong reliability and feature set.',
|
||||
category: 'Database',
|
||||
iconName: 'database',
|
||||
image: 'postgres:16-alpine',
|
||||
port: 5432,
|
||||
envVars: [
|
||||
{ key: 'POSTGRES_USER', value: 'postgres', description: 'Superuser username', required: true },
|
||||
{ key: 'POSTGRES_PASSWORD', value: '', description: 'Superuser password', required: true },
|
||||
{ key: 'POSTGRES_DB', value: 'postgres', description: 'Default database name', required: false },
|
||||
],
|
||||
volumes: ['/var/lib/postgresql/data'],
|
||||
},
|
||||
]}
|
||||
></sz-app-store-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Services'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor apps: IAppTemplate[] = [];
|
||||
|
||||
@state()
|
||||
private accessor selectedCategory: string = 'All';
|
||||
|
||||
@state()
|
||||
private accessor searchQuery: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.category-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 9999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-pill:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.category-pill.active {
|
||||
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||
border-color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
padding: 8px 12px 8px 36px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
outline: none;
|
||||
transition: border-color 200ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: all 200ms ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0,0,0,0.05)', 'rgba(0,0,0,0.2)')};
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.app-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.app-icon .letter-fallback {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
background: ${cssManager.bdTheme('#eff6ff', 'rgba(59, 130, 246, 0.15)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#f4f4f5', '#1a1a1e')};
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.image-tag {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.deploy-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
border: none;
|
||||
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deploy-button:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.deploy-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.details-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.details-button:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private get categories(): string[] {
|
||||
const cats = new Set(this.apps.map((app) => app.category));
|
||||
return ['All', ...Array.from(cats).sort()];
|
||||
}
|
||||
|
||||
private get filteredApps(): IAppTemplate[] {
|
||||
let result = this.apps;
|
||||
|
||||
if (this.selectedCategory !== 'All') {
|
||||
result = result.filter((app) => app.category === this.selectedCategory);
|
||||
}
|
||||
|
||||
if (this.searchQuery.trim()) {
|
||||
const query = this.searchQuery.trim().toLowerCase();
|
||||
result = result.filter(
|
||||
(app) =>
|
||||
app.name.toLowerCase().includes(query) ||
|
||||
app.description.toLowerCase().includes(query) ||
|
||||
app.category.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const filtered = this.filteredApps;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<div class="header-title">App Store</div>
|
||||
<div class="header-subtitle">Deploy popular applications with one click</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="category-pills">
|
||||
${this.categories.map(
|
||||
(cat) => html`
|
||||
<button
|
||||
class="category-pill ${this.selectedCategory === cat ? 'active' : ''}"
|
||||
@click=${() => { this.selectedCategory = cat; }}
|
||||
>
|
||||
${cat}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div class="search-wrapper">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search apps..."
|
||||
.value=${this.searchQuery}
|
||||
@input=${(e: Event) => { this.searchQuery = (e.target as HTMLInputElement).value; }}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-grid">
|
||||
${filtered.length > 0
|
||||
? filtered.map((app) => this.renderAppCard(app))
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>
|
||||
<div class="empty-title">No apps found</div>
|
||||
<div class="empty-description">
|
||||
Try adjusting your search or filter to find what you're looking for.
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAppCard(app: IAppTemplate): TemplateResult {
|
||||
return html`
|
||||
<div class="app-card">
|
||||
<div class="card-top">
|
||||
<div class="app-icon">
|
||||
${app.iconUrl
|
||||
? html`<img src="${app.iconUrl}" alt="${app.name}">`
|
||||
: app.iconName
|
||||
? this.renderIconByName(app.iconName)
|
||||
: html`<span class="letter-fallback">${app.name.charAt(0).toUpperCase()}</span>`}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="app-name">${app.name}</div>
|
||||
<div class="category-badge">${app.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-description">${app.description}</div>
|
||||
<div class="card-footer">
|
||||
<span class="image-tag" title="${app.image}">${app.image}</span>
|
||||
<div class="card-actions">
|
||||
<button class="details-button" @click=${() => this.handleViewDetails(app)}>
|
||||
View Details
|
||||
</button>
|
||||
<button class="deploy-button" @click=${() => this.handleDeploy(app)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 12 20 22 4 22 4 12"></polyline>
|
||||
<rect x="2" y="7" width="20" height="5"></rect>
|
||||
<line x1="12" y1="22" x2="12" y2="7"></line>
|
||||
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
|
||||
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
|
||||
</svg>
|
||||
Deploy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIconByName(name: string): TemplateResult {
|
||||
const icons: Record<string, TemplateResult> = {
|
||||
'file-text': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`,
|
||||
'git-branch': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>`,
|
||||
'book-open': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path></svg>`,
|
||||
'globe': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
|
||||
'database': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg>`,
|
||||
'server': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`,
|
||||
'package': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"></line><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`,
|
||||
'mail': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
|
||||
'shield': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>`,
|
||||
'monitor': html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>`,
|
||||
};
|
||||
|
||||
return icons[name] || html`<span class="letter-fallback">?</span>`;
|
||||
}
|
||||
|
||||
private handleViewDetails(app: IAppTemplate) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('view-app', {
|
||||
detail: { app },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleDeploy(app: IAppTemplate) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('deploy-app', {
|
||||
detail: { app },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
92
ts_web/elements/sz-config-overview.ts
Normal file
92
ts_web/elements/sz-config-overview.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-config-overview': SzConfigOverview;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sz-config-overview')
|
||||
export class SzConfigOverview extends DeesElement {
|
||||
public static demo = () => html`<sz-config-overview
|
||||
heading="Configuration"
|
||||
infoText="This is a read-only view of the current running configuration."
|
||||
>
|
||||
<div style="padding: 20px; text-align: center; color: #71717a; font-size: 14px;">
|
||||
Place <sz-config-section> elements here
|
||||
</div>
|
||||
</sz-config-overview>`;
|
||||
|
||||
public static demoGroups = ['Configuration'];
|
||||
|
||||
@property({ type: String })
|
||||
public accessor heading: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor infoText: string = '';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('#eff6ff', 'rgba(59,130,246,0.08)')};
|
||||
border: 1px solid ${cssManager.bdTheme('#bfdbfe', 'rgba(59,130,246,0.2)')};
|
||||
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-banner dees-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
::slotted(sz-config-section) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
::slotted(sz-config-section:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${this.heading ? html`<div class="heading">${this.heading}</div>` : ''}
|
||||
${this.infoText ? html`
|
||||
<div class="info-banner">
|
||||
<dees-icon .icon=${'lucide:info'}></dees-icon>
|
||||
<span>${this.infoText}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<slot></slot>
|
||||
`;
|
||||
}
|
||||
}
|
||||
681
ts_web/elements/sz-config-section.ts
Normal file
681
ts_web/elements/sz-config-section.ts
Normal file
@@ -0,0 +1,681 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
export interface IConfigField {
|
||||
key: string;
|
||||
value: string | number | boolean | string[] | null;
|
||||
type?: 'text' | 'boolean' | 'badge' | 'pills' | 'code' | 'link';
|
||||
description?: string;
|
||||
linkTo?: string;
|
||||
}
|
||||
|
||||
export interface IConfigSectionAction {
|
||||
label: string;
|
||||
icon?: string;
|
||||
event?: string;
|
||||
detail?: any;
|
||||
}
|
||||
|
||||
export interface IConfigSectionLink {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-config-section': SzConfigSection;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sz-config-section')
|
||||
export class SzConfigSection extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<sz-config-section
|
||||
title="SmartProxy"
|
||||
subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
|
||||
icon="lucide:network"
|
||||
status="enabled"
|
||||
.fields=${[
|
||||
{ key: 'Route Count', value: 12 },
|
||||
{ key: 'ACME Enabled', value: true, type: 'boolean' },
|
||||
{ key: 'Account Email', value: 'admin@example.com' },
|
||||
{ key: 'Use Production', value: true, type: 'boolean' },
|
||||
{ key: 'Auto Renew', value: true, type: 'boolean' },
|
||||
{ key: 'Renew Threshold', value: '30 days' },
|
||||
] as IConfigField[]}
|
||||
.links=${[
|
||||
{ label: 'Docs', href: 'https://code.foss.global/serve.zone/smartproxy', icon: 'lucide:bookOpen', external: true },
|
||||
] as IConfigSectionLink[]}
|
||||
.actions=${[
|
||||
{ label: 'Configure', icon: 'lucide:settings', event: 'configure' },
|
||||
] as IConfigSectionAction[]}
|
||||
></sz-config-section>
|
||||
<sz-config-section
|
||||
title="Email Server"
|
||||
subtitle="SMTP email handling with smartmta"
|
||||
icon="lucide:mail"
|
||||
status="disabled"
|
||||
.fields=${[
|
||||
{ key: 'Ports', value: ['25', '465', '587'], type: 'pills' },
|
||||
{ key: 'Hostname', value: null },
|
||||
{ key: 'Domains', value: ['example.com', 'mail.example.com'], type: 'pills' },
|
||||
] as IConfigField[]}
|
||||
.links=${[
|
||||
{ label: 'Docs', href: 'https://code.foss.global/serve.zone/smartmta', icon: 'lucide:bookOpen', external: true },
|
||||
{ label: 'Source', href: 'https://code.foss.global/serve.zone/smartmta', icon: 'lucide:github', external: true },
|
||||
] as IConfigSectionLink[]}
|
||||
.actions=${[
|
||||
{ label: 'Enable', icon: 'lucide:power', event: 'enable' },
|
||||
] as IConfigSectionAction[]}
|
||||
></sz-config-section>
|
||||
<sz-config-section
|
||||
title="DNS Server"
|
||||
subtitle="Authoritative DNS with smartdns"
|
||||
icon="lucide:globe"
|
||||
status="not-configured"
|
||||
collapsible
|
||||
.fields=${[
|
||||
{ key: 'Port', value: 53 },
|
||||
{ key: 'NS Domains', value: ['ns1.example.com', 'ns2.example.com'], type: 'pills' },
|
||||
] as IConfigField[]}
|
||||
.links=${[
|
||||
{ label: 'Getting Started', href: 'https://docs.example.com/dns', icon: 'lucide:bookOpen', external: true },
|
||||
] as IConfigSectionLink[]}
|
||||
></sz-config-section>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Configuration'];
|
||||
|
||||
@property({ type: String })
|
||||
public accessor title: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor subtitle: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor icon: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor status: 'enabled' | 'disabled' | 'not-configured' | 'warning' = 'enabled';
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor fields: IConfigField[] = [];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor actions: IConfigSectionAction[] = [];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor links: IConfigSectionLink[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor collapsible: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor collapsed: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor isCollapsed: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor hasSlottedContent: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
dees-tile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host([collapsed]) dees-tile::part(content) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host([collapsed]) dees-tile::part(footer) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:host([collapsible]) .section-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:host([collapsible]) .section-header:hover {
|
||||
background: var(--dees-color-hover);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--dees-color-border-default);
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-icon dees-icon {
|
||||
font-size: 14px;
|
||||
color: var(--dees-color-text-muted);
|
||||
}
|
||||
|
||||
.header-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--dees-color-text-muted);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34,197,94,0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.2)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.status-badge.not-configured {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', 'rgba(113,113,122,0.2)')};
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: ${cssManager.bdTheme('#fef3c7', 'rgba(245,158,11,0.15)')};
|
||||
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge.enabled .status-dot {
|
||||
background: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.status-badge.disabled .status-dot {
|
||||
background: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.status-badge.not-configured .status-dot {
|
||||
background: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
}
|
||||
|
||||
.status-badge.warning .status-dot {
|
||||
background: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
||||
}
|
||||
|
||||
/* Footer action buttons — canonical dees-modal / dees-tile pattern */
|
||||
.section-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.tile-button dees-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Chevron */
|
||||
.chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.chevron dees-icon {
|
||||
font-size: 14px;
|
||||
color: var(--dees-color-text-muted);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.section-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fields-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Field rows */
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#1a1a1e')};
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fields-list .field-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.field-key {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-value.null-value {
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
font-style: italic;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Boolean display */
|
||||
.bool-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bool-value.true {
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.bool-value.false {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.bool-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bool-value.true .bool-dot {
|
||||
background: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.bool-value.false .bool-dot {
|
||||
background: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
/* Pills */
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
background: ${cssManager.bdTheme('#eff6ff', 'rgba(59,130,246,0.1)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
/* Code value */
|
||||
.code-value {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
/* Link value */
|
||||
.link-value {
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.link-value:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Description hint */
|
||||
.field-description {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
margin-top: 3px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Slot for custom content */
|
||||
.slot-content {
|
||||
border-top: 1px solid ${cssManager.bdTheme('#f4f4f5', '#1a1a1e')};
|
||||
}
|
||||
|
||||
.slot-content.empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Badge type */
|
||||
.badge-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#52525b', '#a1a1aa')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
this.isCollapsed = this.collapsed;
|
||||
if (this.collapsible) {
|
||||
this.setAttribute('collapsible', '');
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('isCollapsed')) {
|
||||
this.toggleAttribute('collapsed', this.isCollapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private onSlotChange(e: Event) {
|
||||
const slot = e.target as HTMLSlotElement;
|
||||
this.hasSlottedContent = slot.assignedNodes({ flatten: true }).length > 0;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const statusLabels: Record<string, string> = {
|
||||
'enabled': 'Enabled',
|
||||
'disabled': 'Disabled',
|
||||
'not-configured': 'Not Configured',
|
||||
'warning': 'Warning',
|
||||
};
|
||||
|
||||
return html`
|
||||
<dees-tile>
|
||||
<div
|
||||
slot="header"
|
||||
class="section-header"
|
||||
@click=${() => {
|
||||
if (this.collapsible) {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="header-left">
|
||||
${this.icon ? html`
|
||||
<div class="header-icon">
|
||||
<dees-icon .icon=${this.icon}></dees-icon>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="header-text">
|
||||
<div class="header-title">${this.title}</div>
|
||||
${this.subtitle ? html`<div class="header-subtitle">${this.subtitle}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
${this.status ? html`
|
||||
<span class="status-badge ${this.status}">
|
||||
<span class="status-dot"></span>
|
||||
${statusLabels[this.status] || this.status}
|
||||
</span>
|
||||
` : ''}
|
||||
${this.collapsible ? html`
|
||||
<span class="chevron ${this.isCollapsed ? 'collapsed' : ''}">
|
||||
<dees-icon .icon=${'lucide:chevronDown'}></dees-icon>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
${this.fields.length > 0 ? html`
|
||||
<div class="fields-list">
|
||||
${this.fields.map(field => this.renderField(field))}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="slot-content ${!this.hasSlottedContent ? 'empty' : ''}">
|
||||
<slot @slotchange=${this.onSlotChange}></slot>
|
||||
</div>
|
||||
</div>
|
||||
${this.links.length > 0 || this.actions.length > 0 ? html`
|
||||
<div slot="footer" class="section-footer">
|
||||
${this.links.map(link => html`
|
||||
<a
|
||||
class="tile-button"
|
||||
href=${link.href}
|
||||
target=${link.external ? '_blank' : '_self'}
|
||||
rel=${link.external ? 'noopener noreferrer' : ''}
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
>
|
||||
${link.icon ? html`<dees-icon .icon=${link.icon}></dees-icon>` : ''}
|
||||
${link.label}
|
||||
${link.external ? html`<dees-icon .icon=${'lucide:externalLink'}></dees-icon>` : ''}
|
||||
</a>
|
||||
`)}
|
||||
${this.actions.map(action => html`
|
||||
<button class="tile-button primary" @click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent(action.event || 'action', {
|
||||
detail: action.detail || { label: action.label },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}}>
|
||||
${action.icon ? html`<dees-icon .icon=${action.icon}></dees-icon>` : ''}
|
||||
${action.label}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
` : ''}
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderField(field: IConfigField): TemplateResult {
|
||||
return html`
|
||||
<div class="field-row">
|
||||
<div class="field-key">${field.key}</div>
|
||||
<div>
|
||||
${this.renderFieldValue(field)}
|
||||
${field.description ? html`<div class="field-description">${field.description}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFieldValue(field: IConfigField): TemplateResult {
|
||||
const value = field.value;
|
||||
const type = field.type || this.inferType(value);
|
||||
|
||||
// Null / undefined
|
||||
if (value === null || value === undefined) {
|
||||
return html`<span class="field-value null-value">Not configured</span>`;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return html`
|
||||
<span class="bool-value ${value ? 'true' : 'false'}">
|
||||
<span class="bool-dot"></span>
|
||||
${value ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
`;
|
||||
|
||||
case 'pills':
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return html`<span class="field-value null-value">None</span>`;
|
||||
}
|
||||
return html`
|
||||
<div class="pills">
|
||||
${(value as string[]).map(v => html`<span class="pill">${v}</span>`)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'code':
|
||||
return html`<span class="code-value">${String(value)}</span>`;
|
||||
|
||||
case 'badge':
|
||||
return html`<span class="badge-value">${String(value)}</span>`;
|
||||
|
||||
case 'link':
|
||||
return html`
|
||||
<span
|
||||
class="link-value"
|
||||
@click=${() => {
|
||||
if (field.linkTo) {
|
||||
this.dispatchEvent(new CustomEvent('navigate', {
|
||||
detail: { target: field.linkTo },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
>${String(value)}</span>
|
||||
`;
|
||||
|
||||
default:
|
||||
return html`<span class="field-value">${String(value)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
private inferType(value: unknown): string {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (Array.isArray(value)) return 'pills';
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
165
ts_web/elements/sz-demo-view-config.ts
Normal file
165
ts_web/elements/sz-demo-view-config.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { IConfigField, IConfigSectionAction } from './sz-config-section.js';
|
||||
import './index.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-demo-view-config': SzDemoViewConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sz-demo-view-config')
|
||||
export class SzDemoViewConfig extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const systemFields: IConfigField[] = [
|
||||
{ key: 'Base Directory', value: '/home/user/.serve.zone/dcrouter' },
|
||||
{ key: 'Data Directory', value: '/home/user/.serve.zone/dcrouter/data' },
|
||||
{ key: 'Public IP', value: '203.0.113.50' },
|
||||
{ key: 'Proxy IPs', value: ['203.0.113.10', '203.0.113.11'], type: 'pills' },
|
||||
{ key: 'Uptime', value: '3d 14h 22m' },
|
||||
{ key: 'Storage Backend', value: 'filesystem', type: 'badge' },
|
||||
];
|
||||
|
||||
const proxyFields: IConfigField[] = [
|
||||
{ key: 'Route Count', value: 12 },
|
||||
{ key: 'ACME Enabled', value: true, type: 'boolean' },
|
||||
{ key: 'Account Email', value: 'admin@serve.zone' },
|
||||
{ key: 'Use Production', value: true, type: 'boolean' },
|
||||
{ key: 'Auto Renew', value: true, type: 'boolean' },
|
||||
{ key: 'Renew Threshold', value: '30 days' },
|
||||
];
|
||||
|
||||
const emailFields: IConfigField[] = [
|
||||
{ key: 'Ports', value: ['25', '465', '587'], type: 'pills' },
|
||||
{ key: 'Hostname', value: 'mail.serve.zone' },
|
||||
{ key: 'Domains', value: ['serve.zone', 'mail.serve.zone'], type: 'pills' },
|
||||
{ key: 'Email Routes', value: 5 },
|
||||
{ key: 'Received Path', value: '/data/emails' },
|
||||
];
|
||||
|
||||
const dnsFields: IConfigField[] = [
|
||||
{ key: 'Port', value: 53 },
|
||||
{ key: 'NS Domains', value: ['ns1.serve.zone', 'ns2.serve.zone'], type: 'pills' },
|
||||
{ key: 'Scopes', value: ['serve.zone', 'example.com'], type: 'pills' },
|
||||
{ key: 'Record Count', value: 24 },
|
||||
{ key: 'DNS Challenge', value: true, type: 'boolean' },
|
||||
];
|
||||
|
||||
const tlsFields: IConfigField[] = [
|
||||
{ key: 'Contact Email', value: 'admin@serve.zone' },
|
||||
{ key: 'Domain', value: 'serve.zone' },
|
||||
{ key: 'Source', value: 'acme', type: 'badge' },
|
||||
{ key: 'Certificate Path', value: null },
|
||||
{ key: 'Key Path', value: null },
|
||||
];
|
||||
|
||||
const cacheFields: IConfigField[] = [
|
||||
{ key: 'Storage Path', value: '/home/user/.serve.zone/dcrouter/tsmdb' },
|
||||
{ key: 'DB Name', value: 'dcrouter' },
|
||||
{ key: 'Default TTL', value: '30 days' },
|
||||
{ key: 'Cleanup Interval', value: '1 hour' },
|
||||
];
|
||||
|
||||
const radiusFields: IConfigField[] = [
|
||||
{ key: 'Auth Port', value: null },
|
||||
{ key: 'Accounting Port', value: null },
|
||||
];
|
||||
|
||||
const remoteIngressFields: IConfigField[] = [
|
||||
{ key: 'Tunnel Port', value: 8443 },
|
||||
{ key: 'Hub Domain', value: 'hub.serve.zone' },
|
||||
{ key: 'TLS Configured', value: true, type: 'boolean' },
|
||||
];
|
||||
|
||||
return html`
|
||||
<sz-config-overview
|
||||
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
|
||||
>
|
||||
<sz-config-section
|
||||
title="System"
|
||||
subtitle="Base paths and infrastructure"
|
||||
icon="lucide:server"
|
||||
status="enabled"
|
||||
.fields=${systemFields}
|
||||
></sz-config-section>
|
||||
|
||||
<sz-config-section
|
||||
title="SmartProxy"
|
||||
subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
|
||||
icon="lucide:network"
|
||||
status="enabled"
|
||||
.fields=${proxyFields}
|
||||
.actions=${[{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } }] as IConfigSectionAction[]}
|
||||
></sz-config-section>
|
||||
|
||||
<sz-config-section
|
||||
title="Email Server"
|
||||
subtitle="SMTP email handling with smartmta"
|
||||
icon="lucide:mail"
|
||||
status="enabled"
|
||||
.fields=${emailFields}
|
||||
></sz-config-section>
|
||||
|
||||
<sz-config-section
|
||||
title="DNS Server"
|
||||
subtitle="Authoritative DNS with smartdns"
|
||||
icon="lucide:globe"
|
||||
status="enabled"
|
||||
.fields=${dnsFields}
|
||||
></sz-config-section>
|
||||
|
||||
<sz-config-section
|
||||
title="TLS / Certificates"
|
||||
subtitle="Certificate management and ACME"
|
||||
icon="lucide:shield-check"
|
||||
status="enabled"
|
||||
.fields=${tlsFields}
|
||||
></sz-config-section>
|
||||
|
||||
<sz-config-section
|
||||
title="Cache Database"
|
||||
subtitle="Persistent caching with smartdata"
|
||||
icon="lucide:database"
|
||||
status="enabled"
|
||||
.fields=${cacheFields}
|
||||
></sz-config-section>
|
||||
|
||||
<sz-config-section
|
||||
title="RADIUS Server"
|
||||
subtitle="Network authentication and VLAN assignment"
|
||||
icon="lucide:wifi"
|
||||
status="not-configured"
|
||||
.fields=${radiusFields}
|
||||
></sz-config-section>
|
||||
|
||||
<sz-config-section
|
||||
title="Remote Ingress"
|
||||
subtitle="Edge tunnel nodes"
|
||||
icon="lucide:cloud"
|
||||
status="enabled"
|
||||
.fields=${remoteIngressFields}
|
||||
></sz-config-section>
|
||||
</sz-config-overview>
|
||||
`;
|
||||
}
|
||||
}
|
||||
335
ts_web/elements/sz-demo-view-mta.ts
Normal file
335
ts_web/elements/sz-demo-view-mta.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { DeesAppui } from '@design.estate/dees-catalog';
|
||||
import type { IEmail, TEmailDirection } from './sz-mta-list-view.js';
|
||||
import type { IEmailDetail } from './sz-mta-detail-view.js';
|
||||
import './index.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-demo-view-mta': SzDemoViewMta;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sz-demo-view-mta')
|
||||
export class SzDemoViewMta extends DeesElement {
|
||||
private appui: DeesAppui | null = null;
|
||||
|
||||
@state()
|
||||
private accessor currentView: 'list' | 'detail' = 'list';
|
||||
|
||||
@state()
|
||||
private accessor selectedEmail: IEmailDetail | null = null;
|
||||
|
||||
@state()
|
||||
private accessor currentDirectionFilter: TEmailDirection | 'all' = 'all';
|
||||
|
||||
private demoEmails: IEmail[] = [
|
||||
{ id: '1', direction: 'outbound', status: 'delivered', from: 'noreply@serve.zone', to: 'user@example.com', subject: 'Welcome to serve.zone', timestamp: '2024-01-15 14:30:22', messageId: '<abc123@serve.zone>', size: '12.4 KB' },
|
||||
{ id: '2', direction: 'outbound', status: 'bounced', from: 'alerts@serve.zone', to: 'invalid@nowhere.test', subject: 'Service Alert: CPU Usage High', timestamp: '2024-01-15 14:28:10', messageId: '<def456@serve.zone>', size: '8.2 KB' },
|
||||
{ id: '3', direction: 'inbound', status: 'delivered', from: 'support@customer.com', to: 'admin@serve.zone', subject: 'Re: Infrastructure Review', timestamp: '2024-01-15 14:25:00', messageId: '<ghi789@customer.com>', size: '24.1 KB' },
|
||||
{ id: '4', direction: 'outbound', status: 'rejected', from: 'billing@serve.zone', to: 'blocked@spam-domain.test', subject: 'Invoice #2024-001', timestamp: '2024-01-15 14:20:45', messageId: '<jkl012@serve.zone>', size: '45.6 KB' },
|
||||
{ id: '5', direction: 'outbound', status: 'deferred', from: 'noreply@serve.zone', to: 'slow@remote-server.test', subject: 'Password Reset Request', timestamp: '2024-01-15 14:15:30', messageId: '<mno345@serve.zone>', size: '6.8 KB' },
|
||||
{ id: '6', direction: 'inbound', status: 'delivered', from: 'ci@github.com', to: 'devops@serve.zone', subject: 'Build #4521 passed', timestamp: '2024-01-15 14:10:00', messageId: '<pqr678@github.com>', size: '15.3 KB' },
|
||||
{ id: '7', direction: 'outbound', status: 'pending', from: 'reports@serve.zone', to: 'team@serve.zone', subject: 'Weekly Infrastructure Report', timestamp: '2024-01-15 14:05:00', messageId: '<stu901@serve.zone>', size: '102.7 KB' },
|
||||
{ id: '8', direction: 'inbound', status: 'delivered', from: 'monitoring@uptime.io', to: 'ops@serve.zone', subject: 'Uptime Report: 99.98%', timestamp: '2024-01-15 13:55:00', messageId: '<vwx234@uptime.io>', size: '9.1 KB' },
|
||||
];
|
||||
|
||||
private demoEmailDetails: Record<string, IEmailDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
direction: 'outbound',
|
||||
status: 'delivered',
|
||||
from: 'noreply@serve.zone',
|
||||
to: 'user@example.com',
|
||||
toList: ['user@example.com'],
|
||||
subject: 'Welcome to serve.zone',
|
||||
timestamp: '2024-01-15 14:30:22',
|
||||
messageId: '<abc123@serve.zone>',
|
||||
size: '12.4 KB',
|
||||
smtpLog: [
|
||||
{ timestamp: '14:30:20', direction: 'client', command: 'EHLO mail.serve.zone' },
|
||||
{ timestamp: '14:30:20', direction: 'server', command: '250-mail.example.com Hello', responseCode: 250 },
|
||||
{ timestamp: '14:30:20', direction: 'server', command: '250-STARTTLS', responseCode: 250 },
|
||||
{ timestamp: '14:30:20', direction: 'server', command: '250 SIZE 52428800', responseCode: 250 },
|
||||
{ timestamp: '14:30:20', direction: 'client', command: 'STARTTLS' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '220 Ready to start TLS', responseCode: 220 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'EHLO mail.serve.zone' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250-mail.example.com Hello', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250-AUTH PLAIN LOGIN', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250 SIZE 52428800', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '235 2.7.0 Authentication successful', responseCode: 235 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'MAIL FROM:<noreply@serve.zone>' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250 OK', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'RCPT TO:<user@example.com>' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250 Accepted', responseCode: 250 },
|
||||
{ timestamp: '14:30:22', direction: 'client', command: 'DATA' },
|
||||
{ timestamp: '14:30:22', direction: 'server', command: '354 Enter message, ending with "." on a line by itself', responseCode: 354 },
|
||||
{ timestamp: '14:30:22', direction: 'client', command: '.' },
|
||||
{ timestamp: '14:30:22', direction: 'server', command: '250 OK id=1pQ2rS-0003Ab-C4', responseCode: 250 },
|
||||
{ timestamp: '14:30:22', direction: 'client', command: 'QUIT' },
|
||||
{ timestamp: '14:30:22', direction: 'server', command: '221 mail.example.com closing connection', responseCode: 221 },
|
||||
],
|
||||
connectionInfo: {
|
||||
sourceIp: '10.0.1.50',
|
||||
sourceHostname: 'mail.serve.zone',
|
||||
destinationIp: '93.184.216.34',
|
||||
destinationPort: 25,
|
||||
tlsVersion: 'TLSv1.3',
|
||||
tlsCipher: 'TLS_AES_256_GCM_SHA384',
|
||||
authenticated: true,
|
||||
authMethod: 'PLAIN',
|
||||
authUser: 'noreply@serve.zone',
|
||||
},
|
||||
authenticationResults: {
|
||||
spf: 'pass',
|
||||
spfDomain: 'serve.zone',
|
||||
dkim: 'pass',
|
||||
dkimDomain: 'serve.zone',
|
||||
dmarc: 'pass',
|
||||
dmarcPolicy: 'reject',
|
||||
},
|
||||
headers: {
|
||||
'From': 'noreply@serve.zone',
|
||||
'To': 'user@example.com',
|
||||
'Subject': 'Welcome to serve.zone',
|
||||
'Date': 'Mon, 15 Jan 2024 14:30:22 +0000',
|
||||
'MIME-Version': '1.0',
|
||||
'Content-Type': 'text/html; charset=UTF-8',
|
||||
},
|
||||
body: '<html>\n<head><title>Welcome</title></head>\n<body>\n <h1>Welcome to serve.zone!</h1>\n <p>Your account has been created successfully.</p>\n <p>Get started by visiting your <a href="https://serve.zone/dashboard">dashboard</a>.</p>\n</body>\n</html>',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
direction: 'outbound',
|
||||
status: 'bounced',
|
||||
from: 'alerts@serve.zone',
|
||||
to: 'invalid@nowhere.test',
|
||||
toList: ['invalid@nowhere.test'],
|
||||
subject: 'Service Alert: CPU Usage High',
|
||||
timestamp: '2024-01-15 14:28:10',
|
||||
messageId: '<def456@serve.zone>',
|
||||
size: '8.2 KB',
|
||||
rejectionReason: '550 5.1.1 The email account that you tried to reach does not exist.',
|
||||
bounceMessage: 'Delivery to the following recipient failed permanently:\n\n invalid@nowhere.test\n\nTechnical details of permanent failure:\nGoogle tried to deliver your message, but it was rejected by the server for the recipient domain nowhere.test.',
|
||||
smtpLog: [
|
||||
{ timestamp: '14:28:08', direction: 'client', command: 'EHLO mail.serve.zone' },
|
||||
{ timestamp: '14:28:08', direction: 'server', command: '250-mail.nowhere.test Hello', responseCode: 250 },
|
||||
{ timestamp: '14:28:08', direction: 'client', command: 'MAIL FROM:<alerts@serve.zone>' },
|
||||
{ timestamp: '14:28:09', direction: 'server', command: '250 OK', responseCode: 250 },
|
||||
{ timestamp: '14:28:09', direction: 'client', command: 'RCPT TO:<invalid@nowhere.test>' },
|
||||
{ timestamp: '14:28:10', direction: 'server', command: '550 5.1.1 The email account that you tried to reach does not exist.', responseCode: 550 },
|
||||
{ timestamp: '14:28:10', direction: 'client', command: 'QUIT' },
|
||||
{ timestamp: '14:28:10', direction: 'server', command: '221 Bye', responseCode: 221 },
|
||||
],
|
||||
connectionInfo: {
|
||||
sourceIp: '10.0.1.50',
|
||||
sourceHostname: 'mail.serve.zone',
|
||||
destinationIp: '198.51.100.25',
|
||||
destinationPort: 25,
|
||||
tlsVersion: 'TLSv1.2',
|
||||
tlsCipher: 'ECDHE-RSA-AES128-GCM-SHA256',
|
||||
authenticated: true,
|
||||
authMethod: 'PLAIN',
|
||||
authUser: 'alerts@serve.zone',
|
||||
},
|
||||
authenticationResults: {
|
||||
spf: 'pass',
|
||||
spfDomain: 'serve.zone',
|
||||
dkim: 'pass',
|
||||
dkimDomain: 'serve.zone',
|
||||
dmarc: 'pass',
|
||||
dmarcPolicy: 'quarantine',
|
||||
},
|
||||
headers: {
|
||||
'From': 'alerts@serve.zone',
|
||||
'To': 'invalid@nowhere.test',
|
||||
'Subject': 'Service Alert: CPU Usage High',
|
||||
'Date': 'Mon, 15 Jan 2024 14:28:10 +0000',
|
||||
'MIME-Version': '1.0',
|
||||
'Content-Type': 'text/plain; charset=UTF-8',
|
||||
},
|
||||
body: 'ALERT: CPU Usage High\n\nService: api-gateway\nCurrent Usage: 94.2%\nThreshold: 90%\nTimestamp: 2024-01-15 14:28:00 UTC\n\nPlease investigate immediately.',
|
||||
},
|
||||
'3': {
|
||||
id: '3',
|
||||
direction: 'inbound',
|
||||
status: 'delivered',
|
||||
from: 'support@customer.com',
|
||||
to: 'admin@serve.zone',
|
||||
toList: ['admin@serve.zone'],
|
||||
cc: ['ops@serve.zone'],
|
||||
subject: 'Re: Infrastructure Review',
|
||||
timestamp: '2024-01-15 14:25:00',
|
||||
messageId: '<ghi789@customer.com>',
|
||||
size: '24.1 KB',
|
||||
smtpLog: [
|
||||
{ timestamp: '14:24:58', direction: 'client', command: 'EHLO mail.customer.com' },
|
||||
{ timestamp: '14:24:58', direction: 'server', command: '250-mail.serve.zone Hello', responseCode: 250 },
|
||||
{ timestamp: '14:24:58', direction: 'server', command: '250-STARTTLS', responseCode: 250 },
|
||||
{ timestamp: '14:24:58', direction: 'server', command: '250 SIZE 52428800', responseCode: 250 },
|
||||
{ timestamp: '14:24:59', direction: 'client', command: 'STARTTLS' },
|
||||
{ timestamp: '14:24:59', direction: 'server', command: '220 Ready to start TLS', responseCode: 220 },
|
||||
{ timestamp: '14:24:59', direction: 'client', command: 'MAIL FROM:<support@customer.com>' },
|
||||
{ timestamp: '14:24:59', direction: 'server', command: '250 OK', responseCode: 250 },
|
||||
{ timestamp: '14:24:59', direction: 'client', command: 'RCPT TO:<admin@serve.zone>' },
|
||||
{ timestamp: '14:25:00', direction: 'server', command: '250 Accepted', responseCode: 250 },
|
||||
{ timestamp: '14:25:00', direction: 'client', command: 'DATA' },
|
||||
{ timestamp: '14:25:00', direction: 'server', command: '354 Enter message', responseCode: 354 },
|
||||
{ timestamp: '14:25:00', direction: 'client', command: '.' },
|
||||
{ timestamp: '14:25:00', direction: 'server', command: '250 OK id=2bR3sT-0004Cd-E5', responseCode: 250 },
|
||||
],
|
||||
connectionInfo: {
|
||||
sourceIp: '203.0.113.45',
|
||||
sourceHostname: 'mail.customer.com',
|
||||
destinationIp: '10.0.1.50',
|
||||
destinationPort: 25,
|
||||
tlsVersion: 'TLSv1.3',
|
||||
tlsCipher: 'TLS_AES_128_GCM_SHA256',
|
||||
authenticated: false,
|
||||
authMethod: '',
|
||||
authUser: '',
|
||||
},
|
||||
authenticationResults: {
|
||||
spf: 'pass',
|
||||
spfDomain: 'customer.com',
|
||||
dkim: 'pass',
|
||||
dkimDomain: 'customer.com',
|
||||
dmarc: 'pass',
|
||||
dmarcPolicy: 'none',
|
||||
},
|
||||
headers: {
|
||||
'From': 'support@customer.com',
|
||||
'To': 'admin@serve.zone',
|
||||
'CC': 'ops@serve.zone',
|
||||
'Subject': 'Re: Infrastructure Review',
|
||||
'Date': 'Mon, 15 Jan 2024 14:25:00 +0000',
|
||||
'MIME-Version': '1.0',
|
||||
'Content-Type': 'text/plain; charset=UTF-8',
|
||||
'In-Reply-To': '<prev123@serve.zone>',
|
||||
},
|
||||
body: 'Hi Admin,\n\nThank you for the detailed infrastructure review report.\n\nWe have reviewed the recommendations and would like to\nproceed with the following:\n\n1. Upgrade to the Pro tier\n2. Enable automatic backups\n3. Set up monitoring alerts\n\nPlease let us know the timeline for these changes.\n\nBest regards,\nCustomer Support Team',
|
||||
},
|
||||
};
|
||||
|
||||
async onActivate(context: { appui: DeesAppui; viewId: string }) {
|
||||
this.appui = context.appui;
|
||||
|
||||
this.appui.setContentTabs([
|
||||
{
|
||||
key: 'All Emails',
|
||||
action: () => {
|
||||
this.currentDirectionFilter = 'all';
|
||||
this.currentView = 'list';
|
||||
this.updateSecondaryMenu();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Inbound',
|
||||
action: () => {
|
||||
this.currentDirectionFilter = 'inbound';
|
||||
this.currentView = 'list';
|
||||
this.updateSecondaryMenu();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Outbound',
|
||||
action: () => {
|
||||
this.currentDirectionFilter = 'outbound';
|
||||
this.currentView = 'list';
|
||||
this.updateSecondaryMenu();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
this.updateSecondaryMenu();
|
||||
}
|
||||
|
||||
private updateSecondaryMenu() {
|
||||
if (!this.appui) return;
|
||||
|
||||
this.appui.setSecondaryMenu({
|
||||
heading: 'Email / MTA',
|
||||
groups: [
|
||||
{
|
||||
name: 'Actions',
|
||||
items: [
|
||||
{ type: 'action', key: 'Refresh', iconName: 'lucide:RefreshCw', action: () => { console.log('Refresh emails'); } },
|
||||
{ type: 'action', key: 'Export Logs', iconName: 'lucide:Download', action: () => { console.log('Export logs'); } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Status Filters',
|
||||
items: [
|
||||
{ key: 'Delivered', iconName: 'lucide:CheckCircle', badge: '5', badgeVariant: 'success' as const, action: () => { console.log('Filter delivered'); } },
|
||||
{ key: 'Bounced', iconName: 'lucide:XCircle', badge: '1', badgeVariant: 'error' as const, action: () => { console.log('Filter bounced'); } },
|
||||
{ key: 'Deferred', iconName: 'lucide:Clock', badge: '1', badgeVariant: 'warning' as const, action: () => { console.log('Filter deferred'); } },
|
||||
{ key: 'Pending', iconName: 'lucide:Loader', badge: '1', action: () => { console.log('Filter pending'); } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Statistics',
|
||||
items: [
|
||||
{ type: 'header' as const, label: '8 Total Emails' },
|
||||
{ type: 'header' as const, label: '62.5% Delivery Rate' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
onDeactivate() {
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.currentView === 'detail' && this.selectedEmail) {
|
||||
return html`
|
||||
<sz-mta-detail-view
|
||||
.email=${this.selectedEmail}
|
||||
@back=${() => { this.currentView = 'list'; this.selectedEmail = null; }}
|
||||
></sz-mta-detail-view>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<sz-mta-list-view
|
||||
.emails=${this.currentDirectionFilter === 'all'
|
||||
? this.demoEmails
|
||||
: this.demoEmails.filter(e => e.direction === this.currentDirectionFilter)}
|
||||
@email-click=${(e: CustomEvent<IEmail>) => this.handleEmailClick(e.detail)}
|
||||
></sz-mta-list-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleEmailClick(email: IEmail) {
|
||||
const detail = this.demoEmailDetails[email.id];
|
||||
if (detail) {
|
||||
this.selectedEmail = detail;
|
||||
this.currentView = 'detail';
|
||||
} else {
|
||||
console.log('No detail data available for email:', email.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
362
ts_web/elements/sz-demo-view-routes.ts
Normal file
362
ts_web/elements/sz-demo-view-routes.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import type { DeesAppui } from '@design.estate/dees-catalog';
|
||||
import type { IRouteConfig } from './sz-route-card.js';
|
||||
import './index.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-demo-view-routes': SzDemoViewRoutes;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sz-demo-view-routes')
|
||||
export class SzDemoViewRoutes extends DeesElement {
|
||||
private appui: DeesAppui | null = null;
|
||||
|
||||
@state()
|
||||
private accessor currentTab: 'all' | 'https' | 'email' | 'dns' = 'all';
|
||||
|
||||
private demoRoutes: IRouteConfig[] = [
|
||||
// 1. HTTPS with TLS termination + auto cert
|
||||
{
|
||||
id: 'route-1',
|
||||
name: 'Web Frontend',
|
||||
description: 'Main website with TLS termination and automatic certificates',
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
tags: ['web', 'https', 'production'],
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: ['serve.zone', 'www.serve.zone'],
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.10', port: 3000 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
// 2. HTTP to HTTPS redirect
|
||||
{
|
||||
id: 'route-2',
|
||||
name: 'HTTP Redirect',
|
||||
description: 'Redirects all HTTP traffic to HTTPS',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
tags: ['web', 'http', 'redirect'],
|
||||
match: {
|
||||
ports: 80,
|
||||
domains: ['serve.zone', 'www.serve.zone'],
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
},
|
||||
},
|
||||
// 3. Email SMTP route
|
||||
{
|
||||
id: 'route-3',
|
||||
name: 'SMTP Inbound',
|
||||
description: 'Inbound email relay for serve.zone domain',
|
||||
enabled: true,
|
||||
tags: ['email', 'smtp', 'production'],
|
||||
match: {
|
||||
ports: 25,
|
||||
domains: 'mail.serve.zone',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.1.5', port: 25 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
// 4. API gateway with path matching, rate limiting, CORS
|
||||
{
|
||||
id: 'route-4',
|
||||
name: 'API Gateway',
|
||||
description: 'API gateway with rate limiting, CORS headers, and load balancing',
|
||||
enabled: true,
|
||||
priority: 20,
|
||||
tags: ['web', 'api', 'https', 'production'],
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: 'api.serve.zone',
|
||||
path: '/v2/*',
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{ host: ['10.0.0.20', '10.0.0.21', '10.0.0.22'], port: 8080 },
|
||||
],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
loadBalancing: { algorithm: 'round-robin' },
|
||||
},
|
||||
security: {
|
||||
rateLimit: { enabled: true, maxRequests: 200, window: 60 },
|
||||
maxConnections: 5000,
|
||||
},
|
||||
headers: {
|
||||
response: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Request-Id': '{{requestId}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
// 5. WebSocket route
|
||||
{
|
||||
id: 'route-5',
|
||||
name: 'WebSocket Realtime',
|
||||
description: 'Real-time WebSocket connections for live updates',
|
||||
enabled: true,
|
||||
tags: ['web', 'https', 'websocket'],
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: 'ws.serve.zone',
|
||||
path: '/ws/*',
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.30', port: 9090 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
websocket: { enabled: true },
|
||||
},
|
||||
},
|
||||
// 6. Wildcard domain route
|
||||
{
|
||||
id: 'route-6',
|
||||
name: 'Tenant Wildcard',
|
||||
description: 'Multi-tenant wildcard routing for customer subdomains',
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
tags: ['web', 'https', 'multi-tenant'],
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: '*.customers.serve.zone',
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.40', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12'],
|
||||
},
|
||||
},
|
||||
// 7. Load-balanced route with health check
|
||||
{
|
||||
id: 'route-7',
|
||||
name: 'Microservices LB',
|
||||
description: 'Load-balanced microservices backend with IP-hash affinity',
|
||||
enabled: true,
|
||||
tags: ['web', 'https', 'production'],
|
||||
match: {
|
||||
ports: [443, 8443],
|
||||
domains: 'services.serve.zone',
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{ host: ['10.0.2.1', '10.0.2.2', '10.0.2.3', '10.0.2.4'], port: 3000 },
|
||||
],
|
||||
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
|
||||
loadBalancing: { algorithm: 'ip-hash' },
|
||||
},
|
||||
},
|
||||
// 8. DNS-over-HTTPS route
|
||||
{
|
||||
id: 'route-8',
|
||||
name: 'DNS over HTTPS',
|
||||
description: 'DNS-over-HTTPS resolver endpoint',
|
||||
enabled: true,
|
||||
tags: ['dns', 'https'],
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: 'dns.serve.zone',
|
||||
path: '/dns-query',
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.3.1', port: 8053 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
// 9. NFTables high-performance route
|
||||
{
|
||||
id: 'route-9',
|
||||
name: 'High-Perf TCP Proxy',
|
||||
description: 'NFTables-accelerated TCP proxy for game servers',
|
||||
enabled: true,
|
||||
tags: ['tcp', 'nftables', 'production'],
|
||||
match: {
|
||||
ports: [{ from: 27000, to: 27050 }],
|
||||
protocol: 'tcp',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.4.1', port: 'preserve' }],
|
||||
forwardingEngine: 'nftables',
|
||||
},
|
||||
},
|
||||
// 10. Disabled maintenance route
|
||||
{
|
||||
id: 'route-10',
|
||||
name: 'Legacy Admin Panel',
|
||||
description: 'Deprecated admin panel — disabled for maintenance',
|
||||
enabled: false,
|
||||
tags: ['web', 'https', 'deprecated'],
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: 'admin-old.serve.zone',
|
||||
protocol: 'http',
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
},
|
||||
security: {
|
||||
ipBlockList: ['0.0.0.0/0'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private get filteredRoutes(): IRouteConfig[] {
|
||||
if (this.currentTab === 'all') return this.demoRoutes;
|
||||
if (this.currentTab === 'https') {
|
||||
return this.demoRoutes.filter((r) =>
|
||||
r.tags?.some((t) => ['web', 'https', 'http'].includes(t))
|
||||
);
|
||||
}
|
||||
if (this.currentTab === 'email') {
|
||||
return this.demoRoutes.filter((r) =>
|
||||
r.tags?.some((t) => ['email', 'smtp'].includes(t))
|
||||
);
|
||||
}
|
||||
if (this.currentTab === 'dns') {
|
||||
return this.demoRoutes.filter((r) =>
|
||||
r.tags?.some((t) => ['dns'].includes(t))
|
||||
);
|
||||
}
|
||||
return this.demoRoutes;
|
||||
}
|
||||
|
||||
async onActivate(context: { appui: DeesAppui; viewId: string }) {
|
||||
this.appui = context.appui;
|
||||
|
||||
this.appui.setContentTabs([
|
||||
{
|
||||
key: 'All Routes',
|
||||
action: () => {
|
||||
this.currentTab = 'all';
|
||||
this.updateSecondaryMenu();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'HTTP/S',
|
||||
action: () => {
|
||||
this.currentTab = 'https';
|
||||
this.updateSecondaryMenu();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Email',
|
||||
action: () => {
|
||||
this.currentTab = 'email';
|
||||
this.updateSecondaryMenu();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DNS',
|
||||
action: () => {
|
||||
this.currentTab = 'dns';
|
||||
this.updateSecondaryMenu();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
this.updateSecondaryMenu();
|
||||
}
|
||||
|
||||
private updateSecondaryMenu() {
|
||||
if (!this.appui) return;
|
||||
|
||||
const total = this.demoRoutes.length;
|
||||
const active = this.demoRoutes.filter((r) => r.enabled !== false).length;
|
||||
const forwardCount = this.demoRoutes.filter((r) => r.action.type === 'forward').length;
|
||||
|
||||
this.appui.setSecondaryMenu({
|
||||
heading: 'Routes',
|
||||
groups: [
|
||||
{
|
||||
name: 'Actions',
|
||||
items: [
|
||||
{
|
||||
type: 'action',
|
||||
key: 'Refresh',
|
||||
iconName: 'lucide:RefreshCw',
|
||||
action: () => {
|
||||
console.log('Refresh routes');
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
key: 'Add Route',
|
||||
iconName: 'lucide:Plus',
|
||||
action: () => {
|
||||
console.log('Add route');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Statistics',
|
||||
items: [
|
||||
{ type: 'header' as const, label: `${total} Total Routes` },
|
||||
{ type: 'header' as const, label: `${active} Active` },
|
||||
{ type: 'header' as const, label: `${forwardCount} Forward` },
|
||||
{ type: 'header' as const, label: `${total - forwardCount} Socket Handler` },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
onDeactivate() {
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<sz-route-list-view
|
||||
.routes=${this.filteredRoutes}
|
||||
@route-click=${(e: CustomEvent<IRouteConfig>) => {
|
||||
console.log('Route clicked:', e.detail.name);
|
||||
}}
|
||||
></sz-route-list-view>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -229,57 +229,87 @@ export class SzDomainDetailView extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section.full-width {
|
||||
dees-tile.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
}
|
||||
|
||||
.section-title svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--dees-color-text-secondary);
|
||||
}
|
||||
|
||||
.section-action {
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 4px;
|
||||
.section-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-action:hover {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
|
||||
.section-content {
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -582,8 +612,8 @@ export class SzDomainDetailView extends DeesElement {
|
||||
|
||||
<div class="grid">
|
||||
<!-- Certificate Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
@@ -591,9 +621,6 @@ export class SzDomainDetailView extends DeesElement {
|
||||
</svg>
|
||||
SSL Certificate
|
||||
</div>
|
||||
${this.certificate ? html`
|
||||
<button class="section-action" @click=${() => this.handleRenewCertificate()}>Renew</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="section-content">
|
||||
${this.certificate ? html`
|
||||
@@ -652,11 +679,16 @@ export class SzDomainDetailView extends DeesElement {
|
||||
<div class="empty-state">No certificate configured</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
${this.certificate ? html`
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button primary" @click=${() => this.handleRenewCertificate()}>Renew</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</dees-tile>
|
||||
|
||||
<!-- Proxy Routes Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 3 21 3 21 8"></polyline>
|
||||
@@ -679,11 +711,11 @@ export class SzDomainDetailView extends DeesElement {
|
||||
<div class="empty-state">No proxy routes configured</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<!-- DNS Records Section -->
|
||||
<div class="section full-width">
|
||||
<div class="section-header">
|
||||
<dees-tile class="full-width">
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
@@ -692,13 +724,6 @@ export class SzDomainDetailView extends DeesElement {
|
||||
</svg>
|
||||
DNS Records
|
||||
</div>
|
||||
<button class="section-action" @click=${() => this.handleAddDnsRecord()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Record
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
${this.dnsRecords.length > 0 ? html`
|
||||
@@ -737,7 +762,16 @@ export class SzDomainDetailView extends DeesElement {
|
||||
<div class="empty-state">No DNS records configured</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button primary" @click=${() => this.handleAddDnsRecord()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Record
|
||||
</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
977
ts_web/elements/sz-mta-detail-view.ts
Normal file
977
ts_web/elements/sz-mta-detail-view.ts
Normal file
@@ -0,0 +1,977 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import type { IEmail } from './sz-mta-list-view.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-mta-detail-view': SzMtaDetailView;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISmtpLogEntry {
|
||||
timestamp: string;
|
||||
direction: 'client' | 'server';
|
||||
command: string;
|
||||
responseCode?: number;
|
||||
}
|
||||
|
||||
export interface IConnectionInfo {
|
||||
sourceIp: string;
|
||||
sourceHostname: string;
|
||||
destinationIp: string;
|
||||
destinationPort: number;
|
||||
tlsVersion: string;
|
||||
tlsCipher: string;
|
||||
authenticated: boolean;
|
||||
authMethod: string;
|
||||
authUser: string;
|
||||
}
|
||||
|
||||
export interface IAuthenticationResults {
|
||||
spf: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none';
|
||||
spfDomain: string;
|
||||
dkim: 'pass' | 'fail' | 'none';
|
||||
dkimDomain: string;
|
||||
dmarc: 'pass' | 'fail' | 'none';
|
||||
dmarcPolicy: string;
|
||||
}
|
||||
|
||||
export interface IEmailDetail extends IEmail {
|
||||
to: string;
|
||||
toList: string[];
|
||||
cc?: string[];
|
||||
smtpLog: ISmtpLogEntry[];
|
||||
connectionInfo: IConnectionInfo;
|
||||
authenticationResults: IAuthenticationResults;
|
||||
rejectionReason?: string;
|
||||
bounceMessage?: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
@customElement('sz-mta-detail-view')
|
||||
export class SzMtaDetailView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px;">
|
||||
<sz-mta-detail-view
|
||||
.email=${{
|
||||
id: '1',
|
||||
direction: 'outbound',
|
||||
status: 'delivered',
|
||||
from: 'noreply@serve.zone',
|
||||
to: 'user@example.com',
|
||||
toList: ['user@example.com'],
|
||||
subject: 'Welcome to serve.zone',
|
||||
timestamp: '2024-01-15 14:30:22',
|
||||
messageId: '<abc123@serve.zone>',
|
||||
size: '12.4 KB',
|
||||
smtpLog: [
|
||||
{ timestamp: '14:30:20', direction: 'client', command: 'EHLO mail.serve.zone' },
|
||||
{ timestamp: '14:30:20', direction: 'server', command: '250-mail.example.com Hello', responseCode: 250 },
|
||||
{ timestamp: '14:30:20', direction: 'server', command: '250-STARTTLS', responseCode: 250 },
|
||||
{ timestamp: '14:30:20', direction: 'server', command: '250 SIZE 52428800', responseCode: 250 },
|
||||
{ timestamp: '14:30:20', direction: 'client', command: 'STARTTLS' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '220 Ready to start TLS', responseCode: 220 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'EHLO mail.serve.zone' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250-mail.example.com Hello', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250-AUTH PLAIN LOGIN', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250 SIZE 52428800', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'AUTH PLAIN AHVzZXIAcGFzc3dvcmQ=' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '235 2.7.0 Authentication successful', responseCode: 235 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'MAIL FROM:<noreply@serve.zone>' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250 OK', responseCode: 250 },
|
||||
{ timestamp: '14:30:21', direction: 'client', command: 'RCPT TO:<user@example.com>' },
|
||||
{ timestamp: '14:30:21', direction: 'server', command: '250 Accepted', responseCode: 250 },
|
||||
{ timestamp: '14:30:22', direction: 'client', command: 'DATA' },
|
||||
{ timestamp: '14:30:22', direction: 'server', command: '354 Enter message, ending with "." on a line by itself', responseCode: 354 },
|
||||
{ timestamp: '14:30:22', direction: 'client', command: '.' },
|
||||
{ timestamp: '14:30:22', direction: 'server', command: '250 OK id=1pQ2rS-0003Ab-C4', responseCode: 250 },
|
||||
{ timestamp: '14:30:22', direction: 'client', command: 'QUIT' },
|
||||
{ timestamp: '14:30:22', direction: 'server', command: '221 mail.example.com closing connection', responseCode: 221 },
|
||||
],
|
||||
connectionInfo: {
|
||||
sourceIp: '10.0.1.50',
|
||||
sourceHostname: 'mail.serve.zone',
|
||||
destinationIp: '93.184.216.34',
|
||||
destinationPort: 25,
|
||||
tlsVersion: 'TLSv1.3',
|
||||
tlsCipher: 'TLS_AES_256_GCM_SHA384',
|
||||
authenticated: true,
|
||||
authMethod: 'PLAIN',
|
||||
authUser: 'noreply@serve.zone',
|
||||
},
|
||||
authenticationResults: {
|
||||
spf: 'pass',
|
||||
spfDomain: 'serve.zone',
|
||||
dkim: 'pass',
|
||||
dkimDomain: 'serve.zone',
|
||||
dmarc: 'pass',
|
||||
dmarcPolicy: 'reject',
|
||||
},
|
||||
headers: {
|
||||
'From': 'noreply@serve.zone',
|
||||
'To': 'user@example.com',
|
||||
'Subject': 'Welcome to serve.zone',
|
||||
'Date': 'Mon, 15 Jan 2024 14:30:22 +0000',
|
||||
'MIME-Version': '1.0',
|
||||
'Content-Type': 'text/html; charset=UTF-8',
|
||||
},
|
||||
body: '<html>\\n<head><title>Welcome</title></head>\\n<body>\\n <h1>Welcome to serve.zone!</h1>\\n <p>Your account has been created successfully.</p>\\n <a href="https://serve.zone/dashboard">Go to Dashboard</a>\\n</body>\\n</html>',
|
||||
}}
|
||||
></sz-mta-detail-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['MTA'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor email: IEmailDetail | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
cursor: pointer;
|
||||
transition: color 200ms ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.email-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.email-subject {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.delivered {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.status-badge.bounced,
|
||||
.status-badge.rejected {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239, 68, 68, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.status-badge.deferred {
|
||||
background: ${cssManager.bdTheme('#fef9c3', 'rgba(250, 204, 21, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#ca8a04', '#facc15')};
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: ${cssManager.bdTheme('#dbeafe', 'rgba(59, 130, 246, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.direction-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.direction-badge.inbound {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.direction-badge.outbound {
|
||||
background: ${cssManager.bdTheme('#dbeafe', 'rgba(59, 130, 246, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-text-muted);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.smtp-log-container {
|
||||
padding: 16px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')} transparent;
|
||||
}
|
||||
|
||||
.smtp-log-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.smtp-log-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.smtp-log-container::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Phase separators */
|
||||
.smtp-phase-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.smtp-phase-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
}
|
||||
|
||||
.smtp-phase-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Chat bubbles */
|
||||
.smtp-bubble {
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.smtp-bubble.client {
|
||||
align-self: flex-start;
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.08)', 'rgba(59, 130, 246, 0.12)')};
|
||||
border-left: 3px solid ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.smtp-bubble.server {
|
||||
align-self: flex-end;
|
||||
background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.06)', 'rgba(34, 197, 94, 0.10)')};
|
||||
border-right: 3px solid ${cssManager.bdTheme('#22c55e', '#4ade80')};
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.smtp-bubble-command {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.smtp-bubble-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
}
|
||||
|
||||
.smtp-bubble.server .smtp-bubble-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.smtp-direction-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.smtp-direction-tag.client {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.smtp-direction-tag.server {
|
||||
color: ${cssManager.bdTheme('#22c55e', '#4ade80')};
|
||||
}
|
||||
|
||||
/* Response code badges */
|
||||
.response-code-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.response-code-badge.code-2xx {
|
||||
background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.15)', 'rgba(34, 197, 94, 0.25)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.response-code-badge.code-3xx {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.15)', 'rgba(59, 130, 246, 0.25)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.response-code-badge.code-4xx {
|
||||
background: ${cssManager.bdTheme('rgba(250, 204, 21, 0.15)', 'rgba(250, 204, 21, 0.25)')};
|
||||
color: ${cssManager.bdTheme('#ca8a04', '#facc15')};
|
||||
}
|
||||
|
||||
.response-code-badge.code-5xx {
|
||||
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.15)', 'rgba(239, 68, 68, 0.25)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
/* SMTP metadata banner — sits inside content, above the log */
|
||||
.smtp-header-subtitle {
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-text-muted);
|
||||
border-bottom: 1px solid var(--dees-color-border-subtle);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.smtp-direction-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.smtp-direction-badge.inbound {
|
||||
background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.12)', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.smtp-direction-badge.outbound {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.12)', 'rgba(59, 130, 246, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.email-body-container {
|
||||
padding: 16px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.tls-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.auth-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
}
|
||||
|
||||
.auth-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.auth-domain {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-badge.pass {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.auth-badge.fail {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239, 68, 68, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.auth-badge.softfail,
|
||||
.auth-badge.neutral,
|
||||
.auth-badge.none {
|
||||
background: ${cssManager.bdTheme('#fef9c3', 'rgba(250, 204, 21, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#ca8a04', '#facc15')};
|
||||
}
|
||||
|
||||
dees-tile.rejection-card::part(outer) {
|
||||
border-color: ${cssManager.bdTheme('#fecaca', 'rgba(239, 68, 68, 0.3)')};
|
||||
}
|
||||
|
||||
.rejection-content {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.rejection-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rejection-text {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#fef2f2', 'rgba(239, 68, 68, 0.1)')};
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
|
||||
}
|
||||
|
||||
.rejection-text:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.no-email {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (!this.email) {
|
||||
return html`<div class="no-email">No email selected</div>`;
|
||||
}
|
||||
|
||||
const email = this.email;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<div class="back-link" @click=${() => this.handleBack()}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
Back to Emails
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-header">
|
||||
<h1 class="email-subject">${email.subject}</h1>
|
||||
<div class="badge-group">
|
||||
<span class="status-badge ${email.status}">${email.status}</span>
|
||||
<span class="direction-badge ${email.direction}">${email.direction}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="main-content">
|
||||
<!-- Email Metadata -->
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Email Metadata</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="detail-list">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">From</span>
|
||||
<span class="detail-value">${email.from}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">To</span>
|
||||
<span class="detail-value">${email.toList.join(', ')}</span>
|
||||
</div>
|
||||
${email.cc && email.cc.length > 0 ? html`
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">CC</span>
|
||||
<span class="detail-value">${email.cc.join(', ')}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Subject</span>
|
||||
<span class="detail-value">${email.subject}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Date</span>
|
||||
<span class="detail-value">${email.timestamp}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Message ID</span>
|
||||
<span class="detail-value">${email.messageId}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Size</span>
|
||||
<span class="detail-value">${email.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<!-- SMTP Transaction Log -->
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">SMTP Transaction Log</span>
|
||||
<span class="smtp-direction-badge ${email.direction}">${email.direction}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="smtp-header-subtitle">
|
||||
${email.direction === 'outbound'
|
||||
? `${email.connectionInfo.sourceHostname} → ${email.connectionInfo.destinationIp}:${email.connectionInfo.destinationPort}`
|
||||
: `${email.connectionInfo.sourceIp} → ${email.connectionInfo.sourceHostname}:${email.connectionInfo.destinationPort}`
|
||||
}
|
||||
</div>
|
||||
${this.renderSmtpLog(email)}
|
||||
<div slot="footer" class="card-footer">
|
||||
<button class="tile-button" @click=${() => this.copySmtpLog()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
Copy Log
|
||||
</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<!-- Email Body -->
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Email Body (Escaped)</span>
|
||||
<span class="card-subtitle">Raw content — HTML is not rendered</span>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="email-body-container">${email.body}</pre>
|
||||
</dees-tile>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<!-- Connection Info -->
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Connection Info</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="detail-list">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Source IP</span>
|
||||
<span class="detail-value">${email.connectionInfo.sourceIp}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Source Hostname</span>
|
||||
<span class="detail-value">${email.connectionInfo.sourceHostname}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Destination</span>
|
||||
<span class="detail-value">${email.connectionInfo.destinationIp}:${email.connectionInfo.destinationPort}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">TLS</span>
|
||||
<span class="detail-value">
|
||||
${email.connectionInfo.tlsVersion
|
||||
? html`<span class="tls-badge">${email.connectionInfo.tlsVersion}</span>`
|
||||
: 'None'}
|
||||
</span>
|
||||
</div>
|
||||
${email.connectionInfo.tlsCipher ? html`
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Cipher</span>
|
||||
<span class="detail-value">${email.connectionInfo.tlsCipher}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Authenticated</span>
|
||||
<span class="detail-value">${email.connectionInfo.authenticated ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
${email.connectionInfo.authenticated ? html`
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Auth Method</span>
|
||||
<span class="detail-value">${email.connectionInfo.authMethod}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Auth User</span>
|
||||
<span class="detail-value">${email.connectionInfo.authUser}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<!-- Authentication Results -->
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Authentication Results</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="auth-row">
|
||||
<div>
|
||||
<span class="auth-label">SPF</span>
|
||||
<span class="auth-domain">${email.authenticationResults.spfDomain}</span>
|
||||
</div>
|
||||
<span class="auth-badge ${email.authenticationResults.spf}">${email.authenticationResults.spf}</span>
|
||||
</div>
|
||||
<div class="auth-row">
|
||||
<div>
|
||||
<span class="auth-label">DKIM</span>
|
||||
<span class="auth-domain">${email.authenticationResults.dkimDomain}</span>
|
||||
</div>
|
||||
<span class="auth-badge ${email.authenticationResults.dkim}">${email.authenticationResults.dkim}</span>
|
||||
</div>
|
||||
<div class="auth-row">
|
||||
<div>
|
||||
<span class="auth-label">DMARC</span>
|
||||
<span class="auth-domain">policy: ${email.authenticationResults.dmarcPolicy}</span>
|
||||
</div>
|
||||
<span class="auth-badge ${email.authenticationResults.dmarc}">${email.authenticationResults.dmarc}</span>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<!-- Rejection Details (conditional) -->
|
||||
${email.status === 'rejected' || email.status === 'bounced' ? html`
|
||||
<dees-tile class="rejection-card">
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Rejection Details</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
${email.rejectionReason ? html`
|
||||
<div class="rejection-label">Rejection Reason</div>
|
||||
<div class="rejection-text">${email.rejectionReason}</div>
|
||||
` : ''}
|
||||
${email.bounceMessage ? html`
|
||||
<div class="rejection-label">Bounce Message</div>
|
||||
<div class="rejection-text">${email.bounceMessage}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</dees-tile>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getResponseCodeBadgeClass(code: number): string {
|
||||
if (code >= 500) return 'code-5xx';
|
||||
if (code >= 400) return 'code-4xx';
|
||||
if (code >= 300) return 'code-3xx';
|
||||
return 'code-2xx';
|
||||
}
|
||||
|
||||
private getSmtpPhases(log: ISmtpLogEntry[]): Array<{ phase: string; label: string; entries: ISmtpLogEntry[] }> {
|
||||
const phases: Array<{ phase: string; label: string; entries: ISmtpLogEntry[] }> = [];
|
||||
let currentPhase = '';
|
||||
let ehloCount = 0;
|
||||
|
||||
for (const entry of log) {
|
||||
const cmd = entry.command.toUpperCase();
|
||||
let phase = currentPhase;
|
||||
|
||||
if (entry.direction === 'client') {
|
||||
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||||
ehloCount++;
|
||||
if (ehloCount === 1) {
|
||||
phase = 'connection';
|
||||
} else {
|
||||
phase = 'post-tls';
|
||||
}
|
||||
} else if (cmd === 'STARTTLS') {
|
||||
phase = 'tls';
|
||||
} else if (cmd.startsWith('AUTH')) {
|
||||
phase = 'auth';
|
||||
} else if (cmd.startsWith('MAIL FROM') || cmd.startsWith('RCPT TO') || cmd === 'DATA' || cmd === '.') {
|
||||
phase = 'transfer';
|
||||
} else if (cmd === 'QUIT') {
|
||||
phase = 'closing';
|
||||
}
|
||||
}
|
||||
|
||||
// Server responses stay in the current phase
|
||||
if (entry.direction === 'server' && phase === '') {
|
||||
phase = currentPhase || 'connection';
|
||||
}
|
||||
if (phase === '') phase = 'connection';
|
||||
|
||||
if (phase !== currentPhase) {
|
||||
currentPhase = phase;
|
||||
const labels: Record<string, string> = {
|
||||
'connection': 'Connection',
|
||||
'tls': 'TLS Negotiation',
|
||||
'post-tls': 'Post-TLS Handshake',
|
||||
'auth': 'Authentication',
|
||||
'transfer': 'Mail Transfer',
|
||||
'closing': 'Closing',
|
||||
};
|
||||
phases.push({ phase, label: labels[phase] || phase, entries: [] });
|
||||
}
|
||||
|
||||
if (phases.length === 0) {
|
||||
phases.push({ phase: 'connection', label: 'Connection', entries: [] });
|
||||
}
|
||||
phases[phases.length - 1].entries.push(entry);
|
||||
}
|
||||
|
||||
return phases;
|
||||
}
|
||||
|
||||
private renderSmtpLog(email: IEmailDetail): TemplateResult {
|
||||
const phases = this.getSmtpPhases(email.smtpLog);
|
||||
|
||||
return html`
|
||||
<div class="smtp-log-container">
|
||||
${phases.map(phase => html`
|
||||
<div class="smtp-phase-separator">
|
||||
<div class="smtp-phase-line"></div>
|
||||
<span class="smtp-phase-label">${phase.label}</span>
|
||||
<div class="smtp-phase-line"></div>
|
||||
</div>
|
||||
${phase.entries.map(entry => html`
|
||||
<div class="smtp-bubble ${entry.direction}">
|
||||
${entry.direction === 'server' && entry.responseCode ? html`
|
||||
<span class="response-code-badge ${this.getResponseCodeBadgeClass(entry.responseCode)}">${entry.responseCode}</span>
|
||||
` : ''}
|
||||
<div class="smtp-bubble-command">${entry.command}</div>
|
||||
<div class="smtp-bubble-meta">
|
||||
<span>${entry.timestamp}</span>
|
||||
<span>·</span>
|
||||
<span class="smtp-direction-tag ${entry.direction}">${entry.direction === 'client' ? 'Client' : 'Server'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private copySmtpLog() {
|
||||
if (!this.email) return;
|
||||
const text = this.email.smtpLog
|
||||
.map(e => `[${e.timestamp}] ${e.direction === 'client' ? 'C:' : 'S:'} ${e.command}`)
|
||||
.join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
private handleBack() {
|
||||
this.dispatchEvent(new CustomEvent('back', { bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
332
ts_web/elements/sz-mta-list-view.ts
Normal file
332
ts_web/elements/sz-mta-list-view.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-mta-list-view': SzMtaListView;
|
||||
}
|
||||
}
|
||||
|
||||
export type TEmailStatus = 'delivered' | 'bounced' | 'rejected' | 'deferred' | 'pending';
|
||||
export type TEmailDirection = 'inbound' | 'outbound';
|
||||
|
||||
export interface IEmail {
|
||||
id: string;
|
||||
direction: TEmailDirection;
|
||||
status: TEmailStatus;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
timestamp: string;
|
||||
messageId: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
@customElement('sz-mta-list-view')
|
||||
export class SzMtaListView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px;">
|
||||
<sz-mta-list-view
|
||||
.emails=${[
|
||||
{ id: '1', direction: 'outbound', status: 'delivered', from: 'noreply@serve.zone', to: 'user@example.com', subject: 'Welcome to serve.zone', timestamp: '2024-01-15 14:30:22', messageId: '<abc123@serve.zone>', size: '12.4 KB' },
|
||||
{ id: '2', direction: 'outbound', status: 'bounced', from: 'alerts@serve.zone', to: 'invalid@nowhere.test', subject: 'Service Alert: CPU Usage High', timestamp: '2024-01-15 14:28:10', messageId: '<def456@serve.zone>', size: '8.2 KB' },
|
||||
{ id: '3', direction: 'inbound', status: 'delivered', from: 'support@customer.com', to: 'admin@serve.zone', subject: 'Re: Infrastructure Review', timestamp: '2024-01-15 14:25:00', messageId: '<ghi789@customer.com>', size: '24.1 KB' },
|
||||
{ id: '4', direction: 'outbound', status: 'rejected', from: 'billing@serve.zone', to: 'blocked@spam-domain.test', subject: 'Invoice #2024-001', timestamp: '2024-01-15 14:20:45', messageId: '<jkl012@serve.zone>', size: '45.6 KB' },
|
||||
{ id: '5', direction: 'outbound', status: 'deferred', from: 'noreply@serve.zone', to: 'slow@remote-server.test', subject: 'Password Reset Request', timestamp: '2024-01-15 14:15:30', messageId: '<mno345@serve.zone>', size: '6.8 KB' },
|
||||
{ id: '6', direction: 'inbound', status: 'delivered', from: 'ci@github.com', to: 'devops@serve.zone', subject: 'Build #4521 passed', timestamp: '2024-01-15 14:10:00', messageId: '<pqr678@github.com>', size: '15.3 KB' },
|
||||
{ id: '7', direction: 'outbound', status: 'pending', from: 'reports@serve.zone', to: 'team@serve.zone', subject: 'Weekly Infrastructure Report', timestamp: '2024-01-15 14:05:00', messageId: '<stu901@serve.zone>', size: '102.7 KB' },
|
||||
]}
|
||||
></sz-mta-list-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['MTA'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor emails: IEmail[] = [];
|
||||
|
||||
@state()
|
||||
private accessor searchQuery: string = '';
|
||||
|
||||
@state()
|
||||
private accessor statusFilter: TEmailStatus | 'all' = 'all';
|
||||
|
||||
@state()
|
||||
private accessor directionFilter: TEmailDirection | 'all' = 'all';
|
||||
|
||||
private get filteredEmails(): IEmail[] {
|
||||
return this.emails.filter((email) => {
|
||||
if (this.statusFilter !== 'all' && email.status !== this.statusFilter) return false;
|
||||
if (this.directionFilter !== 'all' && email.direction !== this.directionFilter) return false;
|
||||
if (this.searchQuery) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
return (
|
||||
email.from.toLowerCase().includes(q) ||
|
||||
email.to.toLowerCase().includes(q) ||
|
||||
email.subject.toLowerCase().includes(q) ||
|
||||
email.messageId.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
outline: none;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#2563eb', '#3b82f6')};
|
||||
}
|
||||
|
||||
.chip-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||
border-color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 90px 1.5fr 1.5fr 2fr 140px;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 90px 1.5fr 1.5fr 2fr 140px;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.direction-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.direction-icon.inbound {
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.direction-icon.outbound {
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.delivered {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.status-badge.bounced,
|
||||
.status-badge.rejected {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239, 68, 68, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.status-badge.deferred {
|
||||
background: ${cssManager.bdTheme('#fef9c3', 'rgba(250, 204, 21, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#ca8a04', '#facc15')};
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: ${cssManager.bdTheme('#dbeafe', 'rgba(59, 130, 246, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.email-from,
|
||||
.email-to {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.email-subject {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.email-timestamp {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const filtered = this.filteredEmails;
|
||||
|
||||
return html`
|
||||
<div class="filter-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="Search by from, to, subject, or message ID..."
|
||||
.value=${this.searchQuery}
|
||||
@input=${(e: InputEvent) => { this.searchQuery = (e.target as HTMLInputElement).value; }}
|
||||
/>
|
||||
<div class="chip-group">
|
||||
${(['all', 'inbound', 'outbound'] as const).map(dir => html`
|
||||
<button
|
||||
class="chip ${this.directionFilter === dir ? 'active' : ''}"
|
||||
@click=${() => { this.directionFilter = dir; }}
|
||||
>${dir === 'all' ? 'All' : dir === 'inbound' ? 'Inbound' : 'Outbound'}</button>
|
||||
`)}
|
||||
</div>
|
||||
<div class="chip-group">
|
||||
${(['all', 'delivered', 'bounced', 'rejected', 'deferred', 'pending'] as const).map(s => html`
|
||||
<button
|
||||
class="chip ${this.statusFilter === s ? 'active' : ''}"
|
||||
@click=${() => { this.statusFilter = s; }}
|
||||
>${s === 'all' ? 'All' : s.charAt(0).toUpperCase() + s.slice(1)}</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-count">Showing ${filtered.length} of ${this.emails.length} emails</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<span></span>
|
||||
<span>Status</span>
|
||||
<span>From</span>
|
||||
<span>To</span>
|
||||
<span>Subject</span>
|
||||
<span>Timestamp</span>
|
||||
</div>
|
||||
${filtered.length > 0 ? filtered.map(email => html`
|
||||
<div class="table-row" @click=${() => this.handleEmailClick(email)}>
|
||||
<span class="direction-icon ${email.direction}">
|
||||
${email.direction === 'inbound'
|
||||
? html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`
|
||||
: html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`
|
||||
}
|
||||
</span>
|
||||
<span><span class="status-badge ${email.status}">${email.status}</span></span>
|
||||
<span class="email-from" title="${email.from}">${email.from}</span>
|
||||
<span class="email-to" title="${email.to}">${email.to}</span>
|
||||
<span class="email-subject" title="${email.subject}">${email.subject}</span>
|
||||
<span class="email-timestamp">${email.timestamp}</span>
|
||||
</div>
|
||||
`) : html`
|
||||
<div class="empty-state">No emails found</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleEmailClick(email: IEmail) {
|
||||
this.dispatchEvent(new CustomEvent('email-click', { detail: email, bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import './sz-stat-card.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -91,19 +90,11 @@ export class SzNetworkDomainsView extends DeesElement {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
dees-statsgrid {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
@@ -205,6 +196,42 @@ export class SzNetworkDomainsView extends DeesElement {
|
||||
`,
|
||||
];
|
||||
|
||||
private get tiles(): IStatsTile[] {
|
||||
return [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Domains',
|
||||
value: this.stats.total,
|
||||
type: 'number',
|
||||
icon: 'lucide:globe',
|
||||
},
|
||||
{
|
||||
id: 'valid',
|
||||
title: 'Valid Certificates',
|
||||
value: this.stats.valid,
|
||||
type: 'number',
|
||||
icon: 'lucide:shieldCheck',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'expiring',
|
||||
title: 'Expiring Soon',
|
||||
value: this.stats.expiring,
|
||||
type: 'number',
|
||||
icon: 'lucide:shieldAlert',
|
||||
color: this.stats.expiring > 0 ? '#f59e0b' : undefined,
|
||||
},
|
||||
{
|
||||
id: 'expired',
|
||||
title: 'Expired/Pending',
|
||||
value: this.stats.expired,
|
||||
type: 'number',
|
||||
icon: 'lucide:circleOff',
|
||||
color: this.stats.expired > 0 ? '#ef4444' : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="header">
|
||||
@@ -212,31 +239,10 @@ export class SzNetworkDomainsView extends DeesElement {
|
||||
<button class="sync-button" @click=${() => this.handleSync()}>Sync Cloudflare</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<sz-stat-card
|
||||
label="Total Domains"
|
||||
value="${this.stats.total}"
|
||||
icon="server"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Valid Certificates"
|
||||
value="${this.stats.valid}"
|
||||
icon="check"
|
||||
variant="success"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Expiring Soon"
|
||||
value="${this.stats.expiring}"
|
||||
icon="stop"
|
||||
variant="${this.stats.expiring > 0 ? 'warning' : 'default'}"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Expired/Pending"
|
||||
value="${this.stats.expired}"
|
||||
icon="stop"
|
||||
variant="${this.stats.expired > 0 ? 'error' : 'default'}"
|
||||
></sz-stat-card>
|
||||
</div>
|
||||
<dees-statsgrid
|
||||
.tiles=${this.tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import './sz-stat-card.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -113,42 +112,107 @@ export class SzNetworkProxyView extends DeesElement {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
dees-statsgrid {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
dees-tile {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-text-muted);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.tile-button.danger {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.tile-button.danger:hover {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239, 68, 68, 0.1)')};
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -234,61 +298,6 @@ export class SzNetworkProxyView extends DeesElement {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
}
|
||||
|
||||
.logs-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stream-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: ${cssManager.bdTheme('#2563eb', '#3b82f6')};
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.stream-button:hover {
|
||||
background: ${cssManager.bdTheme('#1d4ed8', '#2563eb')};
|
||||
}
|
||||
|
||||
.stream-button.streaming {
|
||||
background: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.stream-button.streaming:hover {
|
||||
background: ${cssManager.bdTheme('#b91c1c', '#dc2626')};
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
padding: 16px;
|
||||
font-family: monospace;
|
||||
@@ -336,41 +345,57 @@ export class SzNetworkProxyView extends DeesElement {
|
||||
`,
|
||||
];
|
||||
|
||||
private get tiles(): IStatsTile[] {
|
||||
return [
|
||||
{
|
||||
id: 'proxy-status',
|
||||
title: 'Proxy Status',
|
||||
value: this.proxyStatus === 'running' ? 'Running' : 'Stopped',
|
||||
type: 'text',
|
||||
icon: 'lucide:server',
|
||||
color: this.proxyStatus === 'running' ? '#22c55e' : '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'routes',
|
||||
title: 'Routes',
|
||||
value: this.routeCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:server',
|
||||
},
|
||||
{
|
||||
id: 'certificates',
|
||||
title: 'Certificates',
|
||||
value: this.certificateCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:check',
|
||||
},
|
||||
{
|
||||
id: 'targets',
|
||||
title: 'Targets',
|
||||
value: this.targetCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:server',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="actions">
|
||||
<button class="refresh-button" @click=${() => this.handleRefresh()}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<sz-stat-card
|
||||
label="Proxy Status"
|
||||
value="${this.proxyStatus === 'running' ? 'Running' : 'Stopped'}"
|
||||
icon="server"
|
||||
variant="${this.proxyStatus === 'running' ? 'success' : 'error'}"
|
||||
valueBadge
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Routes"
|
||||
value="${this.routeCount}"
|
||||
icon="server"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Certificates"
|
||||
value="${this.certificateCount}"
|
||||
icon="check"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Targets"
|
||||
value="${this.targetCount}"
|
||||
icon="server"
|
||||
></sz-stat-card>
|
||||
</div>
|
||||
<dees-statsgrid
|
||||
.tiles=${this.tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Traffic Targets</div>
|
||||
<div class="section-subtitle">Services, registry, and platform services with their routing info</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Traffic Targets</span>
|
||||
<span class="section-subtitle">Services, registry, and platform services with their routing info</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-header">
|
||||
<span>Type</span>
|
||||
@@ -388,30 +413,13 @@ export class SzNetworkProxyView extends DeesElement {
|
||||
<span><span class="status-badge ${target.status}">${target.status}</span></span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<div class="section">
|
||||
<div class="logs-header">
|
||||
<div>
|
||||
<div class="section-title">Access Logs</div>
|
||||
<div class="section-subtitle">Real-time Caddy access logs</div>
|
||||
</div>
|
||||
<div class="logs-actions">
|
||||
<button class="stream-button ${this.streaming ? 'streaming' : ''}" @click=${() => this.toggleStreaming()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
${this.streaming
|
||||
? html`<rect x="6" y="6" width="12" height="12" rx="1"/>`
|
||||
: html`<polygon points="5,3 19,12 5,21"/>`
|
||||
}
|
||||
</svg>
|
||||
${this.streaming ? 'Stop' : 'Stream'}
|
||||
</button>
|
||||
<button class="clear-button" @click=${() => this.handleClearLogs()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6"/><path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6m3,0V4a2,2,0,0,1,2-2h4a2,2,0,0,1,2,2v2"/>
|
||||
</svg>
|
||||
Clear logs
|
||||
</button>
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Access Logs</span>
|
||||
<span class="section-subtitle">Real-time Caddy access logs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-container">
|
||||
@@ -428,7 +436,24 @@ export class SzNetworkProxyView extends DeesElement {
|
||||
<div class="empty-logs">Click "Stream" to start live access log streaming</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button ${this.streaming ? 'danger' : 'primary'}" @click=${() => this.toggleStreaming()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
${this.streaming
|
||||
? html`<rect x="6" y="6" width="12" height="12" rx="1"/>`
|
||||
: html`<polygon points="5,3 19,12 5,21"/>`
|
||||
}
|
||||
</svg>
|
||||
${this.streaming ? 'Stop' : 'Stream'}
|
||||
</button>
|
||||
<button class="tile-button" @click=${() => this.handleClearLogs()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6"/><path d="M19,6v14a2,2,0,0,1-2,2H7a2,2,0,0,1-2-2V6m3,0V4a2,2,0,0,1,2-2h4a2,2,0,0,1,2,2v2"/>
|
||||
</svg>
|
||||
Clear logs
|
||||
</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -225,39 +225,35 @@ export class SzPlatformServiceDetailView extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section.full-width {
|
||||
dees-tile.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
}
|
||||
|
||||
.section-title svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--dees-color-text-secondary);
|
||||
}
|
||||
|
||||
.section-content {
|
||||
@@ -363,56 +359,6 @@ export class SzPlatformServiceDetailView extends DeesElement {
|
||||
background: ${cssManager.bdTheme('#ef4444', '#ef4444')};
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background: ${cssManager.bdTheme('#18181b', '#09090b')};
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #71717a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-level.info {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.log-level.warn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.log-level.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.log-level.debug {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #fafafa;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -502,8 +448,8 @@ export class SzPlatformServiceDetailView extends DeesElement {
|
||||
|
||||
<div class="grid">
|
||||
<!-- Connection Info -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
@@ -566,11 +512,11 @@ export class SzPlatformServiceDetailView extends DeesElement {
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
@@ -587,12 +533,12 @@ export class SzPlatformServiceDetailView extends DeesElement {
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<!-- Metrics -->
|
||||
${this.service.metrics ? html`
|
||||
<div class="section full-width">
|
||||
<div class="section-header">
|
||||
<dees-tile class="full-width">
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="20" x2="18" y2="10"></line>
|
||||
@@ -633,33 +579,22 @@ export class SzPlatformServiceDetailView extends DeesElement {
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
` : ''}
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="section full-width">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
Logs
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="log-container">
|
||||
${this.logs.length > 0 ? this.logs.map(log => html`
|
||||
<div class="log-entry">
|
||||
<span class="log-timestamp">${log.timestamp}</span>
|
||||
<span class="log-level ${log.level}">${log.level}</span>
|
||||
<span class="log-message">${log.message}</span>
|
||||
</div>
|
||||
`) : html`
|
||||
<div style="color: #71717a; text-align: center; padding: 20px;">No logs available</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<div style="grid-column: 1 / -1;">
|
||||
<dees-chart-log
|
||||
.label=${'Service Logs'}
|
||||
.logEntries=${this.logs.map(log => ({
|
||||
timestamp: log.timestamp.includes('T') ? log.timestamp : new Date(log.timestamp).toISOString(),
|
||||
level: log.level as 'debug' | 'info' | 'warn' | 'error',
|
||||
message: log.message,
|
||||
}))}
|
||||
.autoScroll=${true}
|
||||
.maxEntries=${2000}
|
||||
.showMetrics=${true}
|
||||
></dees-chart-log>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
859
ts_web/elements/sz-route-card.ts
Normal file
859
ts_web/elements/sz-route-card.ts
Normal file
@@ -0,0 +1,859 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-route-card': SzRouteCard;
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified route types for display purposes
|
||||
export type TRouteActionType = 'forward' | 'socket-handler';
|
||||
export type TTlsMode = 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
|
||||
export type TPortRange = number | number[] | Array<{ from: number; to: number }>;
|
||||
|
||||
export interface IRouteMatch {
|
||||
ports: TPortRange;
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
clientIp?: string[];
|
||||
tlsVersion?: string[];
|
||||
headers?: Record<string, string>;
|
||||
protocol?: 'http' | 'tcp';
|
||||
}
|
||||
|
||||
export interface IRouteTarget {
|
||||
host: string | string[];
|
||||
port: number | 'preserve';
|
||||
}
|
||||
|
||||
export interface IRouteTls {
|
||||
mode: TTlsMode;
|
||||
certificate?: 'auto' | { key: string; cert: string };
|
||||
}
|
||||
|
||||
export interface IRouteAction {
|
||||
type: TRouteActionType;
|
||||
targets?: IRouteTarget[];
|
||||
tls?: IRouteTls;
|
||||
websocket?: { enabled: boolean };
|
||||
loadBalancing?: { algorithm: 'round-robin' | 'least-connections' | 'ip-hash' };
|
||||
forwardingEngine?: 'node' | 'nftables';
|
||||
}
|
||||
|
||||
export interface IRouteSecurity {
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
maxConnections?: number;
|
||||
rateLimit?: { enabled: boolean; maxRequests: number; window: number };
|
||||
}
|
||||
|
||||
export interface IRouteMetadata {
|
||||
sourceProfileRef?: string;
|
||||
networkTargetRef?: string;
|
||||
sourceProfileName?: string;
|
||||
networkTargetName?: string;
|
||||
lastResolvedAt?: number;
|
||||
}
|
||||
|
||||
export interface IRouteConfig {
|
||||
id?: string;
|
||||
match: IRouteMatch;
|
||||
action: IRouteAction;
|
||||
security?: IRouteSecurity;
|
||||
headers?: { request?: Record<string, string>; response?: Record<string, string> };
|
||||
metadata?: IRouteMetadata;
|
||||
/** When true, only VPN clients whose TargetProfile matches this route get access */
|
||||
vpnOnly?: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
tags?: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function formatPorts(ports: TPortRange): string {
|
||||
if (typeof ports === 'number') return String(ports);
|
||||
if (Array.isArray(ports)) {
|
||||
return ports
|
||||
.map((p) => {
|
||||
if (typeof p === 'number') return String(p);
|
||||
return `${p.from}\u2013${p.to}`;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
return String(ports);
|
||||
}
|
||||
|
||||
function formatTargets(targets: IRouteTarget[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const t of targets) {
|
||||
const hosts = Array.isArray(t.host) ? t.host : [t.host];
|
||||
const portStr = t.port === 'preserve' ? '(preserve)' : String(t.port);
|
||||
for (const h of hosts) {
|
||||
result.push(`${h}:${portStr}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@customElement('sz-route-card')
|
||||
export class SzRouteCard extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 520px;">
|
||||
<sz-route-card
|
||||
.route=${{
|
||||
name: 'API Gateway',
|
||||
description: 'Main API gateway with TLS termination and load balancing',
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
tags: ['web', 'api', 'production'],
|
||||
match: {
|
||||
ports: [443, 8443],
|
||||
domains: ['api.example.com', '*.api.serve.zone'],
|
||||
path: '/api/*',
|
||||
protocol: 'http' as const,
|
||||
clientIp: ['10.0.0.0/8'],
|
||||
},
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [
|
||||
{ host: ['10.0.0.1', '10.0.0.2'], port: 8080 },
|
||||
],
|
||||
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
|
||||
websocket: { enabled: true },
|
||||
loadBalancing: { algorithm: 'round-robin' as const },
|
||||
forwardingEngine: 'nftables' as const,
|
||||
},
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.0/8'],
|
||||
ipBlockList: ['192.168.100.0/24'],
|
||||
rateLimit: { enabled: true, maxRequests: 100, window: 60 },
|
||||
maxConnections: 1000,
|
||||
},
|
||||
vpnOnly: true,
|
||||
metadata: {
|
||||
sourceProfileName: 'STANDARD',
|
||||
networkTargetName: 'LOSSLESS_INFRA',
|
||||
},
|
||||
} satisfies IRouteConfig}
|
||||
></sz-route-card>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Routes'];
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor route: IRouteConfig | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor showActions: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
box-shadow: 0 2px 8px ${cssManager.bdTheme('rgba(0,0,0,0.06)', 'rgba(0,0,0,0.2)')};
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.enabled {
|
||||
background: ${cssManager.bdTheme('#22c55e', '#22c55e')};
|
||||
box-shadow: 0 0 6px ${cssManager.bdTheme('rgba(34,197,94,0.4)', 'rgba(34,197,94,0.3)')};
|
||||
}
|
||||
|
||||
.status-dot.disabled {
|
||||
background: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
}
|
||||
|
||||
.route-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.forward {
|
||||
background: ${cssManager.bdTheme('#dbeafe', 'rgba(59, 130, 246, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.badge.socket-handler {
|
||||
background: ${cssManager.bdTheme('#ede9fe', 'rgba(139, 92, 246, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
|
||||
}
|
||||
|
||||
.badge.enabled {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.badge.disabled {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', 'rgba(113, 113, 122, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 2px 8px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#52525b', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.priority {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
border-left: 3px solid;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#101010')};
|
||||
}
|
||||
|
||||
.section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section.match {
|
||||
border-left-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
.section.action {
|
||||
border-left-color: ${cssManager.bdTheme('#22c55e', '#22c55e')};
|
||||
}
|
||||
|
||||
.section.security {
|
||||
border-left-color: ${cssManager.bdTheme('#f59e0b', '#f59e0b')};
|
||||
}
|
||||
|
||||
.section.linked {
|
||||
border-left-color: ${cssManager.bdTheme('#8b5cf6', '#8b5cf6')};
|
||||
}
|
||||
|
||||
.linked-name {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
background: ${cssManager.bdTheme('#ede9fe', 'rgba(139, 92, 246, 0.15)')};
|
||||
color: ${cssManager.bdTheme('#6d28d9', '#a78bfa')};
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field-key {
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
min-width: 64px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
color: ${cssManager.bdTheme('#18181b', '#e4e4e7')};
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.domain-chip {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
background: ${cssManager.bdTheme('#eff6ff', 'rgba(59, 130, 246, 0.1)')};
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.domain-chip.glob {
|
||||
background: ${cssManager.bdTheme('#fef3c7', 'rgba(245, 158, 11, 0.15)')};
|
||||
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.protocol-badge.http {
|
||||
background: ${cssManager.bdTheme('#dbeafe', 'rgba(59, 130, 246, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.protocol-badge.tcp {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.tls-badge {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tls-badge.auto {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.tls-badge.custom {
|
||||
background: ${cssManager.bdTheme('#ffedd5', 'rgba(249, 115, 22, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#c2410c', '#fb923c')};
|
||||
}
|
||||
|
||||
.engine-badge {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: ${cssManager.bdTheme('#fae8ff', 'rgba(168, 85, 247, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#7e22ce', '#c084fc')};
|
||||
}
|
||||
|
||||
.header-pair {
|
||||
display: inline;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Feature icons */
|
||||
.features-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#f4f4f5', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.no-route {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section.vpn {
|
||||
border-left-color: ${cssManager.bdTheme('#0891b2', '#06b6d4')};
|
||||
}
|
||||
|
||||
.vpn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vpn-badge.mandatory {
|
||||
background: ${cssManager.bdTheme('#fff7ed', 'rgba(249, 115, 22, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#c2410c', '#fb923c')};
|
||||
}
|
||||
|
||||
.vpn-badge.optional {
|
||||
background: ${cssManager.bdTheme('#ecfdf5', 'rgba(16, 185, 129, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#047857', '#34d399')};
|
||||
}
|
||||
|
||||
.vpn-tag {
|
||||
display: inline-flex;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
background: ${cssManager.bdTheme('#ecfeff', 'rgba(6, 182, 212, 0.15)')};
|
||||
color: ${cssManager.bdTheme('#0e7490', '#22d3ee')};
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#f4f4f5', '#1a1a1a')};
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#52525b', '#a1a1aa')};
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.action-btn.edit:hover {
|
||||
border-color: ${cssManager.bdTheme('#93c5fd', 'rgba(59, 130, 246, 0.5)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
border-color: ${cssManager.bdTheme('#fca5a5', 'rgba(239, 68, 68, 0.5)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (!this.route) {
|
||||
return html`<div class="card"><div class="no-route">No route data</div></div>`;
|
||||
}
|
||||
|
||||
const r = this.route;
|
||||
const isEnabled = r.enabled !== false;
|
||||
const match = r.match;
|
||||
const action = r.action;
|
||||
const security = r.security;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<span class="status-dot ${isEnabled ? 'enabled' : 'disabled'}"></span>
|
||||
<span class="route-name">${r.name || r.id || 'Unnamed Route'}</span>
|
||||
</div>
|
||||
<div class="header-badges">
|
||||
<span class="badge ${action.type}">${action.type}</span>
|
||||
<span class="badge ${isEnabled ? 'enabled' : 'disabled'}">${isEnabled ? 'enabled' : 'disabled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${r.description ? html`<div class="description">${r.description}</div>` : ''}
|
||||
|
||||
<div class="meta-row">
|
||||
${r.tags && r.tags.length > 0
|
||||
? html`<div class="tags">${r.tags.map((t) => html`<span class="tag">${t}</span>`)}</div>`
|
||||
: html`<div></div>`}
|
||||
${r.priority != null ? html`<span class="priority">Priority: ${r.priority}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Match Section -->
|
||||
<div class="section match">
|
||||
<div class="section-label">Match</div>
|
||||
<div class="field-row">
|
||||
<span class="field-key">Ports</span>
|
||||
<span class="field-value mono">${formatPorts(match.ports)}</span>
|
||||
</div>
|
||||
${match.domains
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Domains</span>
|
||||
<span class="field-value">${this.renderDomains(match.domains)}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${match.path
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Path</span>
|
||||
<span class="field-value mono">${match.path}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${match.protocol
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Protocol</span>
|
||||
<span class="field-value">
|
||||
<span class="protocol-badge ${match.protocol}">${match.protocol}</span>
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${match.clientIp && match.clientIp.length > 0
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Client</span>
|
||||
<span class="field-value mono">${match.clientIp.join(', ')}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${match.tlsVersion && match.tlsVersion.length > 0
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">TLS Ver</span>
|
||||
<span class="field-value">${match.tlsVersion.join(', ')}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${match.headers
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Headers</span>
|
||||
<span class="field-value">
|
||||
${Object.entries(match.headers).map(
|
||||
([k, v]) => html`<span class="header-pair">${k}=${v}</span> `
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Action Section -->
|
||||
<div class="section action">
|
||||
<div class="section-label">Action</div>
|
||||
${action.targets && action.targets.length > 0
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Targets</span>
|
||||
<span class="field-value mono">${formatTargets(action.targets).join(', ')}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${action.tls
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">TLS</span>
|
||||
<span class="field-value">
|
||||
${action.tls.mode}
|
||||
${action.tls.certificate
|
||||
? action.tls.certificate === 'auto'
|
||||
? html` <span class="tls-badge auto">auto cert</span>`
|
||||
: html` <span class="tls-badge custom">custom cert</span>`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${action.forwardingEngine
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Engine</span>
|
||||
<span class="field-value"><span class="engine-badge">${action.forwardingEngine}</span></span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${action.loadBalancing
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">LB</span>
|
||||
<span class="field-value">${action.loadBalancing.algorithm}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${action.websocket?.enabled
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">WS</span>
|
||||
<span class="field-value"><span class="badge enabled">enabled</span></span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Security Section -->
|
||||
${security
|
||||
? html`
|
||||
<div class="section security">
|
||||
<div class="section-label">Security</div>
|
||||
${security.ipAllowList && security.ipAllowList.length > 0
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Allow</span>
|
||||
<span class="field-value mono">${security.ipAllowList.join(', ')}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${security.ipBlockList && security.ipBlockList.length > 0
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Block</span>
|
||||
<span class="field-value mono">${security.ipBlockList.join(', ')}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${security.rateLimit?.enabled
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Rate</span>
|
||||
<span class="field-value">${security.rateLimit.maxRequests} req / ${security.rateLimit.window}s</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${security.maxConnections
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Max Conn</span>
|
||||
<span class="field-value">${security.maxConnections}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<!-- VPN Section -->
|
||||
${this.renderVpn()}
|
||||
|
||||
<!-- Linked References Section -->
|
||||
${this.renderLinked()}
|
||||
|
||||
<!-- Feature Icons Row -->
|
||||
${this.renderFeatures()}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
${this.showActions ? html`
|
||||
<div class="card-actions">
|
||||
<button class="action-btn edit" @click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent('route-edit', {
|
||||
detail: this.route,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}}>Edit</button>
|
||||
<button class="action-btn delete" @click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent('route-delete', {
|
||||
detail: this.route,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}}>Delete</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDomains(domains: string | string[]): TemplateResult {
|
||||
const list = Array.isArray(domains) ? domains : [domains];
|
||||
return html`${list.map(
|
||||
(d) =>
|
||||
html`<span class="domain-chip ${d.includes('*') ? 'glob' : ''}">${d}</span>`
|
||||
)}`;
|
||||
}
|
||||
|
||||
private renderVpn(): TemplateResult {
|
||||
if (!this.route?.vpnOnly) return html``;
|
||||
|
||||
return html`
|
||||
<div class="section vpn">
|
||||
<div class="section-label">VPN Access</div>
|
||||
<div class="field-row">
|
||||
<span class="field-key">Mode</span>
|
||||
<span class="field-value">
|
||||
<span class="vpn-badge mandatory">VPN Only</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLinked(): TemplateResult {
|
||||
const meta = this.route?.metadata;
|
||||
if (!meta) return html``;
|
||||
const hasProfile = !!meta.sourceProfileName;
|
||||
const hasTarget = !!meta.networkTargetName;
|
||||
if (!hasProfile && !hasTarget) return html``;
|
||||
|
||||
return html`
|
||||
<div class="section linked">
|
||||
<div class="section-label">Linked</div>
|
||||
${hasProfile
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Profile</span>
|
||||
<span class="field-value"><span class="linked-name">${meta.sourceProfileName}</span></span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${hasTarget
|
||||
? html`
|
||||
<div class="field-row">
|
||||
<span class="field-key">Target</span>
|
||||
<span class="field-value"><span class="linked-name">${meta.networkTargetName}</span></span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFeatures(): TemplateResult {
|
||||
if (!this.route) return html``;
|
||||
const features: TemplateResult[] = [];
|
||||
const action = this.route.action;
|
||||
const security = this.route.security;
|
||||
const headers = this.route.headers;
|
||||
const meta = this.route.metadata;
|
||||
|
||||
if (action.tls) {
|
||||
features.push(html`<span class="feature"><span class="feature-icon">🔒</span>TLS</span>`);
|
||||
}
|
||||
if (action.websocket?.enabled) {
|
||||
features.push(html`<span class="feature"><span class="feature-icon">↔</span>WS</span>`);
|
||||
}
|
||||
if (action.loadBalancing) {
|
||||
features.push(html`<span class="feature"><span class="feature-icon">⚖</span>LB</span>`);
|
||||
}
|
||||
if (security) {
|
||||
features.push(html`<span class="feature"><span class="feature-icon">🛡</span>Security</span>`);
|
||||
}
|
||||
if (headers) {
|
||||
features.push(html`<span class="feature"><span class="feature-icon">⚙</span>Headers</span>`);
|
||||
}
|
||||
if (this.route?.vpnOnly) {
|
||||
features.push(html`<span class="feature"><span class="feature-icon">🔐</span>VPN</span>`);
|
||||
}
|
||||
if (meta?.sourceProfileName || meta?.networkTargetName) {
|
||||
features.push(html`<span class="feature"><span class="feature-icon">🔗</span>Linked</span>`);
|
||||
}
|
||||
|
||||
if (features.length === 0) return html``;
|
||||
return html`<div class="features-row">${features}</div>`;
|
||||
}
|
||||
}
|
||||
330
ts_web/elements/sz-route-list-view.ts
Normal file
330
ts_web/elements/sz-route-list-view.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import type { IRouteConfig, TRouteActionType } from './sz-route-card.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-route-list-view': SzRouteListView;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sz-route-list-view')
|
||||
export class SzRouteListView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<div style="padding: 24px; max-width: 1200px;">
|
||||
<sz-route-list-view
|
||||
.routes=${[
|
||||
{
|
||||
name: 'HTTPS Gateway',
|
||||
description: 'Main web gateway with TLS termination',
|
||||
enabled: true,
|
||||
tags: ['web', 'https', 'production'],
|
||||
match: { ports: 443, domains: ['*.example.com', 'serve.zone'], protocol: 'http' as const },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: '10.0.0.1', port: 8080 }],
|
||||
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'SMTP Inbound',
|
||||
description: 'Email relay for incoming mail',
|
||||
enabled: true,
|
||||
tags: ['email', 'smtp'],
|
||||
match: { ports: 25, domains: 'mail.serve.zone' },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: '10.0.1.5', port: 25 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WebSocket API',
|
||||
description: 'Real-time WebSocket connections',
|
||||
enabled: true,
|
||||
tags: ['web', 'api'],
|
||||
match: { ports: 443, domains: 'ws.example.com', path: '/ws/*' },
|
||||
action: {
|
||||
type: 'forward' as const,
|
||||
targets: [{ host: '10.0.0.3', port: 9090 }],
|
||||
websocket: { enabled: true },
|
||||
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Maintenance Page',
|
||||
enabled: false,
|
||||
tags: ['web'],
|
||||
match: { ports: [80, 443], domains: 'old.example.com' },
|
||||
action: { type: 'socket-handler' as const },
|
||||
},
|
||||
] satisfies IRouteConfig[]}
|
||||
></sz-route-list-view>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Routes'];
|
||||
|
||||
@property({ type: Array })
|
||||
public accessor routes: IRouteConfig[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
public accessor showActionsFilter: ((route: IRouteConfig) => boolean) | null = null;
|
||||
|
||||
@state()
|
||||
private accessor searchQuery: string = '';
|
||||
|
||||
@state()
|
||||
private accessor actionFilter: TRouteActionType | 'all' = 'all';
|
||||
|
||||
@state()
|
||||
private accessor enabledFilter: 'all' | 'enabled' | 'disabled' = 'all';
|
||||
|
||||
private get filteredRoutes(): IRouteConfig[] {
|
||||
return this.routes.filter((route) => {
|
||||
// Action type filter
|
||||
if (this.actionFilter !== 'all' && route.action.type !== this.actionFilter) return false;
|
||||
|
||||
// Enabled/disabled filter
|
||||
if (this.enabledFilter === 'enabled' && route.enabled === false) return false;
|
||||
if (this.enabledFilter === 'disabled' && route.enabled !== false) return false;
|
||||
|
||||
// Search query
|
||||
if (this.searchQuery) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
return this.routeMatchesSearch(route, q);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private routeMatchesSearch(route: IRouteConfig, q: string): boolean {
|
||||
// Name and description
|
||||
if (route.name?.toLowerCase().includes(q)) return true;
|
||||
if (route.description?.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Domains
|
||||
if (route.match.domains) {
|
||||
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
|
||||
if (domains.some((d) => d.toLowerCase().includes(q))) return true;
|
||||
}
|
||||
|
||||
// Ports
|
||||
const portsStr = this.formatPortsForSearch(route.match.ports);
|
||||
if (portsStr.includes(q)) return true;
|
||||
|
||||
// Path
|
||||
if (route.match.path?.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Client IPs
|
||||
if (route.match.clientIp?.some((ip) => ip.includes(q))) return true;
|
||||
|
||||
// Targets
|
||||
if (route.action.targets) {
|
||||
for (const t of route.action.targets) {
|
||||
const hosts = Array.isArray(t.host) ? t.host : [t.host];
|
||||
if (hosts.some((h) => h.toLowerCase().includes(q))) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (route.tags?.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private formatPortsForSearch(ports: import('./sz-route-card.js').TPortRange): string {
|
||||
if (typeof ports === 'number') return String(ports);
|
||||
if (Array.isArray(ports)) {
|
||||
return ports
|
||||
.map((p) => (typeof p === 'number' ? String(p) : `${p.from}-${p.to}`))
|
||||
.join(' ');
|
||||
}
|
||||
return String(ports);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
outline: none;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#2563eb', '#3b82f6')};
|
||||
}
|
||||
|
||||
.chip-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||
border-color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid sz-route-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const filtered = this.filteredRoutes;
|
||||
|
||||
return html`
|
||||
<div class="filter-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="Search routes by domain, IP, port, path, or tag..."
|
||||
.value=${this.searchQuery}
|
||||
@input=${(e: InputEvent) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
/>
|
||||
<div class="chip-group">
|
||||
${(['all', 'forward', 'socket-handler'] as const).map(
|
||||
(type) => html`
|
||||
<button
|
||||
class="chip ${this.actionFilter === type ? 'active' : ''}"
|
||||
@click=${() => {
|
||||
this.actionFilter = type;
|
||||
}}
|
||||
>
|
||||
${type === 'all' ? 'All' : type === 'forward' ? 'Forward' : 'Socket Handler'}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div class="chip-group">
|
||||
${(['all', 'enabled', 'disabled'] as const).map(
|
||||
(status) => html`
|
||||
<button
|
||||
class="chip ${this.enabledFilter === status ? 'active' : ''}"
|
||||
@click=${() => {
|
||||
this.enabledFilter = status;
|
||||
}}
|
||||
>
|
||||
${status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-count">
|
||||
Showing ${filtered.length} of ${this.routes.length} routes
|
||||
</div>
|
||||
|
||||
${filtered.length > 0
|
||||
? html`
|
||||
<div class="grid">
|
||||
${filtered.map(
|
||||
(route) => html`
|
||||
<sz-route-card
|
||||
.route=${route}
|
||||
.showActions=${this.showActionsFilter?.(route) ?? false}
|
||||
@click=${() => this.handleRouteClick(route)}
|
||||
></sz-route-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🔍</div>
|
||||
<div>No routes match your filters</div>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private handleRouteClick(route: IRouteConfig) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('route-click', {
|
||||
detail: route,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ export interface IServiceConfig {
|
||||
memoryLimit: string;
|
||||
restartPolicy: 'always' | 'on-failure' | 'never';
|
||||
networkMode: string;
|
||||
enableMongoDB: boolean;
|
||||
enableS3: boolean;
|
||||
enableClickHouse: boolean;
|
||||
}
|
||||
|
||||
@customElement('sz-service-create-view')
|
||||
@@ -104,6 +107,15 @@ export class SzServiceCreateView extends DeesElement {
|
||||
@state()
|
||||
private accessor showAdvanced: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor enableMongoDB: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor enableS3: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor enableClickHouse: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
@@ -312,6 +324,105 @@ export class SzServiceCreateView extends DeesElement {
|
||||
accent-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.platform-toggle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.platform-toggle-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
border-radius: 8px;
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
.platform-toggle-item:has(input:checked) {
|
||||
background: ${cssManager.bdTheme('#eff6ff', 'rgba(59, 130, 246, 0.1)')};
|
||||
}
|
||||
|
||||
.platform-toggle-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.platform-toggle-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.platform-toggle-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.platform-toggle-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.platform-toggle-desc {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
border-radius: 12px;
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -536,6 +647,81 @@ export class SzServiceCreateView extends DeesElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Platform Services -->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
||||
</svg>
|
||||
Platform Services
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom: 12px;">
|
||||
Enable managed infrastructure services for this deployment. Resources are automatically provisioned and connection details injected as environment variables.
|
||||
</div>
|
||||
<div class="platform-toggle-list">
|
||||
<label class="platform-toggle-item">
|
||||
<div class="platform-toggle-info">
|
||||
<div class="platform-toggle-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="platform-toggle-name">MongoDB</div>
|
||||
<div class="platform-toggle-desc">Document database with auto-provisioned credentials</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${this.enableMongoDB}
|
||||
@change=${(e: Event) => this.enableMongoDB = (e.target as HTMLInputElement).checked}
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="platform-toggle-item">
|
||||
<div class="platform-toggle-info">
|
||||
<div class="platform-toggle-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 16.5c0 .38-.21.71-.53.88l-7.9 4.44c-.16.12-.36.18-.57.18-.21 0-.41-.06-.57-.18l-7.9-4.44A.991.991 0 0 1 3 16.5v-9c0-.38.21-.71.53-.88l7.9-4.44c.16-.12.36-.18.57-.18.21 0 .41.06.57.18l7.9 4.44c.32.17.53.5.53.88v9z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="platform-toggle-name">S3 Storage (MinIO)</div>
|
||||
<div class="platform-toggle-desc">Object storage bucket with auto-provisioned access keys</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${this.enableS3}
|
||||
@change=${(e: Event) => this.enableS3 = (e.target as HTMLInputElement).checked}
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="platform-toggle-item">
|
||||
<div class="platform-toggle-info">
|
||||
<div class="platform-toggle-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="2" y="2" width="6" height="20"/><rect x="9" y="7" width="6" height="15"/><rect x="16" y="12" width="6" height="10"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="platform-toggle-name">ClickHouse</div>
|
||||
<div class="platform-toggle-desc">Analytics database with auto-provisioned credentials</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
?checked=${this.enableClickHouse}
|
||||
@change=${(e: Event) => this.enableClickHouse = (e.target as HTMLInputElement).checked}
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options Toggle -->
|
||||
<button
|
||||
class="toggle-advanced ${this.showAdvanced ? 'open' : ''}"
|
||||
@@ -750,6 +936,9 @@ export class SzServiceCreateView extends DeesElement {
|
||||
memoryLimit: this.memoryLimit,
|
||||
restartPolicy: this.restartPolicy,
|
||||
networkMode: this.networkMode,
|
||||
enableMongoDB: this.enableMongoDB,
|
||||
enableS3: this.enableS3,
|
||||
enableClickHouse: this.enableClickHouse,
|
||||
};
|
||||
|
||||
this.dispatchEvent(new CustomEvent('create-service', {
|
||||
@@ -771,5 +960,8 @@ export class SzServiceCreateView extends DeesElement {
|
||||
this.restartPolicy = 'always';
|
||||
this.networkMode = 'bridge';
|
||||
this.showAdvanced = false;
|
||||
this.enableMongoDB = false;
|
||||
this.enableS3 = false;
|
||||
this.enableClickHouse = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
|
||||
import type { IExecutionEnvironment } from '@design.estate/dees-catalog';
|
||||
|
||||
import './sz-stat-card.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-service-detail-view': SzServiceDetailView;
|
||||
@@ -218,31 +216,97 @@ export class SzServiceDetailView extends DeesElement {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
.card-header {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.card-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-text-muted);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.tile-button.danger {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.tile-button.danger:hover {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239, 68, 68, 0.1)')};
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@@ -396,68 +460,6 @@ export class SzServiceDetailView extends DeesElement {
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logs-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stream-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: ${cssManager.bdTheme('#2563eb', '#3b82f6')};
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stream-button.streaming {
|
||||
background: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
padding: 16px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 2px 0;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-logs {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
@@ -580,12 +582,11 @@ export class SzServiceDetailView extends DeesElement {
|
||||
|
||||
<div class="content">
|
||||
<div class="main-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">Service Details</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Service Details</span>
|
||||
</div>
|
||||
<button class="action-button" style="width: auto; padding: 6px 12px;" @click=${() => this.handleEdit()}>Edit</button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="detail-list">
|
||||
@@ -619,43 +620,30 @@ export class SzServiceDetailView extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="card-footer">
|
||||
<button class="tile-button primary" @click=${() => this.handleEdit()}>Edit</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="logs-header" style="width: 100%;">
|
||||
<div>
|
||||
<div class="card-title">Logs</div>
|
||||
<div class="card-subtitle">Container logs</div>
|
||||
</div>
|
||||
<div class="logs-actions">
|
||||
<button class="stream-button ${this.streaming ? 'streaming' : ''}" @click=${() => this.toggleStreaming()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
${this.streaming
|
||||
? html`<rect x="6" y="6" width="12" height="12" rx="1"/>`
|
||||
: html`<polygon points="5,3 19,12 5,21"/>`
|
||||
}
|
||||
</svg>
|
||||
${this.streaming ? 'Stop' : 'Stream'}
|
||||
</button>
|
||||
<button class="clear-button" @click=${() => this.handleClearLogs()}>Clear logs</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-container">
|
||||
${this.logs.length > 0 ? this.logs.map(log => html`
|
||||
<div class="log-entry">${log.timestamp} ${log.message}</div>
|
||||
`) : html`
|
||||
<div class="empty-logs">Click "Stream" to start live log streaming</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<dees-chart-log
|
||||
.label=${'Service Logs'}
|
||||
.logEntries=${this.logs.map(log => ({
|
||||
timestamp: log.timestamp?.includes?.('T') ? log.timestamp : new Date(log.timestamp || Date.now()).toISOString(),
|
||||
level: (log.level || 'info') as 'debug' | 'info' | 'warn' | 'error',
|
||||
message: log.message,
|
||||
}))}
|
||||
.autoScroll=${true}
|
||||
.maxEntries=${2000}
|
||||
.showMetrics=${true}
|
||||
></dees-chart-log>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">Live stats</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Live stats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="stats-grid">
|
||||
@@ -678,13 +666,13 @@ export class SzServiceDetailView extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">Actions</div>
|
||||
<div class="card-subtitle">Manage service state</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Actions</span>
|
||||
<span class="card-subtitle">Manage service state</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
@@ -704,13 +692,13 @@ export class SzServiceDetailView extends DeesElement {
|
||||
<button class="action-button danger" @click=${() => this.handleAction('delete')}>Delete Service</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">Image Source</div>
|
||||
<div class="card-subtitle">${this.service.registry === 'Docker Hub' ? 'External container registry' : 'Onebox registry'}</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Image Source</span>
|
||||
<span class="card-subtitle">${this.service.registry === 'Docker Hub' ? 'External container registry' : 'Onebox registry'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
@@ -729,21 +717,14 @@ export class SzServiceDetailView extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">Backups</div>
|
||||
<div class="card-subtitle">Create and manage service backups</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="card-header">
|
||||
<div class="card-heading">
|
||||
<span class="card-title">Backups</span>
|
||||
<span class="card-subtitle">Create and manage service backups</span>
|
||||
</div>
|
||||
<button class="action-button" style="width: auto; padding: 6px 12px;" @click=${() => this.handleCreateBackup()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Backup
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="backup-list">
|
||||
@@ -778,7 +759,16 @@ export class SzServiceDetailView extends DeesElement {
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="card-footer">
|
||||
<button class="tile-button primary" @click=${() => this.handleCreateBackup()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Backup
|
||||
</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -65,71 +65,94 @@ export class SzServicesBackupsView extends DeesElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
dees-tile {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-info {
|
||||
.section-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-text-muted);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.section-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||
border: none;
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
opacity: 0.9;
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@@ -253,28 +276,11 @@ export class SzServicesBackupsView extends DeesElement {
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-info">
|
||||
<div class="section-title">Backup Schedules</div>
|
||||
<div class="section-subtitle">Configure automated backup schedules for your services</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="action-button" @click=${() => this.handleImport()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import Backup
|
||||
</button>
|
||||
<button class="action-button primary" @click=${() => this.handleCreateSchedule()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Schedule
|
||||
</button>
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Backup Schedules</span>
|
||||
<span class="section-subtitle">Configure automated backup schedules for your services</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-header schedules-header">
|
||||
@@ -318,13 +324,30 @@ export class SzServicesBackupsView extends DeesElement {
|
||||
</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button" @click=${() => this.handleImport()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import Backup
|
||||
</button>
|
||||
<button class="tile-button primary" @click=${() => this.handleCreateSchedule()}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-info">
|
||||
<div class="section-title">All Backups</div>
|
||||
<div class="section-subtitle">Browse and manage all backups across services</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">All Backups</span>
|
||||
<span class="section-subtitle">Browse and manage all backups across services</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-header backups-header">
|
||||
@@ -358,7 +381,7 @@ export class SzServicesBackupsView extends DeesElement {
|
||||
</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,28 +72,98 @@ export class SzSettingsView extends DeesElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
dees-tile {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 16px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-text-muted);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -224,161 +294,151 @@ export class SzSettingsView extends DeesElement {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
border: none;
|
||||
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Appearance</div>
|
||||
<div class="section-subtitle">Customize the look and feel</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label-group">
|
||||
<span class="form-label">Dark Mode</span>
|
||||
<span class="form-hint">Toggle dark mode on or off</span>
|
||||
</div>
|
||||
<div class="toggle-switch ${this.settings.darkMode ? 'active' : ''}" @click=${() => this.toggleDarkMode()}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Cloudflare Integration</div>
|
||||
<div class="section-subtitle">Configure Cloudflare API for DNS management</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="form-group">
|
||||
<div class="field-label">API Token</div>
|
||||
<input type="password" placeholder="Enter Cloudflare API token" .value=${this.settings.cloudflareToken} @input=${(e: Event) => this.updateSetting('cloudflareToken', (e.target as HTMLInputElement).value)}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="field-label">Zone ID (Optional)</div>
|
||||
<input type="text" placeholder="Default zone ID" .value=${this.settings.cloudflareZoneId} @input=${(e: Event) => this.updateSetting('cloudflareZoneId', (e.target as HTMLInputElement).value)}>
|
||||
</div>
|
||||
<div class="form-hint">Get your API token from the Cloudflare dashboard with DNS edit permissions.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">SSL/TLS Settings</div>
|
||||
<div class="section-subtitle">Configure certificate management</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label-group">
|
||||
<span class="form-label">Auto-Renew Certificates</span>
|
||||
<span class="form-hint">Automatically renew certificates before expiry</span>
|
||||
</div>
|
||||
<div class="toggle-switch ${this.settings.autoRenewCerts ? 'active' : ''}" @click=${() => this.toggleSetting('autoRenewCerts')}></div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 16px;">
|
||||
<div class="field-label">Renewal Threshold (days)</div>
|
||||
<input type="number" .value=${String(this.settings.renewalThreshold)} @input=${(e: Event) => this.updateSetting('renewalThreshold', parseInt((e.target as HTMLInputElement).value))}>
|
||||
<div class="form-hint">Renew certificates when they have fewer than this many days remaining.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="field-label">ACME Email</div>
|
||||
<input type="email" placeholder="admin@example.com" .value=${this.settings.acmeEmail} @input=${(e: Event) => this.updateSetting('acmeEmail', (e.target as HTMLInputElement).value)}>
|
||||
<div class="form-hint">Email address for Let's Encrypt notifications.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Network Settings</div>
|
||||
<div class="section-subtitle">Configure network and proxy settings</div>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<div class="form-group">
|
||||
<div class="field-label">HTTP Port</div>
|
||||
<input type="number" .value=${String(this.settings.httpPort)} @input=${(e: Event) => this.updateSetting('httpPort', parseInt((e.target as HTMLInputElement).value))}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="field-label">HTTPS Port</div>
|
||||
<input type="number" .value=${String(this.settings.httpsPort)} @input=${(e: Event) => this.updateSetting('httpsPort', parseInt((e.target as HTMLInputElement).value))}>
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Appearance</span>
|
||||
<span class="section-subtitle">Customize the look and feel</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label-group">
|
||||
<span class="form-label">Force HTTPS</span>
|
||||
<span class="form-hint">Redirect all HTTP traffic to HTTPS</span>
|
||||
</div>
|
||||
<div class="toggle-switch ${this.settings.forceHttps ? 'active' : ''}" @click=${() => this.toggleSetting('forceHttps')}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Account</div>
|
||||
<div class="section-subtitle">Manage your account settings</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="field-label">Current User</div>
|
||||
<div style="font-size: 14px; color: ${cssManager.bdTheme('#18181b', '#fafafa')};">${this.currentUser || 'Unknown'}</div>
|
||||
</div>
|
||||
<div class="password-section">
|
||||
<div class="password-title">Change Password</div>
|
||||
<div class="password-fields">
|
||||
<div>
|
||||
<div class="field-label">Current Password</div>
|
||||
<input type="password" id="currentPassword">
|
||||
<div class="section-content">
|
||||
<div class="form-row">
|
||||
<div class="form-label-group">
|
||||
<span class="form-label">Dark Mode</span>
|
||||
<span class="form-hint">Toggle dark mode on or off</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">New Password</div>
|
||||
<input type="password" id="newPassword">
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">Confirm Password</div>
|
||||
<input type="password" id="confirmPassword">
|
||||
</div>
|
||||
<button class="button secondary" style="width: fit-content;" @click=${() => this.handleChangePassword()}>Update Password</button>
|
||||
<div class="toggle-switch ${this.settings.darkMode ? 'active' : ''}" @click=${() => this.toggleDarkMode()}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<div class="actions">
|
||||
<button class="button secondary" @click=${() => this.handleReset()}>Reset</button>
|
||||
<button class="button primary" @click=${() => this.handleSave()}>Save Settings</button>
|
||||
</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Cloudflare Integration</span>
|
||||
<span class="section-subtitle">Configure Cloudflare API for DNS management</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="input-group">
|
||||
<div class="form-group">
|
||||
<div class="field-label">API Token</div>
|
||||
<input type="password" placeholder="Enter Cloudflare API token" .value=${this.settings.cloudflareToken} @input=${(e: Event) => this.updateSetting('cloudflareToken', (e.target as HTMLInputElement).value)}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="field-label">Zone ID (Optional)</div>
|
||||
<input type="text" placeholder="Default zone ID" .value=${this.settings.cloudflareZoneId} @input=${(e: Event) => this.updateSetting('cloudflareZoneId', (e.target as HTMLInputElement).value)}>
|
||||
</div>
|
||||
<div class="form-hint">Get your API token from the Cloudflare dashboard with DNS edit permissions.</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">SSL/TLS Settings</span>
|
||||
<span class="section-subtitle">Configure certificate management</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="form-row">
|
||||
<div class="form-label-group">
|
||||
<span class="form-label">Auto-Renew Certificates</span>
|
||||
<span class="form-hint">Automatically renew certificates before expiry</span>
|
||||
</div>
|
||||
<div class="toggle-switch ${this.settings.autoRenewCerts ? 'active' : ''}" @click=${() => this.toggleSetting('autoRenewCerts')}></div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 16px;">
|
||||
<div class="field-label">Renewal Threshold (days)</div>
|
||||
<input type="number" .value=${String(this.settings.renewalThreshold)} @input=${(e: Event) => this.updateSetting('renewalThreshold', parseInt((e.target as HTMLInputElement).value))}>
|
||||
<div class="form-hint">Renew certificates when they have fewer than this many days remaining.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="field-label">ACME Email</div>
|
||||
<input type="email" placeholder="admin@example.com" .value=${this.settings.acmeEmail} @input=${(e: Event) => this.updateSetting('acmeEmail', (e.target as HTMLInputElement).value)}>
|
||||
<div class="form-hint">Email address for Let's Encrypt notifications.</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Network Settings</span>
|
||||
<span class="section-subtitle">Configure network and proxy settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="input-row">
|
||||
<div class="form-group">
|
||||
<div class="field-label">HTTP Port</div>
|
||||
<input type="number" .value=${String(this.settings.httpPort)} @input=${(e: Event) => this.updateSetting('httpPort', parseInt((e.target as HTMLInputElement).value))}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="field-label">HTTPS Port</div>
|
||||
<input type="number" .value=${String(this.settings.httpsPort)} @input=${(e: Event) => this.updateSetting('httpsPort', parseInt((e.target as HTMLInputElement).value))}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-label-group">
|
||||
<span class="form-label">Force HTTPS</span>
|
||||
<span class="form-hint">Redirect all HTTP traffic to HTTPS</span>
|
||||
</div>
|
||||
<div class="toggle-switch ${this.settings.forceHttps ? 'active' : ''}" @click=${() => this.toggleSetting('forceHttps')}></div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Account</span>
|
||||
<span class="section-subtitle">Manage your account settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="form-group">
|
||||
<div class="field-label">Current User</div>
|
||||
<div style="font-size: 14px; color: ${cssManager.bdTheme('#18181b', '#fafafa')};">${this.currentUser || 'Unknown'}</div>
|
||||
</div>
|
||||
<div class="password-section">
|
||||
<div class="password-title">Change Password</div>
|
||||
<div class="password-fields">
|
||||
<div>
|
||||
<div class="field-label">Current Password</div>
|
||||
<input type="password" id="currentPassword">
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">New Password</div>
|
||||
<input type="password" id="newPassword">
|
||||
</div>
|
||||
<div>
|
||||
<div class="field-label">Confirm Password</div>
|
||||
<input type="password" id="confirmPassword">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button" @click=${() => this.handleChangePassword()}>Update Password</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<dees-tile>
|
||||
<div class="section-content" style="padding: 12px 16px; text-align: center; color: var(--dees-color-text-muted); font-size: 12px;">
|
||||
Save your changes or reset to defaults.
|
||||
</div>
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button" @click=${() => this.handleReset()}>Reset</button>
|
||||
<button class="tile-button primary" @click=${() => this.handleSave()}>Save Settings</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sz-stat-card': SzStatCard;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sz-stat-card')
|
||||
export class SzStatCard extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<style>
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
}
|
||||
</style>
|
||||
<div class="demo-grid">
|
||||
<sz-stat-card
|
||||
label="Total Services"
|
||||
value="7"
|
||||
icon="server"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Running"
|
||||
value="7"
|
||||
icon="check"
|
||||
variant="success"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Stopped"
|
||||
value="0"
|
||||
icon="stop"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Docker"
|
||||
value="Running"
|
||||
icon="container"
|
||||
variant="success"
|
||||
valueBadge
|
||||
></sz-stat-card>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static demoGroups = ['Dashboard'];
|
||||
|
||||
@property({ type: String })
|
||||
public accessor label: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor value: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor icon: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public accessor variant: 'default' | 'success' | 'warning' | 'error' = 'default';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor valueBadge: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: all 200ms ease;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0,0,0,0.05)', 'rgba(0,0,0,0.2)')};
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.value.success {
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.value.warning {
|
||||
color: ${cssManager.bdTheme('#ca8a04', '#facc15')};
|
||||
}
|
||||
|
||||
.value.error {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
background: ${cssManager.bdTheme('#fef9c3', 'rgba(250, 204, 21, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#ca8a04', '#facc15')};
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239, 68, 68, 0.2)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.badge.default {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const valueClass = this.valueBadge ? `badge ${this.variant}` : `value ${this.variant}`;
|
||||
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<span class="label">${this.label}</span>
|
||||
${this.renderIcon()}
|
||||
</div>
|
||||
<div class="${valueClass}">${this.value}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIcon(): TemplateResult {
|
||||
const icons: Record<string, TemplateResult> = {
|
||||
server: html`<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`,
|
||||
check: html`<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
|
||||
stop: html`<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="10" y1="15" x2="10" y2="9"></line><line x1="14" y1="15" x2="14" y2="9"></line></svg>`,
|
||||
container: html`<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>`,
|
||||
};
|
||||
|
||||
return icons[this.icon] || html``;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import './sz-stat-card.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -54,54 +53,51 @@ export class SzStatusGridCluster extends DeesElement {
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.grid > * {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private get tiles(): IStatsTile[] {
|
||||
return [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Services',
|
||||
value: this.stats.totalServices,
|
||||
type: 'number',
|
||||
icon: 'lucide:server',
|
||||
},
|
||||
{
|
||||
id: 'running',
|
||||
title: 'Running',
|
||||
value: this.stats.running,
|
||||
type: 'number',
|
||||
icon: 'lucide:check',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'stopped',
|
||||
title: 'Stopped',
|
||||
value: this.stats.stopped,
|
||||
type: 'number',
|
||||
icon: 'lucide:circleStop',
|
||||
color: this.stats.stopped > 0 ? '#f59e0b' : undefined,
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
title: 'Docker',
|
||||
value: this.stats.dockerStatus === 'running' ? 'Running' : 'Stopped',
|
||||
type: 'text',
|
||||
icon: 'lucide:container',
|
||||
color: this.stats.dockerStatus === 'running' ? '#22c55e' : '#ef4444',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="grid">
|
||||
<sz-stat-card
|
||||
label="Total Services"
|
||||
value="${this.stats.totalServices}"
|
||||
icon="server"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Running"
|
||||
value="${this.stats.running}"
|
||||
icon="check"
|
||||
variant="success"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Stopped"
|
||||
value="${this.stats.stopped}"
|
||||
icon="stop"
|
||||
variant="${this.stats.stopped > 0 ? 'warning' : 'default'}"
|
||||
></sz-stat-card>
|
||||
<sz-stat-card
|
||||
label="Docker"
|
||||
value="${this.stats.dockerStatus === 'running' ? 'Running' : 'Stopped'}"
|
||||
icon="container"
|
||||
variant="${this.stats.dockerStatus === 'running' ? 'success' : 'error'}"
|
||||
valueBadge
|
||||
></sz-stat-card>
|
||||
</div>
|
||||
<dees-statsgrid
|
||||
.tiles=${this.tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,56 +55,94 @@ export class SzTokensView extends DeesElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 8px;
|
||||
dees-tile {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-info {
|
||||
.section-heading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--dees-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
font-size: 12px;
|
||||
color: var(--dees-color-text-muted);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.create-button {
|
||||
display: inline-flex;
|
||||
.section-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tile-button {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.create-button:hover {
|
||||
opacity: 0.9;
|
||||
.tile-button:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tile-button:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.tile-button.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.token-list {
|
||||
@@ -192,45 +230,18 @@ export class SzTokensView extends DeesElement {
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.empty-button:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-info">
|
||||
<div class="section-title">Global Tokens</div>
|
||||
<div class="section-subtitle">Tokens that can push images to multiple services</div>
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">Global Tokens</span>
|
||||
<span class="section-subtitle">Tokens that can push images to multiple services</span>
|
||||
</div>
|
||||
<button class="create-button" @click=${() => this.handleCreate('global')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
${this.globalTokens.length > 0 ? html`
|
||||
<div class="token-list">
|
||||
@@ -239,25 +250,26 @@ export class SzTokensView extends DeesElement {
|
||||
` : html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No global tokens created</div>
|
||||
<button class="empty-button" @click=${() => this.handleCreate('global')}>Create Global Token</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<div class="section-info">
|
||||
<div class="section-title">CI Tokens (Service-specific)</div>
|
||||
<div class="section-subtitle">Tokens tied to individual services for CI/CD pipelines</div>
|
||||
</div>
|
||||
<button class="create-button" @click=${() => this.handleCreate('ci')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button primary" @click=${() => this.handleCreate('global')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
|
||||
<dees-tile>
|
||||
<div slot="header" class="section-header">
|
||||
<div class="section-heading">
|
||||
<span class="section-title">CI Tokens (Service-specific)</span>
|
||||
<span class="section-subtitle">Tokens tied to individual services for CI/CD pipelines</span>
|
||||
</div>
|
||||
</div>
|
||||
${this.ciTokens.length > 0 ? html`
|
||||
<div class="token-list">
|
||||
${this.ciTokens.map(token => this.renderToken(token))}
|
||||
@@ -265,10 +277,18 @@ export class SzTokensView extends DeesElement {
|
||||
` : html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">No CI tokens created</div>
|
||||
<button class="empty-button" @click=${() => this.handleCreate('ci')}>Create CI Token</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<div slot="footer" class="section-footer">
|
||||
<button class="tile-button primary" @click=${() => this.handleCreate('ci')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,18 @@ export class SzDemoAppShell extends DeesElement {
|
||||
iconName: 'lucide:Key',
|
||||
content: 'sz-demo-view-tokens',
|
||||
},
|
||||
{
|
||||
id: 'mta',
|
||||
name: 'Email / MTA',
|
||||
iconName: 'lucide:Mail',
|
||||
content: 'sz-demo-view-mta',
|
||||
},
|
||||
{
|
||||
id: 'routes',
|
||||
name: 'Routes',
|
||||
iconName: 'lucide:Route',
|
||||
content: 'sz-demo-view-routes',
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
@@ -147,7 +159,7 @@ export class SzDemoAppShell extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Infrastructure',
|
||||
views: ['services', 'network', 'registries'],
|
||||
views: ['services', 'network', 'registries', 'mta', 'routes'],
|
||||
},
|
||||
{
|
||||
name: 'Administration',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user