Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ in `/frontend`

## License

The OpenRemote Machine Learning Forecast Service is distributed under [AGPL-3.0-or-later](LICENSE.txt).
The OpenRemote ML Forecast Service is distributed under [AGPL-3.0-or-later](LICENSE.txt).

```
Copyright 2025, OpenRemote Inc.
Expand Down
42 changes: 24 additions & 18 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ ARG ML_WEB_ROOT_PATH
ARG ML_OR_KEYCLOAK_URL
ARG ML_OR_URL

# Run the front-end bundle in production mode
RUN ML_SERVICE_URL=${ML_SERVICE_URL:-/services/ml-forecast} \
ML_WEB_ROOT_PATH=${ML_WEB_ROOT_PATH:-/services/ml-forecast/ui} \
ML_OR_KEYCLOAK_URL=${ML_OR_KEYCLOAK_URL:-/auth} \
Expand All @@ -31,24 +32,27 @@ RUN echo "Starting Python build phase..."
WORKDIR /app

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
PYTHONDONTWRITEBYTECODE=1

RUN echo "Installing Python build dependencies..."
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
&& rm -rf /var/lib/apt/lists/*

# Copy project files
COPY pyproject.toml README.md ./
# Install uv
RUN pip install uv

# Copy the necessary project files
COPY pyproject.toml uv.lock README.md ./
COPY src/ ./src/
COPY scripts/ ./scripts/
COPY packages/ ./packages/

# Install project dependencies and clean up
# Install project dependencies using uv
RUN echo "Installing Python project dependencies..."
RUN pip install --no-cache-dir . \
RUN uv sync --no-cache-dir \
&& apt-get remove -y build-essential \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* \
Expand All @@ -67,27 +71,29 @@ ARG ML_ENVIRONMENT
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app/src \
# Use the ARG, falling back to a default if not provided during build or runtime
ML_ENVIRONMENT=${ML_ENVIRONMENT:-production}

# Install runtime dependencies and clean up
# Install any runtime dependencies
RUN echo "Installing runtime dependencies..."
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
RUN echo "Copying Python packages from builder..."
COPY --from=builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/
COPY --from=builder /usr/local/bin/ /usr/local/bin/
# Install uv package manager
RUN pip install uv

# Copy application code
COPY pyproject.toml ./
COPY pyproject.toml uv.lock README.md ./
COPY src/ ./src/
COPY scripts/ ./scripts/
COPY packages/ ./packages/

# Install project dependencies using uv
RUN echo "Installing Python project dependencies..."
RUN uv sync --no-cache-dir

# Copy frontend build and clean up
# Copy frontend build artifacts
RUN echo "Copying frontend build artifacts..."
COPY --from=frontend-builder /app/frontend/dist/ ./deployment/web/dist/
RUN rm -rf /app/frontend
Expand All @@ -100,9 +106,9 @@ RUN mkdir -p ./deployment/data/models ./deployment/data/configs
EXPOSE 8000

# Add health check
HEALTHCHECK --interval=5s --timeout=5s --start-period=30s --retries=3 CMD curl --fail --silent http://localhost:8000/ui || exit 1
HEALTHCHECK --interval=10s --timeout=10s --start-period=30s --retries=3 CMD curl --fail --silent http://localhost:8000/ui || exit 1

RUN echo "Container setup complete! Starting application..."

# Run the application
CMD ["python", "-m", "service_ml_forecast.main"]
# Run the application using uv run to ensure virtual environment is activated
CMD ["uv", "run", "python", "-m", "service_ml_forecast.main"]
1 change: 0 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@

/* Outlet */
#outlet {
padding: 20px;
min-width: fit-content;
}
</style>
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"serve": "cross-env rspack serve",
"build:dev": "cross-env ML_OR_KEYCLOAK_URL=${ML_OR_KEYCLOAK_URL:-http://localhost:8081/auth} ML_OR_URL=${ML_OR_URL:-http://localhost:8080} ML_SERVICE_URL=${ML_SERVICE_URL:-/services/ml-forecast} ML_WEB_ROOT_PATH=${ML_WEB_ROOT_PATH:-/services/ml-forecast/ui} rspack build --mode development",
"build:prod": "cross-env ML_OR_KEYCLOAK_URL=${ML_OR_KEYCLOAK_URL:-/auth} ML_OR_URL=${ML_OR_URL} ML_SERVICE_URL=${ML_SERVICE_URL:-/services/ml-forecast} ML_WEB_ROOT_PATH=${ML_WEB_ROOT_PATH:-/services/ml-forecast/ui} rspack build --mode production",
"build:analyze": "rspack build --mode production --analyze",
"lint": "eslint && prettier . --check",
Expand Down
108 changes: 73 additions & 35 deletions frontend/src/components/breadcrumb-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import { css, html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Router, RouterLocation } from '@vaadin/router';
import { getRootPath } from '../common/util';
import { IS_EMBEDDED } from '../common/constants';

/**
* Represents a part of the breadcrumb navigation
*/
interface BreadcrumbPart {
path: string;
name: string;
icon?: string;
}

/**
Expand All @@ -42,7 +44,31 @@ export class BreadcrumbNav extends LitElement {
align-items: center;
gap: 8px;
margin-bottom: 16px;
width: fit-content;
width: 100%;
justify-content: space-between;
}

.breadcrumb-container {
display: flex;
align-items: center;
gap: 8px;
}

.realm-badge {
background-color: var(--or-app-color4);
color: white;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-left: auto;
min-width: 60px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}

a {
Expand All @@ -53,7 +79,7 @@ export class BreadcrumbNav extends LitElement {
gap: 4px;
--or-icon-width: 16px;
--or-icon-height: 16px;
max-width: 200px;
max-width: 300px;
}

a:hover {
Expand All @@ -63,7 +89,7 @@ export class BreadcrumbNav extends LitElement {
span[aria-current='page'] {
color: rgba(0, 0, 0, 0.87);
font-weight: 500;
max-width: 200px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
Expand All @@ -86,26 +112,17 @@ export class BreadcrumbNav extends LitElement {

protected readonly rootPath = getRootPath();

protected get HOME_LINK(): BreadcrumbPart {
return {
path: `${this.rootPath}/${this.realm}/configs`,
name: 'ML Forecast Service'
};
}

protected readonly MAX_TEXT_LENGTH = 20;
protected readonly MAX_TEXT_LENGTH = 40;

willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has('realm') && this.realm) {
// Trigger location change event
const location: Partial<RouterLocation> = {
pathname: `${this.rootPath}/${this.realm}/configs`,
params: {
realm: this.realm
}
};

// Update the breadcrumbs and title
this.updateBreadcrumbs(location as RouterLocation);
}
}
Expand All @@ -115,7 +132,6 @@ export class BreadcrumbNav extends LitElement {
*/
protected readonly handleLocationChange = (event: CustomEvent<{ location: RouterLocation }>) => {
const location = event.detail.location;
// Update the breadcrumbs and title
this.updateBreadcrumbs(location);
};

Expand All @@ -136,26 +152,40 @@ export class BreadcrumbNav extends LitElement {
const parts: BreadcrumbPart[] = [];
const { pathname, params } = location;

// Add Smartcity part (realm)
if (this.realm) {
parts.push({
path: `${this.rootPath}/${this.realm}/configs`,
name: this.realm.charAt(0).toUpperCase() + this.realm.slice(1)
});
const homePart = {
path: `${this.rootPath}/${this.realm}/configs`,
name: 'ML Forecast Service',
icon: 'puzzle'
};

// If we are not embedded, add the home part
if (!IS_EMBEDDED) {
parts.push(homePart);
}

const configsPart = {
path: `${this.rootPath}/${this.realm}/configs`,
name: 'Configurations'
};

// Add Configs part
if (pathname.includes('/configs')) {
parts.push({
path: `${this.rootPath}/${this.realm}/configs`,
name: 'Configs'
});
parts.push(configsPart);

// Add specific config part if we're on a config page
if (params.id) {
// Handle config editor page
const isExistingConfig = params.id && !pathname.includes('/new');
if (isExistingConfig) {
parts.push({
path: `${this.rootPath}/${this.realm}/configs/${params.id}`,
name: params.id === 'new' ? 'New Config' : `${params.id}`
name: `${params.id}`
});
}

const isNewConfig = pathname.includes('/new');
if (isNewConfig) {
parts.push({
path: `${this.rootPath}/${this.realm}/configs/new`,
name: 'New'
});
}
}
Expand All @@ -173,15 +203,18 @@ export class BreadcrumbNav extends LitElement {
/**
* Renders a single breadcrumb item
*/
protected renderBreadcrumbItem(part: BreadcrumbPart, readonly: boolean) {
protected renderBreadcrumbItem(part: BreadcrumbPart, readonly: boolean, isFirst: boolean) {
const truncatedName = this.truncateText(part.name);

const icon = part.icon ? html`<or-icon icon=${part.icon}></or-icon>` : html``;

return html`
<span aria-hidden="true">&gt;</span>
${!isFirst ? html`<span aria-hidden="true">&gt;</span>` : html``}
${readonly
? html`<span aria-current="page">${truncatedName}</span>`
: html`
<a href="${part.path}" @click=${(e: MouseEvent) => this.handleNavigation(e, part.path)}>
${icon}
<span class="truncate">${truncatedName}</span>
</a>
`}
Expand All @@ -197,15 +230,20 @@ export class BreadcrumbNav extends LitElement {
}

render() {
const truncatedHomeName = this.truncateText(this.HOME_LINK.name);
// Hide breadcrumbs if there's only one part
const shouldShowBreadcrumbs = this.parts.length > 1;
if (!shouldShowBreadcrumbs) {
return html``;
}

const realmBadge = IS_EMBEDDED ? html`` : html`<div class="realm-badge">${this.realm}</div>`;

return html`
<nav aria-label="breadcrumb">
<a href="${this.HOME_LINK.path}" @click=${(e: MouseEvent) => this.handleNavigation(e, this.HOME_LINK.path)}>
<or-icon icon="puzzle"></or-icon>
<span class="truncate">${truncatedHomeName}</span>
</a>
${this.parts.map((part, index) => this.renderBreadcrumbItem(part, index === this.parts.length - 1))}
<div class="breadcrumb-container">
${this.parts.map((part, index) => this.renderBreadcrumbItem(part, index === this.parts.length - 1, index === 0))}
</div>
${realmBadge}
</nav>
`;
}
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/pages/app-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,27 @@

import { createContext, provide } from '@lit/context';
import { PreventAndRedirectCommands, RouterLocation } from '@vaadin/router';
import { html, LitElement } from 'lit';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { setRealmTheme } from '../common/theme';
import { manager } from '@openremote/core';
import { ML_OR_URL } from '../common/constants';
import { IS_EMBEDDED, ML_OR_URL } from '../common/constants';

export const realmContext = createContext<string>(Symbol('realm'));

@customElement('app-layout')
export class AppLayout extends LitElement {
static get styles() {
const padding = IS_EMBEDDED ? '0 20px' : '20px';

return css`
:host {
display: block;
padding: ${unsafeCSS(padding)};
}
`;
}

// Provide the realm to all child elements
@provide({ context: realmContext })
@state()
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/pages/pages-config-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,16 @@ export class PageConfigList extends LitElement {
--or-icon-fill: var(--or-app-color3);
}
.title {
font-size: 18px;
font-weight: bold;
font-size: 14px;
font-weight: bolder;
display: flex;
flex-direction: row;
align-items: center;
color: var(--or-app-color3);
text-transform: uppercase;
line-height: 1em;
flex: 0 0 auto;
letter-spacing: 0.025em;
}
`;
}
Expand Down Expand Up @@ -166,8 +170,8 @@ export class PageConfigList extends LitElement {
<or-panel heading="">
<div class="config-header">
<div class="title-container">
<or-icon icon="chart-bell-curve"></or-icon>
<span class="title">Forecast Configurations</span>
<or-icon icon="chart-line"></or-icon>
<span class="title">Forecast configurations</span>
</div>

<or-mwc-input
Expand Down
Loading