Skip to content
Merged
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
61 changes: 59 additions & 2 deletions docs/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -3297,9 +3297,66 @@ You can also use this widget to display an image, wither locally or from a remot

### API Response

Directly output plain-text response from any API-enabled service.
Display data from any HTTP/JSON API endpoint. Fetch a URL, then declaratively map response fields to labelled, formatted values.
This was very much inspired by, and is broadly compatible with [Homepage's customapi widget](https://gethomepage.dev/widgets/services/customapi/)

// Coming soon...
#### Options

**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`url`** | `string` | Required | The full URL of the API endpoint to fetch
**`refreshInterval`** | `number` | _Optional_ | How often to refetch, in milliseconds. Defaults to `10000` (10s). If omitted, the widget-level `updateInterval` (in seconds) is used instead
**`username`** | `string` | _Optional_ | Username for HTTP basic auth
**`password`** | `string` | _Optional_ | Password for HTTP basic auth
**`method`** | `string` | _Optional_ | HTTP method, e.g. `GET` (default) or `POST`
**`headers`** | `object` | _Optional_ | An object of custom request headers
**`requestBody`** | `string` or `object` | _Optional_ | Request body for non-GET methods. Prefer an object. A raw string is JSON-encoded again, so don't pre-stringify
**`display`** | `string` | _Optional_ | Layout for the fields: `block` (default, label and value on one row) or `list` (value stacked under the label)
**`mappings`** | `array` | _Optional_ | The fields to display from the response (see below). If omitted, the raw response root is shown

Each item in `mappings` accepts:

**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`field`** | `string` | _Optional_ | Dot-path to the value, e.g. `path.to.key` or `items.0.name`. Omit to use the response root (useful with `size`)
**`label`** | `string` | _Optional_ | Label shown beside the value
**`format`** | `string` | _Optional_ | One of `text` (default), `number`, `percent`, `date`, `relativeDate` or `size`
**`locale`** | `string` | _Optional_ | Locale for `number`/`percent`/`date`/`relativeDate`, e.g. `nl`. Defaults to the browser locale
**`dateStyle`** | `string` | _Optional_ | For `date` format. One of `full`, `long` (default), `medium`, `short`
**`timeStyle`** | `string` | _Optional_ | For `date` format. One of `full`, `long`, `medium`, `short`
**`style`** | `string` | _Optional_ | For `relativeDate` format. One of `long` (default), `short`, `narrow`
**`numeric`** | `string` | _Optional_ | For `relativeDate` format. One of `always` (default) or `auto`
**`additionalField`** | `object` | _Optional_ | A secondary value shown next to the main one. Accepts `field`, `format` (and its options), plus `color` - one of `theme`, `adaptive` (action colour from the value's sign: positive green, negative red), `black` or `white`

Notes:
- **`percent`** treats the value as an already-computed percentage, so `42` is shown as `42%`
- **`size`** returns the number of items in an array (or keys in an object) — pair it with an omitted `field` to count the response root
- For APIs that don't send CORS headers (most self-hosted services), set the widget-level `useProxy: true` to route the request through Dashy's server-side proxy

#### Example

```yaml
- type: customapi
options:
url: https://api.github.com/repos/lissy93/dashy
refreshInterval: 60000
mappings:
- field: stargazers_count
label: Stars
format: number
- field: open_issues_count
label: Open Issues
format: number
- field: pushed_at
label: Last Push
format: relativeDate
```

#### Info

- **CORS**: 🟠 Depends on target API (use `useProxy: true` if blocked)
- **Auth**: 🟠 Optional (basic auth or custom headers)
- **Price**: 🟢 Free

---

Expand Down
147 changes: 147 additions & 0 deletions src/components/Widgets/CustomApi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<template>
<div class="customapi-wrapper" :class="display">
<div class="row" v-for="(row, i) in results" :key="i">
<span class="lbl" v-if="row.label">{{ row.label }}</span>
<span class="val">
{{ row.value }}
<span
v-if="row.additional"
class="additional"
:class="`color-${row.additional.color || 'default'}`"
>{{ row.additional.value }}</span>
</span>
</div>
</div>
</template>

<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { resolveField, formatValue, adaptiveColor } from '@/utils/CustomApiHelpers';

export default {
mixins: [WidgetMixin],
data() {
return {
results: [],
};
},
computed: {
url() {
return this.parseAsEnvVar(this.options.url);
},
/* Basic-auth header, when username and password are supplied */
authHeaders() {
if (this.options.username && this.options.password) {
const username = this.parseAsEnvVar(this.options.username);
const password = this.parseAsEnvVar(this.options.password);
return { Authorization: `Basic ${window.btoa(`${username}:${password}`)}` };
}
return {};
},
/* User headers (env-vars resolved) merged over the auth header */
mergedHeaders() {
const userHeaders = this.options.headers || {};
const resolved = {};
Object.keys(userHeaders).forEach((key) => {
resolved[key] = this.parseAsEnvVar(userHeaders[key]);
});
const headers = { ...this.authHeaders, ...resolved };
// Default a JSON content-type for body-bearing methods, unless the user set one
const hasBody = this.options.requestBody != null && this.method !== 'GET' && this.method !== 'HEAD';
const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === 'content-type');
if (hasBody && !hasContentType) headers['Content-Type'] = 'application/json';
return headers;
},
method() {
return (this.options.method || 'GET').toUpperCase();
},
display() {
return this.options.display === 'list' ? 'list' : 'block';
},
mappings() {
const { mappings } = this.options;
return Array.isArray(mappings) && mappings.length ? mappings : [{ label: '', format: 'text' }];
},
/* Prefer homepage-style refreshInterval (ms), else fall back to native updateInterval (secs) */
updateInterval() {
const ms = this.options.refreshInterval;
if (ms === 0 || ms === false) return 0;
if (typeof ms === 'number' && ms >= 1000) return ms;
const secs = this.options.updateInterval;
if (typeof secs === 'boolean') return secs ? 30000 : 0;
if (typeof secs === 'number' && secs >= 2 && secs <= 7200) return secs * 1000;
return 10000;
},
},
methods: {
fetchData() {
if (!this.url) { this.error('A `url` is required'); this.finishLoading(); return; }
this.makeRequest(this.url, this.mergedHeaders, this.method, this.options.requestBody)
.then(this.processData)
.catch(() => { /* error already surfaced by the mixin */ });
},
/* Map each configured field to a labelled, formatted value */
processData(data) {
try {
this.results = this.mappings.map((m) => ({
label: m.label || '',
value: formatValue(resolveField(data, m.field), m, data),
additional: this.buildAdditional(m.additionalField, data),
}));
} catch (e) {
this.error('Failed to parse API response', e);
}
},
/* Resolve an optional secondary value; `adaptive` colour derives from the value's sign */
buildAdditional(field, data) {
if (!field) return null;
const raw = resolveField(data, field.field);
return {
value: formatValue(raw, field, data),
color: field.color === 'adaptive' ? adaptiveColor(raw) : (field.color || ''),
};
},
},
};
</script>

<style scoped lang="scss">
.customapi-wrapper {
color: var(--widget-text-color);
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.1rem;
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
.lbl {
font-weight: bold;
margin-right: 0.5rem;
}
.val {
font-family: var(--font-monospace);
text-align: right;
min-width: 0;
overflow-wrap: anywhere;
.additional {
margin-left: 0.5rem;
opacity: var(--dimming-factor);
&.color-theme { color: var(--primary); }
&.color-success { color: var(--success); }
&.color-warning { color: var(--warning); }
&.color-error { color: var(--error); }
&.color-info { color: var(--info); }
&.color-black { color: #000; }
&.color-white { color: #fff; }
}
}
}
&.list .row {
flex-direction: column;
align-items: flex-start;
.val { text-align: left; }
}
}
</style>
2 changes: 2 additions & 0 deletions src/components/Widgets/WidgetBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const COMPAT = {
'crypto-watch-list': 'CryptoWatchList',
'custom-search': 'CustomSearch',
'custom-list': 'CustomList',
customapi: 'CustomApi',
'custom-api': 'CustomApi',
'cve-vulnerabilities': 'CveVulnerabilities',
'domain-monitor': 'DomainMonitor',
'drone-ci': 'DroneCi',
Expand Down
80 changes: 80 additions & 0 deletions src/utils/CustomApiHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/* Field resolution and value formatting for the CustomApi widget */

/* Get a nested value from an object by dot-path, e.g. 'a.b.0.c'. Empty path returns the root */
export const resolveField = (obj, path) => {
if (!path) return obj;
return String(path).split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj);
};

/* Map a value's sign to an action colour, for `color: adaptive` fields. Non-numeric stays neutral */
export const adaptiveColor = (raw) => {
const n = Number(raw);
if (Number.isNaN(n)) return '';
if (n > 0) return 'success';
if (n < 0) return 'error';
return 'info';
};

/* Largest-first periods, used to pick a unit for relative dates */
const RELATIVE_UNITS = [
{ unit: 'year', secs: 31557600 },
{ unit: 'month', secs: 2628000 },
{ unit: 'week', secs: 604800 },
{ unit: 'day', secs: 86400 },
{ unit: 'hour', secs: 3600 },
{ unit: 'minute', secs: 60 },
{ unit: 'second', secs: 1 },
];

/* Format a date as relative to now, e.g. '2 days ago' or 'in 3 hours' */
const formatRelativeDate = (raw, mapping, locale) => {
const time = new Date(raw).getTime();
if (Number.isNaN(time)) return String(raw);
const diffSecs = (time - Date.now()) / 1000;
const period = RELATIVE_UNITS.find((p) => Math.abs(diffSecs) >= p.secs)
|| RELATIVE_UNITS[RELATIVE_UNITS.length - 1];
const rtf = new Intl.RelativeTimeFormat(locale, {
style: mapping.style || 'long',
numeric: mapping.numeric || 'always',
});
return rtf.format(Math.round(diffSecs / period.secs), period.unit);
};

/* Format a raw value per a mapping's `format`. `root` is the full response, used by `size` */
export const formatValue = (raw, mapping = {}, root) => {
const format = mapping.format || 'text';
const locale = mapping.locale || navigator.language;

// `size` counts array elements / object keys (root when no field is given)
if (format === 'size') {
const target = mapping.field == null ? root : raw;
if (Array.isArray(target)) return String(target.length);
if (target && typeof target === 'object') return String(Object.keys(target).length);
return target == null ? '' : String(target);
}

if (raw == null) return '';

switch (format) {
case 'number': {
const n = Number(raw);
return Number.isNaN(n) ? String(raw) : new Intl.NumberFormat(locale).format(n);
}
case 'percent': {
const n = Number(raw);
return Number.isNaN(n) ? String(raw)
: new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format(n / 100);
}
case 'date': {
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return String(raw);
const opts = { dateStyle: mapping.dateStyle || 'long' };
if (mapping.timeStyle) opts.timeStyle = mapping.timeStyle;
return new Intl.DateTimeFormat(locale, opts).format(date);
}
case 'relativeDate':
return formatRelativeDate(raw, mapping, locale);
default:
return String(raw);
}
};
Loading