Skip to content
Open
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
139 changes: 111 additions & 28 deletions Readme.md
Original file line number Diff line number Diff line change
@@ -1,52 +1,135 @@
# Dave

Dave is a dashboard for Docker, perfect for home servers. It dynamically updates it's list of links to apps based on `labels` set with Docker.
Dave is a dashboard for Docker and Kubernetes, perfect for home servers. It dynamically updates its list of links to apps based on `labels` set with Docker or `annotations` on Kubernetes Services.

## Docker compose setup

```
dave:
image: theknarf/dave
ports:
- 80:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
```yaml
dave:
image: theknarf/dave
ports:
- 80:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
```

Then for each service you want to add to the apps list of `dave` add the following labels:

```
helloworld:
image: theknarf/hello-world
ports:
- 81:80
labels:
- "dave.url=//localhost:81/"
- "dave.name=Hello World"
restart: unless-stopped
```yaml
helloworld:
image: theknarf/hello-world
ports:
- 81:80
labels:
- "dave.url=//localhost:81/"
- "dave.name=Hello World"
restart: unless-stopped
```

See full `docker-compose` examples in the [examples folder](./examples).

## Enviroment variables and labels
## Kubernetes setup

Dave can also run in Kubernetes and discover services via annotations.

You can set the following enviroment variables on the Docker image `theknarf/dave`:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: dave
spec:
replicas: 1
selector:
matchLabels:
app: dave
template:
metadata:
labels:
app: dave
spec:
serviceAccountName: dave
containers:
- name: dave
image: theknarf/dave
ports:
- containerPort: 80
env:
- name: DAVE_PROVIDER
value: "kubernetes"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: dave
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: dave-service-reader
rules:
- apiGroups: [""]
resources: ["services"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: dave-service-reader
subjects:
- kind: ServiceAccount
name: dave
namespace: default
roleRef:
kind: ClusterRole
name: dave-service-reader
apiGroup: rbac.authorization.k8s.io
```

Then for each service you want to show on the dashboard, add annotations:

```yaml
apiVersion: v1
kind: Service
metadata:
name: my-app
annotations:
dave.name: "My Application"
dave.url: "http://my-app.example.com"
dave.icon: "mdi:application"
spec:
selector:
app: my-app
ports:
- port: 80
```

## Environment variables

Variable|Default|Description
--------|-------|-----------
DAVE_PROVIDER|auto|Provider to use: `docker`, `kubernetes`, or `auto` (auto-detects)
bgcolor|#EDEEC0|Background color
textcolor|#433E0E|Text color
accentcolor|#553555|Accent color, used for url's
accentcolor|#553555|Accent color, used for urls
mdx||The markdown used for the dashboard
forceHttps|false|Redirect to `https`. Possible values `all`, `dave`, `false`.

Labels you can set on containers you want to show on the dashboard:
## Labels / Annotations

For Docker containers, use labels. For Kubernetes services, use annotations. The format is the same:

Name|Default|Description|Note
----|-------|-----------|----
`dave.name`|Container/Service name|Name to show on the dashboard.|
`dave.url`||URL to link to.|Set either `dave.url` or `dave.relativeSubdomain` but not both.
`dave.relativeSubdomain`||URL to link to, relative to the domain that the dashboard is served from.|Set either `dave.url` or `dave.relativeSubdomain` but not both.
`dave.icon`||The name of an icon, taken from [Iconify](https://iconify.design/).|Some containers have default icons

## Provider auto-detection

Label|Default|Description|Note
-----|-------|-----------|----
`dave.name`|Container name|Name to show on the dashboard.|
`dave.url`||Url to link to.|Set either `dave.url` or `dave.relativeSubdomain` but not both.
`dave.relativeSubdomain`||Url to link to, relative to the domain that the dashboard is served from.|Set either `dave.url` or `dave.relativeSubdomain` but not both.
`dave.icon`||The name of an icon, taken from [Iconify](https://iconify.design/).|Some containers have default icon
When `DAVE_PROVIDER` is set to `auto` (the default), Dave will:

1. Check if running inside Kubernetes (via `KUBERNETES_SERVICE_HOST` env var)
2. Fall back to Docker if `/var/run/docker.sock` exists
3. Default to Docker for backwards compatibility
10 changes: 2 additions & 8 deletions src/docker/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fallbackIcon from '../fallback-icon';
import { Docker } from 'node-docker-api';
import type { AppProps } from '../providers';

const fetch = (docker : Docker, path : string, callOverride = {}) => {
const call = {
Expand Down Expand Up @@ -51,14 +52,7 @@ interface Container {
Status: string;
};

export interface AppProps {
id: string;
icon: string;
name: string;
status: string;
url?: string;
relativeSubdomain?: string;
};
// AppProps is now exported from '../providers'

const processContainer = (containers : Container[]) : AppProps[] => {
return containers
Expand Down
112 changes: 112 additions & 0 deletions src/kubernetes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as https from 'https';
import * as fs from 'fs';
import fallbackIcon from '../fallback-icon';
import { AppProps } from '../providers';

interface DaveLabels {
name?: string;
url?: string;
relativeSubdomain?: string;
icon?: string;
}

interface V1Service {
metadata?: {
uid?: string;
name?: string;
namespace?: string;
annotations?: { [key: string]: string };
};
}

interface V1ServiceList {
items: V1Service[];
}

const processAnnotations = (annotations: { [key: string]: string } | undefined): DaveLabels => {
if (!annotations) return {};

return Object
.keys(annotations)
.filter(key => key.toLowerCase().startsWith('dave.'))
.map(key => ({
key: key.substring(5), // Removes 'dave.'
value: annotations[key],
}))
.reduce(
(acc: DaveLabels, { key, value }) => ({
...acc,
[key]: value,
}),
{}
);
};

const fetchFromKubernetes = async (path: string): Promise<any> => {
// In-cluster config paths
const tokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token';
const caPath = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt';
const namespaceFile = '/var/run/secrets/kubernetes.io/serviceaccount/namespace';

const host = process.env.KUBERNETES_SERVICE_HOST;
const port = process.env.KUBERNETES_SERVICE_PORT || '443';

if (!host) {
throw new Error('Not running in Kubernetes cluster');
}

const token = fs.readFileSync(tokenPath, 'utf8');
const ca = fs.readFileSync(caPath);

return new Promise((resolve, reject) => {
const options = {
hostname: host,
port: parseInt(port),
path,
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
ca,
};

const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(data));
} else {
reject(new Error(`Kubernetes API error: ${res.statusCode} - ${data}`));
}
});
});

req.on('error', reject);
req.end();
});
};

export const getServicesWithLabels = async (): Promise<AppProps[]> => {
const response = await fetchFromKubernetes('/api/v1/services') as V1ServiceList;
const services = response.items;

return services
.map((service: V1Service) => {
const annotations = processAnnotations(service.metadata?.annotations);
const name = service.metadata?.name || 'unknown';
const namespace = service.metadata?.namespace || 'default';

return {
id: service.metadata?.uid || `${namespace}-${name}`,
name: annotations.name || name,
icon: annotations.icon || fallbackIcon(name),
status: 'Running',
url: annotations.url || '',
relativeSubdomain: annotations.relativeSubdomain || '',
};
})
.filter(({ url, relativeSubdomain }: { url: string; relativeSubdomain: string }) => url !== '' || relativeSubdomain !== '')
.sort((first: AppProps, second: AppProps) => first.name.localeCompare(second.name));
};
6 changes: 3 additions & 3 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import MDX from '@mdx-js/runtime';
import App, { AppProps as IApp } from '../components/app';
import Grid from '../components/grid';
import useForceHttps, { ForceHttpsStatus, replaceUrlWithHttps } from '../force-https';
import { getContainersWithLabels, AppProps } from '../docker';
import { getApps, AppProps } from '../providers';
import { themeVars } from '../styles/index.css';
import 'inter-ui/Inter (web)/inter.css';
import { createInlineTheme } from '@vanilla-extract/dynamic';
Expand Down Expand Up @@ -73,7 +73,7 @@ const Home : React.FC<Props> = ({ colors, mdx, appData, forceHttps }) => {
});
}

const gotoApp = (app) => {
const gotoApp = (app: IApp) => {
window.location.href = app.href;
};

Expand All @@ -94,7 +94,7 @@ const Home : React.FC<Props> = ({ colors, mdx, appData, forceHttps }) => {
export default Home;

export const getServerSideProps : GetServerSideProps = async (context) => {
const appData = await getContainersWithLabels();
const appData = await getApps();

const defaultMdx = `
# Dave
Expand Down
48 changes: 48 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { getContainersWithLabels } from '../docker';
import { getServicesWithLabels } from '../kubernetes';

export interface AppProps {
id: string;
icon: string;
name: string;
status: string;
url?: string;
relativeSubdomain?: string;
}

export type Provider = 'docker' | 'kubernetes' | 'auto';

const detectProvider = (): Provider => {
// Check for Kubernetes environment (in-cluster)
if (process.env.KUBERNETES_SERVICE_HOST) {
return 'kubernetes';
}

// Check for Docker socket
try {
const fs = require('fs');
if (fs.existsSync('/var/run/docker.sock')) {
return 'docker';
}
} catch {
// Ignore errors
}

// Default to docker for backwards compatibility
return 'docker';
};

export const getApps = async (): Promise<AppProps[]> => {
const configuredProvider = (process.env.DAVE_PROVIDER || 'auto').toLowerCase() as Provider;
const provider = configuredProvider === 'auto' ? detectProvider() : configuredProvider;

console.log(`[Dave] Using provider: ${provider}`);

switch (provider) {
case 'kubernetes':
return getServicesWithLabels();
case 'docker':
default:
return getContainersWithLabels();
}
};
Loading